17 Commits
Author SHA1 Message Date
Nixevol 8b845843d6 fix: 完成 broker 复审 B-03 至 B-12
每连接异步下发与背压、写出后断开、校验当前连接与订阅、生命周期串行、登录条件更新、闲置按在线计、认证超时并发与 Shutdown 0x8B。
2026-09-30 15:24:15 +08:00
Nixevol 42160720bd fix: 认证失败不泄漏连接表并脱敏 mochi 整包日志 2026-09-30 15:08:03 +08:00
Nixevol b5b63ed070 fix: 大帧名额按 PacketID 在 PUBACK 与断线时归还 2026-09-30 15:05:42 +08:00
Nixevol da43385c4f fix: 在 OnConnect 把 mochi 发送配额置 0 规避死锁 2026-09-30 14:58:45 +08:00
Nixevol 4059a1576b fix: 消除合并后的变量遮蔽以通过检查 2026-09-30 12:12:13 +08:00
Nixevol 7b209ce3d6 docs: 记录 issue #3 下行死锁未修 2026-09-30 12:11:07 +08:00
Nixevol bb8ce5f178 fix: 合并 Prometheus 指标接线 2026-09-30 12:10:44 +08:00
Nixevol 60bc873ef6 fix: 合并停用删除重置密码发 fatal 与 revoked 2026-09-30 12:08:51 +08:00
Nixevol 613f4bffa4 fix: 合并自助注册按 trusted_proxies 解析客户端 IP 2026-09-30 12:08:21 +08:00
Nixevol 99c134b1ce fix: 合并解散群 scheduled 回执改为 rejected 2026-09-30 12:07:50 +08:00
Nixevol b723ff13cf fix: 合并管理员 IP 锁定不再阻断已认证请求 2026-09-30 12:07:27 +08:00
Nixevol 1d6be59652 fix: 停用删除重置密码发 fatal 并注入 Downlink 发 revoked 2026-09-30 10:55:25 +08:00
Nixevol b4789fc9e2 fix: 接线 Prometheus 指标到连接与投递事件 2026-09-30 10:51:01 +08:00
Nixevol de2c64d111 fix: 解散群作废 scheduled 时消息级回执改用 rejected 2026-09-30 10:45:28 +08:00
Nixevol 73ee4e74c7 fix: 自助注册按 trusted_proxies 解析客户端 IP 2026-09-30 10:44:43 +08:00
Nixevol 8b4da3dc05 fix: 管理员 IP 锁定不再阻断已认证 Cookie 与 API 令牌 2026-09-30 10:43:13 +08:00
Nixevol 479a08ee11 test: 补齐短时间可测验收并修复建群下行卡住 2026-09-30 10:20:47 +08:00
44 changed files with 4153 additions and 265 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ docker compose -f deploy/docker-compose.yml up -d
- [产品需求](docs/PRD.md) - [产品需求](docs/PRD.md)
- [开发说明](docs/DEVELOPMENT.md) - [开发说明](docs/DEVELOPMENT.md)
- [运维手册](docs/OPS.md) - [运维手册](docs/OPS.md)
- [验收对照表](test/accept/ACCEPTANCE.md)(含未测项) - [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 短时间项已通过;长时/环境限制见备注与 [OPS.md](docs/OPS.md) 第 9 节)
- [开发任务](docs/TASKS.md) - [开发任务](docs/TASKS.md)
- [与文档的偏差](docs/DEVIATIONS.md) - [与文档的偏差](docs/DEVIATIONS.md)
@@ -0,0 +1,196 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/config"
)
// TestUplinkDisableFatalAndRevoked 验证停用在线端收到 fatal,已推送投递收到 revoked。
func TestUplinkDisableFatalAndRevoked(t *testing.T) {
dataDir := t.TempDir()
cfgPath := writeTestConfig(t, dataDir)
initAdminForTest(t, dataDir)
enableRegistration(t, dataDir, "uplink-code")
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
if vErr := cfg.Validate(); vErr != nil {
t.Fatal(vErr)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() { errCh <- runServe(ctx, cfg) }()
defer func() {
cancel()
select {
case err := <-errCh:
if err != nil {
t.Errorf("serve exit: %v", err)
}
case <-time.After(15 * time.Second):
t.Error("serve did not stop")
}
}()
addr := waitListenAddr(t, dataDir, 15*time.Second)
base := "http://" + addr
registerEP(t, base, "alice", "password12", "Alice")
registerEP(t, base, "bob", "password12", "Bob")
alice := mqttSessionLogin(t, base, "alice", "password12")
defer alice.Close()
bob := mqttSessionLogin(t, base, "bob", "password12")
defer bob.Close()
delay0 := int64(0)
sendResp := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "s1", "id": "dm-fatal-1",
"to": map[string]any{"kind": "endpoint", "id": "bob"},
"body": map[string]any{"enc": "utf8", "data": "to-void"},
"delay_ms": delay0,
})
if !sendResp.OK {
t.Fatalf("send: %+v", sendResp)
}
msg := bob.WaitType(t, "msg", 8*time.Second)
if msg["id"] != "dm-fatal-1" {
t.Fatalf("bob msg=%v", msg)
}
admin := adminHTTPClient(t, base)
disableEP(t, admin, base, "bob")
fatal := bob.WaitType(t, "fatal", 8*time.Second)
if fatal["reason"] != "disabled" {
t.Fatalf("fatal=%v", fatal)
}
revoked := bob.WaitType(t, "revoked", 8*time.Second)
if revoked["id"] != "dm-fatal-1" || revoked["reason"] != "endpoint_disabled" {
t.Fatalf("revoked=%v", revoked)
}
}
// TestUplinkResetPasswordFatal 验证重置登录密码后在线端收到 fatal(password_reset)。
func TestUplinkResetPasswordFatal(t *testing.T) {
dataDir := t.TempDir()
cfgPath := writeTestConfig(t, dataDir)
initAdminForTest(t, dataDir)
enableRegistration(t, dataDir, "uplink-code")
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
if vErr := cfg.Validate(); vErr != nil {
t.Fatal(vErr)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() { errCh <- runServe(ctx, cfg) }()
defer func() {
cancel()
select {
case err := <-errCh:
if err != nil {
t.Errorf("serve exit: %v", err)
}
case <-time.After(15 * time.Second):
t.Error("serve did not stop")
}
}()
addr := waitListenAddr(t, dataDir, 15*time.Second)
base := "http://" + addr
registerEP(t, base, "carol", "password12", "Carol")
carol := mqttSessionLogin(t, base, "carol", "password12")
defer carol.Close()
admin := adminHTTPClient(t, base)
resetLoginPassword(t, admin, base, "carol", "password99xx")
fatal := carol.WaitType(t, "fatal", 8*time.Second)
if fatal["reason"] != "password_reset" {
t.Fatalf("fatal=%v", fatal)
}
}
func adminHTTPClient(t *testing.T, base string) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
client := &http.Client{Jar: jar, Timeout: 10 * time.Second}
loginBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "test-admin-password-xx",
})
resp, err := client.Post(base+"/api/admin/login", "application/json", bytes.NewReader(loginBody))
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin login: %d %s", resp.StatusCode, raw)
}
return client
}
func disableEP(t *testing.T, client *http.Client, base, id string) {
t.Helper()
req, err := http.NewRequest(http.MethodPatch, base+"/api/admin/endpoints/"+id,
strings.NewReader(`{"enabled":false}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Nixmsg-Request", "1")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("disable %s: %d %s", id, resp.StatusCode, raw)
}
}
func resetLoginPassword(t *testing.T, client *http.Client, base, id, password string) {
t.Helper()
body := `{"login_password":"` + password + `"}`
req, err := http.NewRequest(http.MethodPost, base+"/api/admin/endpoints/"+id+"/reset-login-password",
strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Nixmsg-Request", "1")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("reset password %s: %d %s", id, resp.StatusCode, raw)
}
}
+56 -8
View File
@@ -71,6 +71,10 @@ func runServe(ctx context.Context, cfg config.Config) error {
loginLocks := auth.NewLoginLocks() loginLocks := auth.NewLoginLocks()
memConns := message.NewMemoryConns() memConns := message.NewMemoryConns()
msgLim := message.LimitsFromFullConfig(cfg) msgLim := message.LimitsFromFullConfig(cfg)
metricsReg := metrics.New()
db.Queue.OnBatchCommit = func(d time.Duration) {
metricsReg.WriteCommitSeconds.Observe(d.Seconds())
}
login := broker.NewLogin(broker.LoginOptions{ login := broker.NewLogin(broker.LoginOptions{
DB: db, DB: db,
@@ -84,11 +88,13 @@ func runServe(ctx context.Context, cfg config.Config) error {
msgApp := message.New(db, msgLim, hashPool, msgApp := message.New(db, msgLim, hashPool,
message.WithLocks(loginLocks), message.WithLocks(loginLocks),
message.WithConnRegistry(memConns), message.WithConnRegistry(memConns),
message.WithMetrics(metricsReg),
) )
uplink := &appUplink{ uplink := &appUplink{
msg: msgApp, msg: msgApp,
conns: memConns, conns: memConns,
log: slog.Default(), log: slog.Default(),
metrics: metricsReg,
} }
sess := broker.NewSession(broker.SessionOptions{ sess := broker.NewSession(broker.SessionOptions{
Login: login, Login: login,
@@ -109,6 +115,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
Authenticator: login, Authenticator: login,
Uplink: sess, Uplink: sess,
Logger: slog.Default(), Logger: slog.Default(),
Metrics: metricsReg,
OnPublishDropped: func(dropCtx context.Context, endpointID string, connID port.ConnID, payload []byte) { OnPublishDropped: func(dropCtx context.Context, endpointID string, connID port.ConnID, payload []byte) {
if dropErr := msgApp.OnPublishDropped(dropCtx, endpointID, connID, payload); dropErr != nil { if dropErr := msgApp.OnPublishDropped(dropCtx, endpointID, connID, payload); dropErr != nil {
slog.Error("on publish dropped", "endpoint", endpointID, "err", dropErr) slog.Error("on publish dropped", "endpoint", endpointID, "err", dropErr)
@@ -125,6 +132,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
message.WithLocks(loginLocks), message.WithLocks(loginLocks),
message.WithConnRegistry(memConns), message.WithConnRegistry(memConns),
message.WithDownlink(brk), message.WithDownlink(brk),
message.WithMetrics(metricsReg),
) )
uplink.msg = msgApp uplink.msg = msgApp
uplink.down = brk uplink.down = brk
@@ -137,6 +145,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
sess.SetPresence(presApp) sess.SetPresence(presApp)
uplink.presence = presApp uplink.presence = presApp
trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies)
idApp := identity.New(identity.Config{ idApp := identity.New(identity.Config{
DB: db, DB: db,
Hash: hashPool, Hash: hashPool,
@@ -144,7 +153,11 @@ func runServe(ctx context.Context, cfg config.Config) error {
Sessions: sessionTokens, Sessions: sessionTokens,
MaxScheduleSeconds: int64(cfg.Limits.MaxScheduleSeconds), MaxScheduleSeconds: int64(cfg.Limits.MaxScheduleSeconds),
Logger: slog.Default(), Logger: slog.Default(),
ConnControl: brk, ConnControl: nil, // B-04:踢线走 Session 钩子,避免 identity 20ms 异步 Disconnect
Downlink: brk,
ClientIP: func(r *http.Request) string {
return httpx.ClientIP(r, trustedNets)
},
}) })
uplink.identity = idApp uplink.identity = idApp
@@ -161,7 +174,6 @@ func runServe(ctx context.Context, cfg config.Config) error {
return fmt.Errorf("message recover: %w", recoverErr) return fmt.Errorf("message recover: %w", recoverErr)
} }
trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies)
adminHandler := admin.New(admin.Deps{ adminHandler := admin.New(admin.Deps{
DB: db, DB: db,
Hash: hashPool, Hash: hashPool,
@@ -173,6 +185,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
Groups: groupApp, Groups: groupApp,
Config: cfg, Config: cfg,
Version: Version, Version: Version,
// Kick:只断开,令牌不变,SDK 重连(PRD 踢下线)。
KickEndpoint: func(kickCtx context.Context, endpointID string) (bool, error) { KickEndpoint: func(kickCtx context.Context, endpointID string) (bool, error) {
if _, found := brk.ConnInfoOf(endpointID); !found { if _, found := brk.ConnInfoOf(endpointID); !found {
return false, nil return false, nil
@@ -182,9 +195,36 @@ func runServe(ctx context.Context, cfg config.Config) error {
} }
return true, nil return true, nil
}, },
// 停用/删除/重置:先 fatal 再断开(DEVELOPMENT 6.8)。
DisableKick: func(kickCtx context.Context, endpointID string) (bool, error) {
if _, found := brk.ConnInfoOf(endpointID); !found {
return false, nil
}
if disableErr := sess.Disable(kickCtx, endpointID); disableErr != nil {
return false, disableErr
}
return true, nil
},
DeleteKick: func(kickCtx context.Context, endpointID string) (bool, error) {
if _, found := brk.ConnInfoOf(endpointID); !found {
return false, nil
}
if deleteErr := sess.Deleted(kickCtx, endpointID); deleteErr != nil {
return false, deleteErr
}
return true, nil
},
PasswordResetKick: func(kickCtx context.Context, endpointID string) (bool, error) {
if _, found := brk.ConnInfoOf(endpointID); !found {
return false, nil
}
if resetErr := sess.ResetPassword(kickCtx, endpointID); resetErr != nil {
return false, resetErr
}
return true, nil
},
}) })
metricsReg := metrics.New()
buildHandlers := func(proxies *listener.ProxySet) listener.Handlers { buildHandlers := func(proxies *listener.ProxySet) listener.Handlers {
return listener.Handlers{ return listener.Handlers{
MQTT: brk.WSHandler(proxies), MQTT: brk.WSHandler(proxies),
@@ -266,10 +306,14 @@ func runServe(ctx context.Context, cfg config.Config) error {
loopCtx, loopCancel := context.WithCancel(ctx) loopCtx, loopCancel := context.WithCancel(ctx)
defer loopCancel() defer loopCancel()
go messageLoops(loopCtx, msgApp, memConns) go messageLoops(loopCtx, msgApp, memConns, db, hashPool, metricsReg)
<-ctx.Done() <-ctx.Done()
loopCancel() loopCancel()
// B-08:先对 MQTT 连接发 0x8B。HTTP Shutdown 与监听器完整停机顺序见 L-03。
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = brk.Shutdown(shutCtx)
shutCancel()
_ = lnSrv.Close() _ = lnSrv.Close()
drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second) drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer drainCancel() defer drainCancel()
@@ -279,7 +323,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
return nil return nil
} }
func messageLoops(ctx context.Context, msgApp *message.App, conns *message.MemoryConns) { func messageLoops(ctx context.Context, msgApp *message.App, conns *message.MemoryConns, db *store.DB, hashPool auth.HashPool, met *metrics.Registry) {
t := time.NewTicker(time.Second) t := time.NewTicker(time.Second)
defer t.Stop() defer t.Stop()
for { for {
@@ -299,6 +343,10 @@ func messageLoops(ctx context.Context, msgApp *message.App, conns *message.Memor
if err := msgApp.CleanupOnce(ctx, nowMs); err != nil { if err := msgApp.CleanupOnce(ctx, nowMs); err != nil {
slog.Error("cleanup once", "err", err) slog.Error("cleanup once", "err", err)
} }
if err := metrics.SampleStoreGauges(ctx, met, db.Read); err != nil {
slog.Debug("sample store gauges", "err", err)
}
metrics.SampleQueues(met, db.Queue.Len(), hashPool.QueueLen())
} }
} }
} }
+47 -16
View File
@@ -5,12 +5,15 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"sync"
"git.asio.asia/nixevol/NixMsg/internal/app/group" "git.asio.asia/nixevol/NixMsg/internal/app/group"
"git.asio.asia/nixevol/NixMsg/internal/app/identity" "git.asio.asia/nixevol/NixMsg/internal/app/identity"
"git.asio.asia/nixevol/NixMsg/internal/app/message" "git.asio.asia/nixevol/NixMsg/internal/app/message"
"git.asio.asia/nixevol/NixMsg/internal/app/port" "git.asio.asia/nixevol/NixMsg/internal/app/port"
"git.asio.asia/nixevol/NixMsg/internal/app/presence" "git.asio.asia/nixevol/NixMsg/internal/app/presence"
"git.asio.asia/nixevol/NixMsg/internal/broker"
"git.asio.asia/nixevol/NixMsg/internal/metrics"
"git.asio.asia/nixevol/NixMsg/internal/protocol" "git.asio.asia/nixevol/NixMsg/internal/protocol"
) )
@@ -23,9 +26,32 @@ type appUplink struct {
conns *message.MemoryConns conns *message.MemoryConns
down port.Downlink down port.Downlink
log *slog.Logger log *slog.Logger
metrics *metrics.Registry
lifeMu sync.Mutex
lifeLocks map[string]*sync.Mutex
hsMu sync.Mutex
handshake map[port.ConnID]string // 已 hello 的连接代号 → 端编号
}
func (u *appUplink) epLife(endpointID string) *sync.Mutex {
u.lifeMu.Lock()
defer u.lifeMu.Unlock()
if u.lifeLocks == nil {
u.lifeLocks = make(map[string]*sync.Mutex)
}
m := u.lifeLocks[endpointID]
if m == nil {
m = &sync.Mutex{}
u.lifeLocks[endpointID] = m
}
return m
} }
func (u *appUplink) OnSessionEstablished(ctx context.Context, conn port.ConnInfo) error { func (u *appUplink) OnSessionEstablished(ctx context.Context, conn port.ConnInfo) error {
lk := u.epLife(conn.EndpointID)
lk.Lock()
defer lk.Unlock()
u.conns.Set(conn.EndpointID, message.LiveConn{ u.conns.Set(conn.EndpointID, message.LiveConn{
ConnID: conn.ConnID, ConnID: conn.ConnID,
MaxPacketSize: conn.MaxPacketSize, MaxPacketSize: conn.MaxPacketSize,
@@ -34,6 +60,15 @@ func (u *appUplink) OnSessionEstablished(ctx context.Context, conn port.ConnInfo
} }
func (u *appUplink) OnHandshakeComplete(ctx context.Context, hs port.HandshakeInfo) error { func (u *appUplink) OnHandshakeComplete(ctx context.Context, hs port.HandshakeInfo) error {
lk := u.epLife(hs.EndpointID)
lk.Lock()
defer lk.Unlock()
u.hsMu.Lock()
if u.handshake == nil {
u.handshake = make(map[port.ConnID]string)
}
u.handshake[hs.ConnID] = hs.EndpointID
u.hsMu.Unlock()
live := message.LiveConn{ live := message.LiveConn{
ConnID: hs.ConnID, ConnID: hs.ConnID,
MaxReceiveBytes: hs.MaxReceiveBytes, MaxReceiveBytes: hs.MaxReceiveBytes,
@@ -48,11 +83,18 @@ func (u *appUplink) OnHandshakeComplete(ctx context.Context, hs port.HandshakeIn
} }
func (u *appUplink) OnDisconnect(ctx context.Context, conn port.ConnInfo, reason port.DisconnectReason) { func (u *appUplink) OnDisconnect(ctx context.Context, conn port.ConnInfo, reason port.DisconnectReason) {
lk := u.epLife(conn.EndpointID)
lk.Lock()
defer lk.Unlock()
if u.presence != nil { if u.presence != nil {
u.presence.ClearWatch(conn.ConnID) u.presence.ClearWatch(conn.ConnID)
} }
u.hsMu.Lock()
_, handshook := u.handshake[conn.ConnID]
delete(u.handshake, conn.ConnID)
u.hsMu.Unlock()
live, ok := u.conns.Current(conn.EndpointID) live, ok := u.conns.Current(conn.EndpointID)
isCurrent := ok && live.ConnID == conn.ConnID isCurrent := ok && live.ConnID == conn.ConnID && handshook
if err := u.msg.OnDisconnect(ctx, conn.EndpointID, conn.ConnID, isCurrent); err != nil { if err := u.msg.OnDisconnect(ctx, conn.EndpointID, conn.ConnID, isCurrent); err != nil {
u.log.Error("message disconnect", "endpoint", conn.EndpointID, "err", err) u.log.Error("message disconnect", "endpoint", conn.EndpointID, "err", err)
} }
@@ -255,6 +297,9 @@ func (u *appUplink) replyErr(ctx context.Context, conn port.ConnInfo, rid, code,
if rid == "" { if rid == "" {
rid = "0" rid = "0"
} }
if u.metrics != nil && code != "" {
u.metrics.ErrorsTotal.WithLabelValues(code).Inc()
}
resp := protocol.Resp{ resp := protocol.Resp{
V: protocol.Version, V: protocol.Version,
Type: protocol.TypeResp, Type: protocol.TypeResp,
@@ -275,7 +320,7 @@ func (u *appUplink) publishResp(ctx context.Context, conn port.ConnInfo, resp pr
return return
} }
if live, ok := u.conns.Current(conn.EndpointID); ok && live.ConnID == conn.ConnID { if live, ok := u.conns.Current(conn.EndpointID); ok && live.ConnID == conn.ConnID {
limit := respPayloadLimit(live.MaxPacketSize, live.MaxReceiveBytes) limit := broker.EffectivePayloadLimit(live.MaxPacketSize, live.MaxReceiveBytes)
if limit > 0 && len(b) > limit { if limit > 0 && len(b) > limit {
tooLarge := protocol.Resp{ tooLarge := protocol.Resp{
V: protocol.Version, V: protocol.Version,
@@ -295,20 +340,6 @@ func (u *appUplink) publishResp(ctx context.Context, conn port.ConnInfo, resp pr
} }
} }
func respPayloadLimit(maxPacketSize uint32, maxRecvBytes int) int {
limit := 0
if maxRecvBytes > 0 {
limit = maxRecvBytes
}
if maxPacketSize > 0 {
n := int(maxPacketSize)
if limit == 0 || n < limit {
limit = n
}
}
return limit
}
func peekRID(payload []byte) string { func peekRID(payload []byte) string {
var peek struct { var peek struct {
RID string `json:"rid"` RID string `json:"rid"`
+217
View File
@@ -1062,3 +1062,220 @@
- 原因:避免四份说明与 SDK 线漂移。 - 原因:避免四份说明与 SDK 线漂移。
- 备选方案:在 docs/ 再建 SDK 汇总页。 - 备选方案:在 docs/ 再建 SDK 汇总页。
- 影响:无。 - 影响:无。
### Q accept-rest(补齐短时可测验收)2026-09-30
1. **补测 F03/F04/F07/F10/F11/F14/F15/F18;F19 引用既有 SDK 清单**
- 原条款:PRD 第 10 节;总控要求跳过 1000×10min、Linux netem 20%、1000 端全表 1s。
- 实际做法:`test/accept/rest_accept_test.go` 用随机端口与临时目录;`grace_seconds`/`ack_timeout_seconds` 调到数秒;`record_retention_days=0` 另起进程;F19 对照表改为通过并写明四套 SDK checklist 证据路径,本波不重跑全量。
- 原因:短时可测项应收口;长时/环境限制项不假装通过。
- 备选方案:专用压测机与 Linux 宿主再补长时项。
- 影响:`ACCEPTANCE.md` 汇总通过 23 / 失败 0 / 未测 0;长时子项仍写在备注。
2. **harness MQTT 握手后清除 SetDeadline**
- 原条款:`test/harness` 属总控;Dial 时 `SetDeadline(now+timeout)`。
- 实际做法:WebSocket 升级成功与 TCP dial 成功后 `SetDeadline(time.Time{})`,避免长会话在 dial timeout 到期后读写全部失败。
- 原因:F10 等短宽限仍需跨数秒保持连接;未清 deadline 时旧 10s dial 会在会话中途使 Recv 失败,表现为 `timeout waiting resp`。
- 备选方案:每次读写刷新 deadline(更繁琐)。
- 影响:跨线改了 harness;行为仅更正测试客户端,不改产品。
3. **F15 带密建群用独立短生命周期进程**
- 原条款:拉进群须当次带对话密码。
- 实际做法:主会话用 `group.create` 无密断言失败;带密成功在干净进程上立刻建群。
- 原因:与第 4 条同一死锁,补测时先用隔离进程覆盖校验路径。
- 备选方案:仅依赖第 4 条修复后在同一长会话上测 `group.add`。
- 影响:验收覆盖仍成立。
4. **群事件 `emit` 改为异步 PublishDown**
- 原条款:群变更向成员推 `group_event`(QoS 0)。
- 实际做法:`internal/app/group/app.go` 的 `emit` 在独立 goroutine 里延迟约 20ms 再 `PublishDown`,让上行 worker 先把 `resp` 推完。
- 原因:同一连接上 `group.create`/`group.add` 同步向本连接注入下行时,与 mochi InlineClient 互相等待,`resp` 回不去(`TestUplinkDMOfflineGroupRecall` 在清掉测试客户端 dial deadline 后稳定复现)。
- 备选方案:broker 层对 Inline 发布做无锁队列。
- 影响:`group_event` 可能略晚于 `resp` 到达;业务结果仍以 `resp` 为准。
### fix-issue-1
1. **管理员 IP 锁定不再阻断已认证会话**
- 原条款:PRD D18 / F02(密码锁只拦密码登录,不拦已有会话令牌);DEVELOPMENT 第 5/8 节(管理员登录锁定、错误令牌按 IP 计入锁定);issue #1。
- 实际做法:去掉 `internal/admin/auth.go` 的 `auth()` 鉴权前 `Check(LockAdminIP)`;登录入口仍 `Check`/`Fail`,错误或停用 API 令牌仍经 `authFail` 计入锁定。有效 Cookie 与合法 Bearer 在锁定期可继续调管理接口。
- 原因:先前把「防暴力登录」扩成「封整个管理面」,同 NAT 下刷错误 Bearer 即可锁死已登录管理员,与端侧 nst_ 重连语义不一致。
- 备选方案:锁定期对 Cookie 与令牌也拒绝(否决,违背 D18 对齐)。
- 影响:仅管理后台鉴权中间件;端侧登录锁定未改。
### fix-issue-6
1. **解散群时 scheduled 消息级回执 state 改为 rejected**
- 原条款:DEVELOPMENT 6.4 消息级作废写 `endpoint_id` 空、`state=rejected`;7.6 解散群将 `scheduled` 消息改为 `completed`/`group_dissolved` 并写消息级回执。I4 旧实现把回执 state 误写成消息状态 `completed`。
- 实际做法:`internal/app/group/void.go` 的 `voidGroupAllTx` 插入回执时改用 `rejected`(与 I5.2 / identity lifecycle 一致);消息行仍为 `completed`。
- 原因:`completed` 不在回执枚举(accepted|recalled|expired|dropped|rejected)内,会误导 SDK/后台。
- 备选方案:沿用 `completed`(违反协议)。
- 影响:仅修正解散路径回执字段;不改 emit / PublishDown。
### fix-issue-2
1. **自助注册接入 trusted_proxies 客户端 IP**
- 原条款:PRD F23 / D18 注册安全码按来源 IP 锁定;DEVELOPMENT 4.5 来自受信代理时用 `X-Forwarded-For`;I1.4 曾写「经代理部署时接线方必须注入真实 IP」。
- 实际做法:`cmd/nixmsg/serve.go` 在 `identity.New` 注入与管理接口相同的 `httpx.ClientIP(r, trustedNets)`;不改锁定阈值与注册开关/安全码语义,不在 identity 内复制解析。
- 原因:L-WIRE 已挂注册 Handler,管理与 WS 已接 `trusted_proxies`,唯独注册漏接,反向代理后会把安全码锁定计到代理 IP。
- 备选方案:在 listener 层统一改写 `RemoteAddr` 后再交给注册 Handler。
- 影响:经受信代理开放注册时,输错安全码按真实客户端 IP 锁定。
### fix-issue-4
1. **接线补齐 Downlink 与停用/删除/重置密码 fatal**
- 原条款:DEVELOPMENT 6.8 / 7.6:停用、删除、重置密码先发 `fatal` 再断开;已推送作废投递尽力发 `revoked`。
- 实际做法:`serve` 给 `identity.New` 注入 `Downlink: brk`(作废后 `publishRevokes`);`DisableKick`/`DeleteKick`/`PasswordResetKick` 分别接到 `Session.Disable`/`Deleted`/`ResetPassword`;`KickEndpoint` 仍只 `Kick`。Identity 在未接 Kick 钩子时仍可用 `ConnControl` 异步断开兜底。
- 原因:原先 Downlink 未注入导致 revoked 丢失;管理路径只 `Kick`/`Disconnect` 不发 fatal。
- 备选方案:仅在 identity 内 `PublishDown(fatal)` 再断开;联调中该路径不如 Session.fatalKick 稳,故生产致命踢线统一走 Session。
- 影响:管理「踢下线」语义不变;SDK 可按 fatal 停止重连;接收方能收到已推送消息的 revoked。
### fix-issue-5
1. **指标在真实事件点打点,不新造名字**
- 原条款:PRD F22 / DEVELOPMENT 4.3 / issue #5;DEVIATIONS P4 已定名但从未接线。
- 实际做法:`nixmsg_connections{transport}` 在 broker `OnSessionEstablished`/`OnDisconnect` 末尾 Inc/Dec;`endpoints`/`deliveries_pending`/`messages_scheduled` 与写队列、哈希排队在 `messageLoops` 每秒按库/队列真实长度采样;`dispatch_to_push`/`ack` 直方图在成功推送与确认路径 Observe;写批提交耗时经 `store.Queue.OnBatchCommit`;`errors_total` 仅在上行 `replyErr` 时按错误码递增。门禁不变。
- 原因:空指标等于监控未交付;采样避免在每条写路径上改大段分发逻辑,并减小与 #3/#6 的合并面。
- 备选方案:全部改为纯事件加减(pending 等需在每处状态迁移维护计数)。
- 影响:仪表盘按既有名字即可看在线连接与待投递;无对应事件时计数保持 0,不做假数。
### 死锁未修(issue #3)
issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是核对过的调用链、三次尝试和仍留在 `main` 上的绕过。不改产品行为。
1. **现象**
- 清掉测试客户端 dial deadline 后,`cmd/nixmsg/uplink_integration_test.go` 的 `TestUplinkDMOfflineGroupRecall` 在 `group.create`(`rid=g1`)稳定超时,`resp` 回不去。
- 行号以本次合入后的 `main` 为准。
2. **调用链**
- 每端一条上行队列。`internal/broker/queue.go` 的 `loop`(约 41 行)同步调用 `HandleUplink`。
- `cmd/nixmsg/uplink.go` 的 `HandleUplink`(约 64 行)先 `dispatch`,再在同一调用栈里 `replyOK` → `publishResp`(约 273 行)用 QoS 1 调 `PublishDown`。`group.create` / `group.add` 走 `groups.Create` / `Add`,在返回 `resp` 之前就 `emit`(`internal/app/group/app.go` 约 168、223 行)。
- `Broker.PublishDown`(`internal/broker/broker.go` 约 202 行)进入 `server.Publish`(约 234 行)。`New` 设了 `InlineClient: true`(约 148 行,DEVELOPMENT 要求保持)。Inline 发布走到 `InjectPacket` → `OnPublish`。
- `internal/broker/hooks.go` 的 `OnPublish`(约 114 行)对 `cl.Net.Inline` 必须直接放行,否则 `PublishDown` 送不到订阅者(见本文更早的 InlineClient 偏差)。
- 群操作因此在同一次上行调用栈里,再向本连接 `PublishDown` `group_event`。`InjectPacket`(`NextPacketID` / 写路径)与读循环随后写 PUBACK 抢同一把 Client 锁,两边互等,`resp` 出不去。
- `presence.notify`(`internal/app/presence/app.go` 约 317 行)仍在业务调用栈里同步 `PublishDown`(约 341 行),不在 20ms 绕过的覆盖范围内。
3. **已尝试**
- 尝试 1:`emit` 改成立刻起 goroutine 做 `PublishDown`。仍死锁,因为 `group_event` 与同连接上的 `resp` 一起抢注入。
- 尝试 2(已在 `main`,来自 `479a08e` 的 Q accept-rest 第 4 条):`emit` 起 goroutine 后 `time.Sleep(20ms)` 再下发(`internal/app/group/app.go` 约 672–685 行),让 `resp` 先出去。当时 `task check` 通过。这是时间差绕过,不是根因修复;presence 以及其他同步 `PublishDown` 仍可能卡。
- 尝试 3(负责人叫停,未合入、未验证):工作树 `e:\code\NixMsg-wt\fix3`,分支 `feat/fix-3-downlink-deadlock` 停在 `479a08e`,与合入前的 `origin/main` 相同,**没有可合的提交**。未提交改动在 broker 层:
- `hooks.go` `OnPublish`:客户端 QoS≥1 先在读循环里 `WritePacket(PUBACK)`,再把包降成 QoS 0 并 `Ignore`,然后入队,返回 `nil`(不再用 `CodeSuccessIgnore` 让 mochi 事后写 PUBACK)。注释写明:若先入队,worker 的 `PublishDown` → `InjectPacket` → `NextPacketID` 会与随后的 `WritePacket(PUBACK)` 争 Client 锁。
- `queue.go` `loop`:`HandleUplink` 前后调用 `beginUplink` / `endUplink`。
- `broker.go`:该端 `depth>0` 时,对本端的 `PublishDown` 只推进延后队列,handler 返回后由上行 worker 再 `server.Publish`;其他端仍同步下发。`InlineClient` 放行未改。
- `group/app.go` `emit` 改回同步 `PublishDown`,去掉 20ms sleep。
- 同目录 `docs/DEVIATIONS.md` 有一段未提交的 `### fix-issue-3` 草稿。
- 本会话没有跑这套未提交代码的 `task check`,不把它们合进 `main`。工作树保留,给工程师看。
4. **仍在 main 上的做法**
- 继续用尝试 2 的 20ms 绕过。群事件可能略晚于 `resp`;业务结果仍以 `resp` 为准。
5. **建议的正确方向**
- 在 broker 把对本连接的下行 `InjectPacket` 与上行 worker 解耦:上行读循环先写完 PUBACK,处理 `HandleUplink` 期间不要同步向本连接注入;handler 返回后再发 `resp` 和 `group_event`。不要靠固定 `Sleep`。`InlineClient: true` 保持,`OnPublish` 对 InlineClient 继续放行。
- 覆盖 presence 等其他同步 `PublishDown`,而不只包一层 `emit`。
### 复审修复 B-01
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节装配 mochi;未写客户端 Receive Maximum。Gitea #8。
- 实际做法:`OnConnect` 在心跳校正后调用 `cl.State.Inflight.ResetSendQuota(0)`,不 fork mochi。CONNECT 声明的 Receive Maximum 小于 256 时打 warn,连接仍接受。应用层窗口(推送 32、回执 64、在途 resp 等)约束未确认的 QoS 1。
- 原因:mochi v2.7.9 在 `sendQuota>0` 时走 `NextImmediate` 递归读锁,并可因补发后删除 inflight 泄漏配额;已验证置 0 绕开整条路径。
- 备选方案:fork 修补 mochi(只修递归读锁仍观察到停滞)。
- 影响:服务端不再执行客户端 Receive Maximum;裸设备若带过小的 Receive Maximum,实际在途可能超过该值。
### 复审修复 B-02
- 日期:2026-09-30
- 原条款:DEVELOPMENT 7.5 / DEVIATIONS N1/N2 第 4 条:大帧名额在 PUBACK、丢弃、断线时归还。Gitea #9。
- 实际做法:`OnQosPublish` 按 PacketID 记下超过 64KiB 的出站包;`OnQosComplete`/`OnQosDropped`/断线按 ID 归还。获取名额最多等 5 秒,超时返回 `ErrLargeFrameTimeout`。Publish 未产生 inflight(无订阅者、队列丢弃)时立即归还。不采用「发布完成即归还」。
- 原因:mochi 传给 `OnQosComplete` 的是 PUBACK,没有载荷,旧实现从未归还。
- 备选方案:发布后立即归还(会把卡死点挪到消息包那份名额)。
- 影响:只在 broker 保留一份全局 64 名额;确认超时仍由消息线踢线/清标记触发断线归还。
### 复审修复 B-05
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节连接表;Gitea #12。
- 实际做法:`OnConnect` 只在认证通过时写入 `byClient`/`byConnID`;拒绝与内部错误不登记。`connState` 增加 `established` 与 `createdAt`,每分钟清扫未建立且已关闭超过 1 分钟的条目。按连接代号查找改为 O(1)。
- 原因:mochi 在认证失败路径不调用 `OnDisconnect`,旧实现会永久泄漏。
- 备选方案:失败路径也登记再在 Authenticate 返回 false 时删除(仍覆盖不了 CONNACK 失败)。
- 影响:失败连接不再占用查找路径;行为对客户端不变(仍回 0x86 或不回 CONNACK)。
### 复审修复 B-07
- 日期:2026-09-30
- 原条款:PRD §8 日志无正文、无密码、无令牌。Gitea #14。
- 实际做法:`broker.New` 给 mochi 包一层 slog.Handler,把 `packets.Packet` / `*packets.Packet` 换成类型、QoS、包号、主题、正文长度。
- 原因:默认 info 下第二个 CONNECT、3.1.1 发到错误主题等会把整包写入 JSON 日志。
- 备选方案:改 mochi 日志调用点(需 fork)。
- 影响:排障时看不到载荷与密码,只见摘要。
### 复审修复 B-03
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节每端串行队列;Gitea #10。不改 `PublishDown` 签名。
- 实际做法:每连接独立下行队列(256 帧 / 16MiB)和发送 goroutine。`PublishDown` 只入队;发送与上行读循环解耦。队列满返回 `ErrBackpressure`。
- 原因:同连接同步 `InjectPacket` 与读循环写 PUBACK 会互相等待。
- 备选方案:改 `PublishDown` 签名或继续用 20ms sleep。
- 影响:调用方入队即返回;慢客户端只挡住该连接的发送 goroutine。
### 复审修复 B-06
- 日期:2026-09-30
- 原条款:Gitea #13。`PublishDown` 校验当前连接与下行订阅;导出有效载荷上限。
- 实际做法:非空 `connID` 必须仍是当前连接。未订阅 down 返回 `ErrNotSubscribed`。导出 `EffectivePayloadLimit`(Maximum Packet Size 减 128 字节包头预留)。`uplink.publishResp` 改用该函数。新连接建立时把旧连接标为 `superseded`。
- 原因:旧连接或未订阅时写入会静默失败或写错连接。
- 备选方案:发送时再检查(入队后连接可能已换)。
- 影响:无订阅时下行立即失败,不再占用大帧名额。
### 复审修复 B-04
- 日期:2026-09-30
- 原条款:Gitea #11。写出后再断开,不用固定 sleep。`serve.go` 只改 `identity.New` 的 ConnControl。
- 实际做法:`PublishThenDisconnect` 把帧与断开原因一并入队,发送 goroutine 写完再 `Disconnect`。logout / fatalKick 改走该原语。`identity.New` 的 `ConnControl` 置 nil,踢线仍走 Session 钩子。
- 原因:固定 20ms/50ms sleep 在慢客户端上会先断开,在快路径上又多余等待。
- 备选方案:继续 sleep;或改 identity 生命周期(本线不改)。
- 影响:identity 未接 ConnControl 时不再自己 20ms 踢线,生产路径统一由 Session 写出后断开。
### 复审修复 B-09
- 日期:2026-09-30
- 原条款:Gitea #16。生命周期串行化。不改 presence/app.go。
- 实际做法:broker 与 `appUplink` 按端编号加锁串行 `OnSessionEstablished` / `OnDisconnect` / 握手。uplink 另记 hello 握手表,仅已握手连接的断开才按当前连接通知消息线。
- 原因:顶号时旧连接 `OnDisconnect` 可能和新连接登记交错。
- 备选方案:改 presence 在线表(超出本线允许文件)。
- 影响:未 hello 的断开不再把消息连接表当成已握手在线来清推送标记。
### 复审修复 B-10
- 日期:2026-09-30
- 原条款:Gitea #17。登录写库条件更新;hello 重读令牌。不改 identity/self.go。
- 实际做法:密码登录 `UPDATE ... WHERE COALESCE(session_hash,'') = 读到的旧值`,影响行数为 0 则 `ErrSessionWriteConflict`。hello 用 `TokenMatchesDB` 核对明文,库已被换则响应里不带回旧令牌。
- 原因:两处同时密码登录会互相覆盖;hello 可能把已作废明文交给客户端。
- 备选方案:写库后无条件返回本次签发明文。
- 影响:写冲突时 OnConnect 返回 error(不回 0x86),客户端按网络故障重连。
### 复审修复 B-11
- 日期:2026-09-30
- 原条款:Gitea #18。令牌闲置按在线计。
- 实际做法:闲置判断取 `session_used_at` / `online_since` / `offline_since` 的较新者;当前在线(`online_since >= offline_since`)视为未闲置。
- 原因:只看 `session_used_at` 会让长期在线却很少写库的令牌过期。
- 备选方案:在线时每小时强制刷新 used_at(已有 touch,但仍可能窗口不够)。
- 影响:在线设备不会因为闲置天数被踢;离线后从最后一次在线/离线时刻起算。
### 复审修复 B-12
- 日期:2026-09-30
- 原条款:Gitea #19。认证超时与每端校验并发。不改 auth 池/PHC。
- 实际做法:`Authenticate` 套 30 秒超时;argon2 `Verify` 前每端信号量 2。`OnConnect` 同样带 30 秒 ctx。
- 原因:慢哈希或卡住的校验会堵住 mochi 读循环;同一编号并发登录会打满全局哈希池。
- 备选方案:改全局 Pool 大小(超出允许文件)。
- 影响:超时表现为内部错误断开(不回 0x86)。
### 复审修复 B-08
- 日期:2026-09-30
- 原条款:Gitea #15。Shutdown API。完整 HTTP 停机依赖 L-03。
- 实际做法:`Broker.Shutdown` 对现有连接发 MQTT 5 `0x8B`,清空上行队列并 `Close`。`serve` 在 listener Close 之前调用。HTTP `Shutdown` 留给 L-03。
- 原因:只关 listener 时 MQTT 客户端看不到规范的停机原因码。
- 备选方案:等 L-03 一并做(本线仍提供 broker API,避免监听线无法调用)。
- 影响:进程退出时端会收到 server shutting down;监听器 HTTP 优雅停机仍未做。
+6 -14
View File
@@ -117,20 +117,12 @@ curl -sS -H "Authorization: Bearer $NIXMSG_METRICS_TOKEN" http://127.0.0.1:7443/
- 首次:`docker compose run --rm nixmsg admin init`,再 `up -d`。 - 首次:`docker compose run --rm nixmsg admin init`,再 `up -d`。
- 构建/推送 Task 目标见根目录 README(`q:docker-build` / `q:docker-push` / `q:docker-buildx`)。正式仓库推送在阶段 3。 - 构建/推送 Task 目标见根目录 README(`q:docker-build` / `q:docker-push` / `q:docker-buildx`)。正式仓库推送在阶段 3。
## 9. 验收未测项(勿当作已通过) ## 9. 验收与仍跳过的长时项
截至 Q4/Q5 文档定稿,对照表 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md) 中下列项仍为**未测**,运维与交付说明须保持该状态,不得宣称通过: F01–F23 短时间验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 23,失败 0,未测 0)。下列因环境或时长限制**未测**,不得宣称已通过:
| 编号 | 摘要 | - F03:1000 端全表 1 秒内返回、真拔网线后心跳超时离线
|---|---| - F08 / Q3:Linux netem 20% 丢包(本机 Windows)
| F03 | 断开后离线状态 / 目录全表 | - 压测:1000 连接保持 10 分钟、每秒 200 条
| F04 | presence 订阅通知 |
| F07 | 256 KiB 边界与接收上限 |
| F10 | 抖动宽限长短断线 |
| F11 | 发送方离线后定时到点 |
| F14 | 回执补送 |
| F15 | 对话密码授权链路 |
| F18 | 正文删除与记录天数 0 |
| F19 | 四种 SDK 统一接入清单(属 SDK 线,本波未在 Q 对照表复测) |
F22 标为通过的子集仅覆盖 init + 健康检查等;备份恢复、升级迁移、证书重载、Docker 全量、`/metrics` 抓取等仍见对照表备注中的未测说明。 F22 通过的子集仅覆盖 init + 健康检查等;备份恢复、升级迁移、证书重载、Docker 全量、`/metrics` 抓取等仍见对照表备注。
+27 -12
View File
@@ -17,7 +17,13 @@
## 2. F01–F23 验收结果 ## 2. F01–F23 验收结果
来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(生成时间 2026-09-30T00:26:12Z)。对照表汇总:通过 14,失败 0,未测 9。 来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(生成时间 2026-09-30T02:16:46Z)。对照表汇总:通过 23,失败 0,未测 0。
长时/环境限制项在对照表备注中保留「未测子项」说明,不单独占「未测」行:
- F03:未跑 1000 端全表 1s、真拔网线心跳超时(关连接模拟断线)
- F08:Linux netem 20% 丢包未测(本机 Windows)
- 压测:未跑 1000 连接保持 10 分钟
### 通过 ### 通过
@@ -25,14 +31,23 @@
|---|---| |---|---|
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | | F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 |
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | | F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 |
| F03 | 断开后状态及时变离线,全表可列出 |
| F04 | 只通知订阅了的端 |
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | | F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 |
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | | F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | | F08 | 弱网最终送达且应用层不重复,重启后续传 |
| F09 | 保留时间从发送时刻起算,超时过期 | | F09 | 保留时间从发送时刻起算,超时过期 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 |
| F11 | 发送方离线后到点仍发送 |
| F12 | 延迟窗口内撤回对方收不到 | | F12 | 延迟窗口内撤回对方收不到 |
| F13 | 未推送必撤成功;群部分确认得到部分撤回 | | F13 | 未推送必撤成功;群部分确认得到部分撤回 |
| F14 | 回执能补送给当时离线的发送方 |
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 |
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | | F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 |
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | | F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 |
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 |
| F19 | 四种 SDK 通过同一清单 |
| F20 | 裸 MQTT 能登录、收、确认、发 | | F20 | 裸 MQTT 能登录、收、确认、发 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | | F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 |
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | | F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 |
@@ -40,17 +55,7 @@
### 未测 ### 未测
| 编号 | 一句话 | 原因(摘自对照表) | 无整行未测项。子项因长时间或环境限制未测的见上「长时/环境限制」与对照表备注。
|---|---|---|
| F03 | 断开后状态及时变离线,全表可列出 | directory.list / 断开后离线状态未在本波单独断言 |
| F04 | 只通知订阅了的端 | presence.watch 订阅通知未覆盖 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 256 KiB 边界与接收上限未覆盖 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 抖动宽限长短断线未单独拨钟 |
| F11 | 发送方离线后到点仍发送 | 发送方离线后定时到点发送未覆盖 |
| F14 | 回执能补送给当时离线的发送方 | 回执补送未覆盖 |
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 对话密码授权链路未覆盖 |
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 正文删除与记录天数 0 未覆盖 |
| F19 | 四种 SDK 通过同一清单 | 对照表仍标未测(属 S1/S2);本轮交付回归已另跑四套 SDK 测试,见第 4 节 |
### 失败 ### 失败
@@ -96,6 +101,7 @@
- Q2 第一部分(已合并功能验收)2026-09-30 - Q2 第一部分(已合并功能验收)2026-09-30
- Q2 补齐 + Q3(本机 Windows)2026-09-30 - Q2 补齐 + Q3(本机 Windows)2026-09-30
- Q4 定稿 + Q5 文档 2026-09-30 - Q4 定稿 + Q5 文档 2026-09-30
- Q accept-rest 补测 2026-09-30
## 4. 本轮验证 ## 4. 本轮验证
@@ -111,6 +117,15 @@
| `sdk/python`:venv + `pytest` | 通过(22 passed);测完已删本地 `.venv` | | `sdk/python`:venv + `pytest` | 通过(22 passed);测完已删本地 `.venv` |
| `sdk/java`:`mvn test` | 通过(scoop maven 3.9.16;测完已删 `target`) | | `sdk/java`:`mvn test` | 通过(scoop maven 3.9.16;测完已删 `target`) |
其后在 `feat/accept-rest` 补齐短时间验收并更新对照表:
| 项 | 结果 |
|---|---|
| `go test ./test/accept/ -count=1`(含 F03/F04/F07/F10/F11/F14/F15/F18,F19 引用既有 SDK 清单) | 通过(约 24–27s) |
| 写入 `ACCEPTANCE.md` / `q2_results.json` | 通过 23,失败 0,未测 0 |
跳过:1000 连接 10 分钟浸泡、Linux netem 20% 丢包、F03 的 1000 端全表 1s 与真拔网线心跳超时。
## 5. 构建与启动 ## 5. 构建与启动
请直接按仓库文档操作,此处不重复步骤: 请直接按仓库文档操作,此处不重复步骤:
+76
View File
@@ -280,6 +280,82 @@ func TestLoginLock(t *testing.T) {
} }
} }
// TestAdminLockDoesNotBlockAuthedSession:密码失败触发 IP 锁后,
// 已有 Cookie 会话与合法 API 令牌仍可调管理接口;未认证密码登录仍被拒。
func TestAdminLockDoesNotBlockAuthedSession(t *testing.T) {
_, srv, cookieClient, _ := setup(t)
base := srv.URL
login(t, cookieClient, base)
res := postJSON(t, cookieClient, base+"/api/admin/tokens",
`{"name":"ops-lock"}`,
map[string]string{"X-Nixmsg-Request": "1"})
env := decodeEnv(t, res)
if res.StatusCode != 200 || !env.OK {
t.Fatalf("create token: %d %+v", res.StatusCode, env)
}
var created struct {
Token string `json:"token"`
}
if err := json.Unmarshal(env.Data, &created); err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
bad := &http.Client{}
res = postJSON(t, bad, base+"/api/admin/login",
`{"username":"admin","password":"wrong-password!!"}`, nil)
env = decodeEnv(t, res)
if i < 9 {
if res.StatusCode != 401 {
t.Fatalf("fail %d: want 401 got %d %+v", i, res.StatusCode, env)
}
continue
}
if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" {
t.Fatalf("10th fail want 429 rate_limited got %d %+v", res.StatusCode, env)
}
}
res = doReq(t, cookieClient, http.MethodGet, base+"/api/admin/me", "", nil)
env = decodeEnv(t, res)
if res.StatusCode != 200 || !env.OK {
t.Fatalf("cookie me after lock: want 200 got %d %+v", res.StatusCode, env)
}
var me map[string]any
_ = json.Unmarshal(env.Data, &me)
if me["auth"] != "cookie" {
t.Fatalf("cookie me auth=%v", me["auth"])
}
tokClient := &http.Client{}
hdr := map[string]string{"Authorization": "Bearer " + created.Token}
res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr)
env = decodeEnv(t, res)
if res.StatusCode != 200 || !env.OK {
t.Fatalf("token me after lock: want 200 got %d %+v", res.StatusCode, env)
}
_ = json.Unmarshal(env.Data, &me)
if me["auth"] != "token" {
t.Fatalf("token me auth=%v", me["auth"])
}
res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/overview", "", hdr)
env = decodeEnv(t, res)
if res.StatusCode != 200 || !env.OK {
t.Fatalf("token overview after lock: want 200 got %d %+v", res.StatusCode, env)
}
anon := &http.Client{}
res = postJSON(t, anon, base+"/api/admin/login",
`{"username":"admin","password":"`+testPassword+`"}`, nil)
env = decodeEnv(t, res)
if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" {
t.Fatalf("password login while locked want 429 got %d %+v", res.StatusCode, env)
}
}
func TestBadAPITokenCountsTowardLock(t *testing.T) { func TestBadAPITokenCountsTowardLock(t *testing.T) {
_, srv, _, _ := setup(t) _, srv, _, _ := setup(t)
base := srv.URL base := srv.URL
+2 -6
View File
@@ -43,12 +43,8 @@ func (h *Handler) auth(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := httpx.ClientIP(r, h.trusted) ip := httpx.ClientIP(r, h.trusted)
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked { // 锁定只拦密码登录(login.go)与错误令牌试错累计;
w.Header().Set("Retry-After", formatRetryAfter(retry)) // 已认证的 Cookie / 合法 API 令牌在锁定期仍可用(对齐 PRD D18)。
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
return
}
p, errCode, errMsg, status := h.authenticate(r, ip) p, errCode, errMsg, status := h.authenticate(r, ip)
if status != 0 { if status != 0 {
if status == http.StatusTooManyRequests { if status == http.StatusTooManyRequests {
+32 -5
View File
@@ -102,6 +102,33 @@ func (h *Handler) kickEndpoint(ctx context.Context, id string) (bool, error) {
return h.kick(ctx, id) return h.kick(ctx, id)
} }
func (h *Handler) passwordResetKick(ctx context.Context, id string) (bool, error) {
if h.resetKick != nil {
return h.resetKick(ctx, id)
}
return h.kickEndpoint(ctx, id)
}
func (h *Handler) afterDisableKick(ctx context.Context, id string) {
if h.disableKick != nil {
_, _ = h.disableKick(ctx, id)
return
}
if h.identity == nil {
_, _ = h.kickEndpoint(ctx, id)
}
}
func (h *Handler) afterDeleteKick(ctx context.Context, id string) {
if h.deleteKick != nil {
_, _ = h.deleteKick(ctx, id)
return
}
if h.identity == nil {
_, _ = h.kickEndpoint(ctx, id)
}
}
func (h *Handler) handleEndpointList(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleEndpointList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
limit := defaultListLimit limit := defaultListLimit
@@ -350,7 +377,7 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
return return
} }
if !*req.Enabled { if !*req.Enabled {
_, _ = h.kickEndpoint(r.Context(), id) h.afterDisableKick(r.Context(), id)
} }
} else if req.Enabled != nil && !*req.Enabled && wasEnabled { } else if req.Enabled != nil && !*req.Enabled && wasEnabled {
_, _ = h.kickEndpoint(r.Context(), id) _, _ = h.kickEndpoint(r.Context(), id)
@@ -381,7 +408,7 @@ func (h *Handler) handleEndpointDelete(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在") httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
return return
} }
_, _ = h.kickEndpoint(r.Context(), id) h.afterDeleteKick(r.Context(), id)
h.audit(actorString(p), "endpoint_delete", id, "ok", ip) h.audit(actorString(p), "endpoint_delete", id, "ok", ip)
httpx.WriteOK(w, map[string]any{}) httpx.WriteOK(w, map[string]any{})
} }
@@ -416,14 +443,14 @@ func (h *Handler) handleEndpointBatch(w http.ResponseWriter, r *http.Request) {
case "disable": case "disable":
found, opErr = h.setEndpointEnabled(r.Context(), id, false) found, opErr = h.setEndpointEnabled(r.Context(), id, false)
if found && opErr == nil { if found && opErr == nil {
_, _ = h.kickEndpoint(r.Context(), id) h.afterDisableKick(r.Context(), id)
} }
case "enable": case "enable":
found, opErr = h.setEndpointEnabled(r.Context(), id, true) found, opErr = h.setEndpointEnabled(r.Context(), id, true)
case "delete": case "delete":
found, opErr = h.deleteEndpointBasic(r.Context(), id) found, opErr = h.deleteEndpointBasic(r.Context(), id)
if found && opErr == nil { if found && opErr == nil {
_, _ = h.kickEndpoint(r.Context(), id) h.afterDeleteKick(r.Context(), id)
} }
} }
if opErr != nil { if opErr != nil {
@@ -512,7 +539,7 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在") httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
return return
} }
_, _ = h.kickEndpoint(r.Context(), id) _, _ = h.passwordResetKick(r.Context(), id)
h.audit(actorString(p), "endpoint_reset_login_password", id, "ok", ip) h.audit(actorString(p), "endpoint_reset_login_password", id, "ok", ip)
httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw}) httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw})
} }
+42 -30
View File
@@ -41,6 +41,12 @@ type Deps struct {
SecureCookies bool SecureCookies bool
// KickEndpoint 踢下线钩子(只断开连接);nil 时踢线为 no-op。 // KickEndpoint 踢下线钩子(只断开连接);nil 时踢线为 no-op。
KickEndpoint EndpointKickFunc KickEndpoint EndpointKickFunc
// PasswordResetKick 重置登录密码后踢线(应发 fatal);nil 时回退 KickEndpoint。
PasswordResetKick EndpointKickFunc
// DisableKick 停用后踢线(应发 fatal(disabled));nil 且已注入 Identity 时不再 Kick。
DisableKick EndpointKickFunc
// DeleteKick 删除后踢线(应发 fatal(deleted));nil 且已注入 Identity 时不再 Kick。
DeleteKick EndpointKickFunc
// Identity 端停用/启用/删除级联(I5);nil 时回退为仅改 enabled/删行。 // Identity 端停用/启用/删除级联(I5);nil 时回退为仅改 enabled/删行。
Identity identity.Service Identity identity.Service
@@ -54,20 +60,23 @@ type Deps struct {
// Handler 是可挂载的管理接口(路由前缀 /api/admin/)。 // Handler 是可挂载的管理接口(路由前缀 /api/admin/)。
type Handler struct { type Handler struct {
db *store.DB db *store.DB
hash auth.HashPool hash auth.HashPool
tokens auth.APITokens tokens auth.APITokens
locks auth.LoginLocks locks auth.LoginLocks
log *slog.Logger log *slog.Logger
trusted []*net.IPNet trusted []*net.IPNet
ttl time.Duration ttl time.Duration
forceSec bool forceSec bool
kick EndpointKickFunc kick EndpointKickFunc
identity identity.Service resetKick EndpointKickFunc
groups group.Service disableKick EndpointKickFunc
cfg config.Config deleteKick EndpointKickFunc
version string identity identity.Service
startedAt time.Time groups group.Service
cfg config.Config
version string
startedAt time.Time
mux *http.ServeMux mux *http.ServeMux
@@ -96,22 +105,25 @@ func New(d Deps) *Handler {
ver = "dev" ver = "dev"
} }
h := &Handler{ h := &Handler{
db: d.DB, db: d.DB,
hash: d.Hash, hash: d.Hash,
tokens: d.Tokens, tokens: d.Tokens,
locks: d.Locks, locks: d.Locks,
log: d.Logger, log: d.Logger,
trusted: d.TrustedProxies, trusted: d.TrustedProxies,
ttl: ttl, ttl: ttl,
forceSec: d.SecureCookies, forceSec: d.SecureCookies,
kick: d.KickEndpoint, kick: d.KickEndpoint,
identity: d.Identity, resetKick: d.PasswordResetKick,
groups: d.Groups, disableKick: d.DisableKick,
cfg: cfg, deleteKick: d.DeleteKick,
version: ver, identity: d.Identity,
startedAt: time.Now(), groups: d.Groups,
mux: http.NewServeMux(), cfg: cfg,
lastUsed: make(map[string]time.Time), version: ver,
startedAt: time.Now(),
mux: http.NewServeMux(),
lastUsed: make(map[string]time.Time),
} }
h.routes() h.routes()
return h return h
+14 -7
View File
@@ -660,6 +660,7 @@ func (a *App) emit(ctx context.Context, recipients []string, groupID, event, end
if a.down == nil { if a.down == nil {
return return
} }
_ = ctx
frame := protocol.GroupEvent{ frame := protocol.GroupEvent{
V: protocol.Version, Type: protocol.TypeGroupEvent, V: protocol.Version, Type: protocol.TypeGroupEvent,
GroupID: groupID, Event: event, EndpointID: endpointID, AtMs: atMs, GroupID: groupID, Event: event, EndpointID: endpointID, AtMs: atMs,
@@ -668,14 +669,20 @@ func (a *App) emit(ctx context.Context, recipients []string, groupID, event, end
if encErr != nil { if encErr != nil {
return return
} }
seen := map[string]struct{}{} // 异步且略推迟:必须让处理该端上行的 worker 先 PublishDown resp。
for _, id := range recipients { // 若与 resp 同时向本连接注入 group_event,会与 mochi InlineClient 互相等待。
if _, ok := seen[id]; ok { ids := append([]string(nil), recipients...)
continue go func() {
time.Sleep(20 * time.Millisecond)
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
_ = a.down.PublishDown(context.Background(), id, "", payload, port.PublishOpts{QoS: 0})
} }
seen[id] = struct{}{} }()
_ = a.down.PublishDown(ctx, id, "", payload, port.PublishOpts{QoS: 0})
}
} }
func encodeFrame(v any) ([]byte, error) { func encodeFrame(v any) ([]byte, error) {
+10
View File
@@ -296,6 +296,16 @@ WHERE m.id='gm1' AND d.endpoint_id='bob'`).Scan(&reason)
if err != nil || state != "completed" || mreason != "group_dissolved" { if err != nil || state != "completed" || mreason != "group_dissolved" {
t.Fatalf("state=%s reason=%s err=%v", state, mreason, err) t.Fatalf("state=%s reason=%s err=%v", state, mreason, err)
} }
// 消息级回执 state 须为协议枚举 rejected,不得写成消息状态 completed(issue #6 / DEVELOPMENT 6.4)
var rState, rReason, rEndpoint string
err = db.Read.QueryRow(`
SELECT state, reason, endpoint_id FROM receipts WHERE sender_id='alice' AND msg_id='gm2'`).Scan(&rState, &rReason, &rEndpoint)
if err != nil {
t.Fatalf("receipt for dissolved scheduled: %v", err)
}
if rState != "rejected" || rReason != "group_dissolved" || rEndpoint != "" {
t.Fatalf("receipt state=%q reason=%q endpoint=%q want rejected/group_dissolved/empty", rState, rReason, rEndpoint)
}
// 同编号新建群 // 同编号新建群
created2, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{ created2, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "6", V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "6",
+2 -1
View File
@@ -139,10 +139,11 @@ UPDATE messages SET state = 'completed', reason = ? WHERE seq = ? AND state = 's
return execErr return execErr
} }
if r.receipt != 0 { if r.receipt != 0 {
// 消息级作废回执:endpoint_id 空,state=rejected(DEVELOPMENT 6.4);消息行仍为 completed
if _, execErr := tx.Exec(` if _, execErr := tx.Exec(`
INSERT INTO receipts(sender_id, msg_id, endpoint_id, state, reason, created_at, acked) INSERT INTO receipts(sender_id, msg_id, endpoint_id, state, reason, created_at, acked)
VALUES(?,?,?,?,?,?,0)`, VALUES(?,?,?,?,?,?,0)`,
r.senderID, r.msgID, "", "completed", reasonGroupDissolved, nowMs); execErr != nil { r.senderID, r.msgID, "", "rejected", reasonGroupDissolved, nowMs); execErr != nil {
return execErr return execErr
} }
} }
+10 -1
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port" "git.asio.asia/nixevol/NixMsg/internal/app/port"
"git.asio.asia/nixevol/NixMsg/internal/protocol" "git.asio.asia/nixevol/NixMsg/internal/protocol"
@@ -22,6 +23,9 @@ const (
eventLeft = "left" eventLeft = "left"
eventMemberRemoved = "member_removed" eventMemberRemoved = "member_removed"
eventDissolved = "dissolved" eventDissolved = "dissolved"
// kickFlushDelay 给接线方 Session.Disable/Deleted 留出发 fatal 的窗口。
kickFlushDelay = 20 * time.Millisecond
) )
type revokeItem struct { type revokeItem struct {
@@ -124,8 +128,13 @@ WHERE id = ?`, endpointID); e != nil {
a.publishRevokes(ctx, revokes) a.publishRevokes(ctx, revokes)
a.publishGroupEvents(ctx, notifies) a.publishGroupEvents(ctx, notifies)
// fatal+断开由 admin DisableKick/DeleteKick(Session.Disable/Deleted)完成。
// 未接 Kick 钩子的单元测试仍可用 ConnControl 兜底断开。
if a.connCtrl != nil { if a.connCtrl != nil {
_ = a.connCtrl.Disconnect(ctx, endpointID, "", port.DisconnectFatal) go func() {
time.Sleep(kickFlushDelay)
_ = a.connCtrl.Disconnect(context.Background(), endpointID, "", port.DisconnectFatal)
}()
} }
return nil return nil
} }
+77
View File
@@ -3,6 +3,7 @@ package identity_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
@@ -64,6 +65,82 @@ VALUES(?,?,?,?,0,0,1,?,?)`, id, id, "stub$login", nil, 1_700_000_000_000, 1_700_
} }
} }
func TestDisableEmitsRevokedForPushed(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "data"), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
fixed := time.UnixMilli(1_700_000_000_000)
down := &message.RecordingDownlink{}
ctrl := &port.StubConnControl{}
idApp := identity.New(identity.Config{
DB: db,
Hash: auth.NewStubHashPool(),
Locks: auth.NewStubLoginLocks(),
Sessions: auth.NewSessionTokens(),
MaxScheduleSeconds: int64(config.Default().Limits.MaxScheduleSeconds),
Now: func() time.Time { return fixed },
ConnControl: ctrl,
Downlink: down,
})
ctx := context.Background()
insertEPFull(t, db, "alice")
insertEPFull(t, db, "bob")
err = db.Queue.Do(ctx, func(tx *sql.Tx) error {
res, e := tx.Exec(`
INSERT INTO messages(
id, sender_id, dest_kind, dest_id, meta, content_type, body_enc,
send_at, keep, ttl_seconds, receipt, state, reason, created_at)
VALUES('pushed-1','alice','endpoint','bob','{}','text/plain','utf8',?,1,0,0,'dispatched','',?)`,
fixed.UnixMilli(), fixed.UnixMilli())
if e != nil {
return e
}
seq, _ := res.LastInsertId()
_, e = tx.Exec(`
INSERT INTO deliveries(seq, endpoint_id, send_at, keep, state, reason, updated_at, pushed_at, pushed_conn)
VALUES(?,?,?,1,'pending','',?,?,?)`,
seq, "bob", fixed.UnixMilli(), fixed.UnixMilli(), fixed.UnixMilli(), "c-bob")
return e
})
if err != nil {
t.Fatal(err)
}
if err := idApp.Disable(ctx, "bob"); err != nil {
t.Fatal(err)
}
if down.FilterType(protocol.TypeRevoked) != 1 {
t.Fatalf("want 1 revoked, got snapshots=%v", down.Snapshots())
}
p := down.Snapshots()[0]
var head struct {
Type string `json:"type"`
Reason string `json:"reason"`
ID string `json:"id"`
}
_ = json.Unmarshal(p.Payload, &head)
if head.Type != protocol.TypeRevoked || head.Reason != "endpoint_disabled" || head.ID != "pushed-1" {
t.Fatalf("revoked=%+v", head)
}
if p.EndpointID != "bob" || p.QoS != 1 {
t.Fatalf("publish=%+v", p)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(ctrl.Calls) == 1 && ctrl.Calls[0] == "bob" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("disconnect calls=%v", ctrl.Calls)
}
func TestF01DisableVoidsScheduledAndRejectsNew(t *testing.T) { func TestF01DisableVoidsScheduledAndRejectsNew(t *testing.T) {
t.Parallel() t.Parallel()
idApp, msgApp, db := openLifecycle(t) idApp, msgApp, db := openLifecycle(t)
+78
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"git.asio.asia/nixevol/NixMsg/internal/auth" "git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/httpx"
"git.asio.asia/nixevol/NixMsg/internal/protocol" "git.asio.asia/nixevol/NixMsg/internal/protocol"
"git.asio.asia/nixevol/NixMsg/internal/store" "git.asio.asia/nixevol/NixMsg/internal/store"
) )
@@ -309,6 +310,83 @@ func TestRegisterF23_WrongCodeLock(t *testing.T) {
} }
} }
// TestRegisterTrustedProxyClientIPLock 验证与管理接口相同的 httpx.ClientIP:
// 受信代理的 X-Forwarded-For 按真实客户端 IP 计锁;非信任来源不采信转发头。
func TestRegisterTrustedProxyClientIPLock(t *testing.T) {
db, err := store.Open(t.TempDir(), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
locks := newRegisterIPLocker(time.Now)
trusted := httpx.ParseCIDRs([]string{"127.0.0.1/32"})
handler := NewRegisterHandler(RegisterConfig{
DB: db,
Hash: auth.NewStubHashPool(),
Locks: locks,
ClientIP: func(r *http.Request) string {
return httpx.ClientIP(r, trusted)
},
})
env := &testEnv{db: db, hash: auth.NewStubHashPool(), locks: locks, handler: handler}
env.setRegistration(t, true, "proxy-lock-1")
post := func(remote, xff, body string) (int, registerResp) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/client/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = remote
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
var resp registerResp
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
}
return rr.Code, resp
}
wrong := `{"registration_code":"wrong-code","id":"ep_px","login_password":"password1"}`
good := `{"registration_code":"proxy-lock-1","id":"ep_px","login_password":"password1"}`
for i := 0; i < 10; i++ {
code, resp := post("127.0.0.1:9000", "198.51.100.7", wrong)
if code != http.StatusForbidden || resp.Error == nil || resp.Error.Code != protocol.CodeRegistrationCodeInvalid {
t.Fatalf("trusted fail #%d: status=%d resp=%+v", i+1, code, resp)
}
}
code, resp := post("127.0.0.1:9000", "198.51.100.7", good)
if code != http.StatusTooManyRequests || resp.Error == nil || resp.Error.Code != protocol.CodeRateLimited {
t.Fatalf("real client should be locked: status=%d resp=%+v", code, resp)
}
code, resp = post("127.0.0.1:9000", "198.51.100.8", good)
if code != http.StatusOK || !resp.OK || resp.Data.ID != "ep_px" {
t.Fatalf("other XFF client must not share lock: status=%d resp=%+v", code, resp)
}
// 非信任对端:忽略 XFF,按 RemoteAddr 计锁。
locks.Clear(auth.LockKey{Kind: auth.LockRegisterIP, IP: "198.51.100.7"})
locks.Clear(auth.LockKey{Kind: auth.LockRegisterIP, IP: "203.0.113.50"})
for i := 0; i < 10; i++ {
code, resp = post("203.0.113.50:4433", "198.51.100.7", wrong)
if code != http.StatusForbidden || resp.Error == nil || resp.Error.Code != protocol.CodeRegistrationCodeInvalid {
t.Fatalf("untrusted fail #%d: status=%d resp=%+v", i+1, code, resp)
}
}
code, resp = post("203.0.113.50:4433", "198.51.100.7", `{"registration_code":"proxy-lock-1","id":"ep_px2","login_password":"password1"}`)
if code != http.StatusTooManyRequests || resp.Error == nil || resp.Error.Code != protocol.CodeRateLimited {
t.Fatalf("untrusted RemoteAddr should be locked: status=%d resp=%+v", code, resp)
}
// 若误采信 XFF,198.51.100.7 会已锁;直连该 IP 应仍可注册。
code, resp = post("198.51.100.7:5555", "", `{"registration_code":"proxy-lock-1","id":"ep_px3","login_password":"password1"}`)
if code != http.StatusOK || !resp.OK || resp.Data.ID != "ep_px3" {
t.Fatalf("spoofed XFF must not lock real client: status=%d resp=%+v", code, resp)
}
}
func TestRegisterF23_IDTakenKeepsOriginal(t *testing.T) { func TestRegisterF23_IDTakenKeepsOriginal(t *testing.T) {
env := openTestEnv(t) env := openTestEnv(t)
env.setRegistration(t, true, "taken-code") env.setRegistration(t, true, "taken-code")
+13
View File
@@ -17,6 +17,8 @@ func (a *App) Ack(ctx context.Context, endpointID string, req *protocol.Ack) (Ac
nowMs := a.now().UnixMilli() nowMs := a.now().UnixMilli()
var out AckResult var out AckResult
var seq int64 var seq int64
var ackLatencySec float64
var observeAck bool
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error { err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
err := tx.QueryRow(`SELECT seq FROM messages WHERE sender_id = ? AND id = ?`, req.From, req.ID).Scan(&seq) err := tx.QueryRow(`SELECT seq FROM messages WHERE sender_id = ? AND id = ?`, req.From, req.ID).Scan(&seq)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -25,6 +27,10 @@ func (a *App) Ack(ctx context.Context, endpointID string, req *protocol.Ack) (Ac
if err != nil { if err != nil {
return err return err
} }
var pushedAt sql.NullInt64
_ = tx.QueryRow(`
SELECT pushed_at FROM deliveries
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`, seq, endpointID).Scan(&pushedAt)
res, err := tx.Exec(` res, err := tx.Exec(`
UPDATE deliveries SET state = ?, reason = '', pushed_conn = NULL, updated_at = ? UPDATE deliveries SET state = ?, reason = '', pushed_conn = NULL, updated_at = ?
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`, WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
@@ -35,6 +41,10 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
aff, _ := res.RowsAffected() aff, _ := res.RowsAffected()
if aff > 0 { if aff > 0 {
out.Result = DeliveryAccepted out.Result = DeliveryAccepted
if pushedAt.Valid && pushedAt.Int64 > 0 && nowMs >= pushedAt.Int64 {
ackLatencySec = float64(nowMs-pushedAt.Int64) / 1000.0
observeAck = true
}
if e := insertReceiptTx(tx, req.From, seq, endpointID, DeliveryAccepted, "", nowMs); e != nil { if e := insertReceiptTx(tx, req.From, seq, endpointID, DeliveryAccepted, "", nowMs); e != nil {
return e return e
} }
@@ -55,6 +65,9 @@ SELECT state FROM deliveries WHERE seq = ? AND endpoint_id = ?`, seq, endpointID
if err != nil { if err != nil {
return out, err return out, err
} }
if observeAck && a.met != nil {
a.met.AckSeconds.Observe(ackLatencySec)
}
if out.Result == DeliveryAccepted { if out.Result == DeliveryAccepted {
a.releaseLarge(seq, endpointID) a.releaseLarge(seq, endpointID)
} }
+7
View File
@@ -7,6 +7,7 @@ import (
"git.asio.asia/nixevol/NixMsg/internal/app/port" "git.asio.asia/nixevol/NixMsg/internal/app/port"
"git.asio.asia/nixevol/NixMsg/internal/auth" "git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/config" "git.asio.asia/nixevol/NixMsg/internal/config"
"git.asio.asia/nixevol/NixMsg/internal/metrics"
"git.asio.asia/nixevol/NixMsg/internal/protocol" "git.asio.asia/nixevol/NixMsg/internal/protocol"
"git.asio.asia/nixevol/NixMsg/internal/store" "git.asio.asia/nixevol/NixMsg/internal/store"
) )
@@ -86,6 +87,7 @@ type App struct {
down port.Downlink down port.Downlink
conns ConnRegistry conns ConnRegistry
met *metrics.Registry
mu sync.Mutex mu sync.Mutex
largeSem chan struct{} largeSem chan struct{}
@@ -117,6 +119,11 @@ func WithConnRegistry(c ConnRegistry) Option {
return func(a *App) { a.conns = c } return func(a *App) { a.conns = c }
} }
// WithMetrics 注入 Prometheus 注册表(投递耗时直方图)。
func WithMetrics(m *metrics.Registry) Option {
return func(a *App) { a.met = m }
}
// New 创建消息服务实现。 // New 创建消息服务实现。
func New(db *store.DB, lim Limits, hash auth.HashPool, opts ...Option) *App { func New(db *store.DB, lim Limits, hash auth.HashPool, opts ...Option) *App {
if lim.RequestBurst <= 0 { if lim.RequestBurst <= 0 {
+9
View File
@@ -229,11 +229,20 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending' AND pushed_conn IS NULL`
a.releaseLarge(it.seq, endpointID) a.releaseLarge(it.seq, endpointID)
} }
a.scheduleRepush(endpointID, time.Second) a.scheduleRepush(endpointID, time.Second)
} else {
a.observeDispatchToPush(it.sendAt, nowMs)
} }
} }
return a.pushReceipts(ctx, endpointID, connID, nowMs) return a.pushReceipts(ctx, endpointID, connID, nowMs)
} }
func (a *App) observeDispatchToPush(sendAtMs, pushedAtMs int64) {
if a.met == nil || pushedAtMs < sendAtMs {
return
}
a.met.DispatchToPushSeconds.Observe(float64(pushedAtMs-sendAtMs) / 1000.0)
}
func (a *App) rejectTooLarge(ctx context.Context, seq int64, endpointID, senderID string, nowMs int64) error { func (a *App) rejectTooLarge(ctx context.Context, seq int64, endpointID, senderID string, nowMs int64) error {
return a.db.Queue.Do(ctx, func(tx *sql.Tx) error { return a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
res, err := tx.Exec(` res, err := tx.Exec(`
+118 -10
View File
@@ -32,6 +32,9 @@ type Login struct {
// 内存中的 session_used_at(毫秒)与上次落库时间。 // 内存中的 session_used_at(毫秒)与上次落库时间。
usedAt map[string]int64 usedAt map[string]int64
lastFlush map[string]int64 lastFlush map[string]int64
verifyMu sync.Mutex
verifySem map[string]chan struct{}
} }
// LoginOptions 装配 Login。 // LoginOptions 装配 Login。
@@ -67,9 +70,15 @@ func NewLogin(opts LoginOptions) *Login {
Now: now, Now: now,
usedAt: make(map[string]int64), usedAt: make(map[string]int64),
lastFlush: make(map[string]int64), lastFlush: make(map[string]int64),
verifySem: make(map[string]chan struct{}),
} }
} }
const (
authTimeout = 30 * time.Second
verifyPerEndpoint = 2
)
// Authenticate 按 DEVELOPMENT 第 5 节校验;内部故障返回 error。 // Authenticate 按 DEVELOPMENT 第 5 节校验;内部故障返回 error。
func (l *Login) Authenticate(ctx context.Context, endpointID string, password []byte, remoteIP string) (AuthResult, error) { func (l *Login) Authenticate(ctx context.Context, endpointID string, password []byte, remoteIP string) (AuthResult, error) {
if l == nil || l.DB == nil { if l == nil || l.DB == nil {
@@ -78,6 +87,11 @@ func (l *Login) Authenticate(ctx context.Context, endpointID string, password []
if endpointID == "" { if endpointID == "" {
return AuthResult{OK: false}, nil return AuthResult{OK: false}, nil
} }
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, authTimeout)
defer cancel()
row, err := l.loadEndpoint(ctx, endpointID) row, err := l.loadEndpoint(ctx, endpointID)
if err != nil { if err != nil {
@@ -107,6 +121,8 @@ type endpointAuthRow struct {
loginHash string loginHash string
sessionHash []byte // 原始 32 字节;无令牌时 nil sessionHash []byte // 原始 32 字节;无令牌时 nil
sessionUsedAt int64 // 毫秒;无则 0 sessionUsedAt int64 // 毫秒;无则 0
onlineSince int64
offlineSince int64
} }
func (l *Login) loadEndpoint(ctx context.Context, id string) (endpointAuthRow, error) { func (l *Login) loadEndpoint(ctx context.Context, id string) (endpointAuthRow, error) {
@@ -115,10 +131,12 @@ func (l *Login) loadEndpoint(ctx context.Context, id string) (endpointAuthRow, e
enabled int enabled int
sessHex sql.NullString sessHex sql.NullString
usedAt sql.NullInt64 usedAt sql.NullInt64
online sql.NullInt64
offline sql.NullInt64
) )
err := l.DB.Read.QueryRowContext(ctx, ` err := l.DB.Read.QueryRowContext(ctx, `
SELECT login_hash, enabled, session_hash, session_used_at SELECT login_hash, enabled, session_hash, session_used_at, online_since, offline_since
FROM endpoints WHERE id = ?`, id).Scan(&loginHash, &enabled, &sessHex, &usedAt) FROM endpoints WHERE id = ?`, id).Scan(&loginHash, &enabled, &sessHex, &usedAt, &online, &offline)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return endpointAuthRow{}, ErrEndpointNotFound return endpointAuthRow{}, ErrEndpointNotFound
@@ -132,6 +150,12 @@ FROM endpoints WHERE id = ?`, id).Scan(&loginHash, &enabled, &sessHex, &usedAt)
if usedAt.Valid { if usedAt.Valid {
row.sessionUsedAt = usedAt.Int64 row.sessionUsedAt = usedAt.Int64
} }
if online.Valid {
row.onlineSince = online.Int64
}
if offline.Valid {
row.offlineSince = offline.Int64
}
if sessHex.Valid && sessHex.String != "" { if sessHex.Valid && sessHex.String != "" {
raw, decErr := hex.DecodeString(sessHex.String) raw, decErr := hex.DecodeString(sessHex.String)
if decErr != nil || len(raw) != 32 { if decErr != nil || len(raw) != 32 {
@@ -160,11 +184,8 @@ func (l *Login) authSession(ctx context.Context, endpointID, token string, row e
usedAt = mem usedAt = mem
} }
l.usedMu.Unlock() l.usedMu.Unlock()
if l.IdleDays > 0 { if l.IdleDays > 0 && !sessionIdleOK(now, usedAt, row.onlineSince, row.offlineSince, l.IdleDays) {
idle := time.Duration(l.IdleDays) * 24 * time.Hour return false, nil
if usedAt <= 0 || now.Sub(time.UnixMilli(usedAt)) > idle {
return false, nil
}
} }
if err := l.touchSessionUsed(ctx, endpointID, nowMs); err != nil { if err := l.touchSessionUsed(ctx, endpointID, nowMs); err != nil {
return false, err return false, err
@@ -204,7 +225,11 @@ func (l *Login) authPassword(ctx context.Context, endpointID, password, remoteIP
if l.Pool == nil { if l.Pool == nil {
return false, "", errors.New("broker: password pool not configured") return false, "", errors.New("broker: password pool not configured")
} }
if err := l.acquireVerify(ctx, endpointID); err != nil {
return false, "", err
}
match, verErr := l.Pool.Verify(ctx, auth.PasswordLogin, password, row.loginHash) match, verErr := l.Pool.Verify(ctx, auth.PasswordLogin, password, row.loginHash)
l.releaseVerify(endpointID)
if verErr != nil { if verErr != nil {
return false, "", verErr return false, "", verErr
} }
@@ -220,12 +245,28 @@ func (l *Login) authPassword(ctx context.Context, endpointID, password, remoteIP
} }
nowMs := l.Now().UnixMilli() nowMs := l.Now().UnixMilli()
hashHex := hex.EncodeToString(hash) hashHex := hex.EncodeToString(hash)
var oldHex any
if len(row.sessionHash) == 0 {
oldHex = ""
} else {
oldHex = hex.EncodeToString(row.sessionHash)
}
writeErr := l.DB.Queue.Do(ctx, func(tx *sql.Tx) error { writeErr := l.DB.Queue.Do(ctx, func(tx *sql.Tx) error {
_, e := tx.Exec(` res, e := tx.Exec(`
UPDATE endpoints UPDATE endpoints
SET session_hash = ?, session_issued_at = ?, session_used_at = ? SET session_hash = ?, session_issued_at = ?, session_used_at = ?
WHERE id = ?`, hashHex, nowMs, nowMs, endpointID) WHERE id = ? AND COALESCE(session_hash, '') = ?`, hashHex, nowMs, nowMs, endpointID, oldHex)
return e if e != nil {
return e
}
n, nErr := res.RowsAffected()
if nErr != nil {
return nErr
}
if n == 0 {
return ErrSessionWriteConflict
}
return nil
}) })
if writeErr != nil { if writeErr != nil {
return false, "", writeErr return false, "", writeErr
@@ -288,6 +329,73 @@ func (l *Login) SessionHashOf(ctx context.Context, endpointID string) ([]byte, e
return hex.DecodeString(sessHex.String) return hex.DecodeString(sessHex.String)
} }
// TokenMatchesDB 握手时重读:明文令牌是否仍对应库中当前哈希。
func (l *Login) TokenMatchesDB(ctx context.Context, endpointID, token string) (bool, error) {
if l == nil || l.DB == nil || token == "" {
return false, nil
}
got := l.Tokens.HashToken(token)
dbHash, err := l.SessionHashOf(ctx, endpointID)
if err != nil {
return false, err
}
if len(dbHash) == 0 {
return false, nil
}
return auth.EqualHash(got, dbHash), nil
}
func sessionIdleOK(now time.Time, usedAt, onlineSince, offlineSince int64, idleDays int) bool {
if idleDays <= 0 {
return true
}
online := onlineSince > 0 && onlineSince >= offlineSince
if online {
return true
}
activity := usedAt
if onlineSince > activity {
activity = onlineSince
}
if offlineSince > activity {
activity = offlineSince
}
if activity <= 0 {
return false
}
idle := time.Duration(idleDays) * 24 * time.Hour
return now.Sub(time.UnixMilli(activity)) <= idle
}
func (l *Login) acquireVerify(ctx context.Context, endpointID string) error {
l.verifyMu.Lock()
sem := l.verifySem[endpointID]
if sem == nil {
sem = make(chan struct{}, verifyPerEndpoint)
l.verifySem[endpointID] = sem
}
l.verifyMu.Unlock()
select {
case sem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (l *Login) releaseVerify(endpointID string) {
l.verifyMu.Lock()
sem := l.verifySem[endpointID]
l.verifyMu.Unlock()
if sem == nil {
return
}
select {
case <-sem:
default:
}
}
// LooksLikeSessionToken 暴露给测试。 // LooksLikeSessionToken 暴露给测试。
func (l *Login) LooksLikeSessionToken(s string) bool { func (l *Login) LooksLikeSessionToken(s string) bool {
return strings.HasPrefix(s, "nst_") return strings.HasPrefix(s, "nst_")
+123
View File
@@ -0,0 +1,123 @@
package broker
import (
"context"
"database/sql"
"sync"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func TestSessionIdleOKUsesOnlineOffline(t *testing.T) {
now := time.UnixMilli(1_700_000_000_000)
idleDays := 1
old := now.Add(-48 * time.Hour).UnixMilli()
recentOffline := now.Add(-2 * time.Hour).UnixMilli()
if sessionIdleOK(now, old, 0, 0, idleDays) {
t.Fatal("stale used_at should expire")
}
if !sessionIdleOK(now, old, now.UnixMilli(), 0, idleDays) {
t.Fatal("currently online should not expire")
}
if !sessionIdleOK(now, old, 0, recentOffline, idleDays) {
t.Fatal("recent offline_since should keep token")
}
}
func TestPasswordLoginConditionalUpdate(t *testing.T) {
dir := t.TempDir()
db, err := store.Open(dir, "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
pool := auth.NewStubHashPool()
login := NewLogin(LoginOptions{DB: db, Pool: pool, Tokens: auth.NewSessionTokens(), Locks: auth.NewLoginLocks(), IdleDays: 30})
phc, _ := pool.Hash(context.Background(), auth.PasswordLogin, "password1")
_ = db.Queue.Do(context.Background(), func(tx *sql.Tx) error {
_, e := tx.Exec(`INSERT INTO endpoints(id, name, login_hash, talk_hash, talk_version, default_delay_ms, enabled, created_at)
VALUES ('ep-cond', '', ?, NULL, 0, 0, 1, ?)`, phc, time.Now().UnixMilli())
return e
})
res, err := login.Authenticate(context.Background(), "ep-cond", []byte("password1"), "1.1.1.1")
if err != nil || !res.OK || res.SessionToken == "" {
t.Fatalf("first login %+v err=%v", res, err)
}
ok, err := login.TokenMatchesDB(context.Background(), "ep-cond", res.SessionToken)
if err != nil || !ok {
t.Fatalf("match=%v err=%v", ok, err)
}
res2, err := login.Authenticate(context.Background(), "ep-cond", []byte("password1"), "1.1.1.1")
if err != nil || !res2.OK {
t.Fatalf("second login %+v err=%v", res2, err)
}
ok, _ = login.TokenMatchesDB(context.Background(), "ep-cond", res.SessionToken)
if ok {
t.Fatal("old token should not match after second login")
}
ok, _ = login.TokenMatchesDB(context.Background(), "ep-cond", res2.SessionToken)
if !ok {
t.Fatal("new token should match")
}
}
func TestAuthenticateRespectsCanceledContext(t *testing.T) {
dir := t.TempDir()
db, err := store.Open(dir, "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
pool := &blockingPool{ready: make(chan struct{}), release: make(chan struct{})}
login := NewLogin(LoginOptions{DB: db, Pool: pool, Tokens: auth.NewSessionTokens(), Locks: auth.NewLoginLocks()})
phc, _ := auth.NewStubHashPool().Hash(context.Background(), auth.PasswordLogin, "password1")
_ = db.Queue.Do(context.Background(), func(tx *sql.Tx) error {
_, e := tx.Exec(`INSERT INTO endpoints(id, name, login_hash, talk_hash, talk_version, default_delay_ms, enabled, created_at)
VALUES ('ep-to', '', ?, NULL, 0, 0, 1, ?)`, phc, time.Now().UnixMilli())
return e
})
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
var gotErr error
go func() {
defer wg.Done()
_, gotErr = login.Authenticate(ctx, "ep-to", []byte("password1"), "9.9.9.9")
}()
select {
case <-pool.ready:
case <-time.After(2 * time.Second):
t.Fatal("verify did not start")
}
cancel()
wg.Wait()
close(pool.release)
if gotErr == nil {
t.Fatal("expected canceled auth")
}
}
type blockingPool struct {
ready chan struct{}
release chan struct{}
once sync.Once
}
func (p *blockingPool) Hash(ctx context.Context, kind auth.PasswordKind, password string) (string, error) {
return auth.NewStubHashPool().Hash(ctx, kind, password)
}
func (p *blockingPool) Verify(ctx context.Context, _ auth.PasswordKind, _, _ string) (bool, error) {
p.once.Do(func() { close(p.ready) })
select {
case <-ctx.Done():
return false, ctx.Err()
case <-p.release:
return true, nil
}
}
func (p *blockingPool) QueueLen() int { return 0 }
+225
View File
@@ -0,0 +1,225 @@
package broker
import (
"bytes"
"context"
"io"
"net"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"github.com/mochi-mqtt/server/v2/packets"
)
func TestReceiveMaximumDoesNotDeadlockPublish(t *testing.T) {
b, err := New(Options{Authenticator: AllowAuthenticator{}})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
clientDone := make(chan struct{})
go func() {
defer close(clientDone)
c, accErr := ln.Accept()
if accErr != nil {
return
}
_ = b.AttachTCP(c)
}()
w, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = w.Close() }()
endpoint := "ep-rm-quota"
writeConnectFull(t, w, endpoint, 30, 0, 20)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic(endpoint))
readExactPacket(t, w, packets.Suback, 3*time.Second)
deadline := time.Now().Add(3 * time.Second)
for {
if _, ok := b.ConnInfoOf(endpoint); ok {
break
}
if time.Now().After(deadline) {
t.Fatal("session not established")
}
time.Sleep(5 * time.Millisecond)
}
var received atomic.Int64
var writeMu sync.Mutex
stop := make(chan struct{})
var stopOnce sync.Once
halt := func() { stopOnce.Do(func() { close(stop) }) }
defer halt()
go func() {
for {
select {
case <-stop:
return
default:
}
_ = w.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
hdr := make([]byte, 1)
if _, err := io.ReadFull(w, hdr); err != nil {
continue
}
rem, err := readRemainingLengthConn(w)
if err != nil {
continue
}
body := make([]byte, rem)
if _, err := io.ReadFull(w, body); err != nil {
continue
}
typ := hdr[0] >> 4
if typ != packets.Publish {
continue
}
qos := (hdr[0] >> 1) & 0x3
received.Add(1)
if qos == 0 {
continue
}
pk := new(packets.Packet)
pk.ProtocolVersion = 5
pk.FixedHeader = packets.FixedHeader{Type: packets.Publish, Remaining: rem, Qos: qos}
if decErr := pk.PublishDecode(body); decErr != nil {
continue
}
ack := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Puback},
ProtocolVersion: 5,
PacketID: pk.PacketID,
}
var ab bytes.Buffer
_ = ack.PubackEncode(&ab)
writeMu.Lock()
_, _ = w.Write(ab.Bytes())
writeMu.Unlock()
}
}()
go func() {
tick := time.NewTicker(2 * time.Millisecond)
defer tick.Stop()
pk := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Pingreq},
ProtocolVersion: 5,
}
var buf bytes.Buffer
_ = pk.PingreqEncode(&buf)
ping := append([]byte(nil), buf.Bytes()...)
for {
select {
case <-stop:
return
case <-tick.C:
writeMu.Lock()
_, _ = w.Write(ping)
writeMu.Unlock()
}
}
}()
payload := []byte(`{"v":1,"type":"resp","rid":"x"}`)
pubCtx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for pubCtx.Err() == nil {
_ = b.PublishDown(pubCtx, endpoint, "", payload, port.PublishOpts{QoS: 1})
}
}()
}
runFor := 5 * time.Second
watch := 2 * time.Second
start := time.Now()
last := received.Load()
lastChange := time.Now()
for time.Since(start) < runFor {
time.Sleep(50 * time.Millisecond)
n := received.Load()
if n > last {
last = n
lastChange = time.Now()
}
if time.Since(lastChange) > watch {
buf := make([]byte, 1<<20)
nstack := runtime.Stack(buf, true)
halt()
cancel()
_ = w.Close()
t.Fatalf("progress stalled at %d after %s\n%s", last, time.Since(lastChange), buf[:nstack])
}
}
cancel()
wg.Wait()
halt()
if last < 100 {
t.Fatalf("too few publishes delivered: %d", last)
}
_ = w.Close()
select {
case <-clientDone:
case <-time.After(3 * time.Second):
}
}
func readExactPacket(t *testing.T, conn net.Conn, wantType byte, timeout time.Duration) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(timeout))
hdr := make([]byte, 1)
if _, err := io.ReadFull(conn, hdr); err != nil {
t.Fatal(err)
}
if hdr[0]>>4 != wantType {
t.Fatalf("want packet type %d got %d", wantType, hdr[0]>>4)
}
rem, err := readRemainingLengthConn(conn)
if err != nil {
t.Fatal(err)
}
body := make([]byte, rem)
if _, err := io.ReadFull(conn, body); err != nil {
t.Fatal(err)
}
}
func readRemainingLengthConn(r io.Reader) (int, error) {
var mul uint32 = 1
var value uint32
for i := 0; i < 4; i++ {
var b [1]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
value += uint32(b[0]&127) * mul
if b[0]&128 == 0 {
return int(value), nil
}
mul *= 128
}
return 0, io.ErrUnexpectedEOF
}
+223
View File
@@ -0,0 +1,223 @@
package broker
import (
"bytes"
"context"
"errors"
"io"
"net"
"sync"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"github.com/mochi-mqtt/server/v2/packets"
)
func TestLargeFrameQuotaReleasedOnPuback(t *testing.T) {
b, w, done := startTCPClient(t, "ep-large-ack")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-large-ack", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-large-ack"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-large-ack")
stop := make(chan struct{})
defer close(stop)
var writeMu sync.Mutex
go autoPuback(w, stop, &writeMu)
payload := bytes.Repeat([]byte("x"), 70*1024)
for i := 0; i < 65; i++ {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
err := b.PublishDown(ctx, "ep-large-ack", "", payload, port.PublishOpts{QoS: 1})
cancel()
if err != nil {
t.Fatalf("publish %d: %v", i+1, err)
}
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(b.largeSem) == 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("slots still held: %d", len(b.largeSem))
}
func TestLargeFrameQuotaReleasedOnDisconnect(t *testing.T) {
b, w, done := startTCPClient(t, "ep-large-disc")
defer func() { _ = b.Close() }()
writeConnect(t, w, "ep-large-disc", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-large-disc"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-large-disc")
go func() {
buf := make([]byte, 32*1024)
for {
_, err := w.Read(buf)
if err != nil {
return
}
}
}()
payload := bytes.Repeat([]byte("y"), 70*1024)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
if err := b.PublishDown(ctx, "ep-large-disc", "", payload, port.PublishOpts{QoS: 1}); err != nil {
cancel()
t.Fatal(err)
}
cancel()
held := false
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(b.largeSem) > 0 {
held = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !held {
t.Fatal("expected a held large slot before disconnect")
}
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("client attach did not return")
}
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(b.largeSem) == 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("slots after disconnect: %d", len(b.largeSem))
}
func TestLargeFrameQuotaReleasedWithoutSubscriber(t *testing.T) {
b, w, done := startTCPClient(t, "ep-large-nosub")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-large-nosub", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
waitSession(t, b, "ep-large-nosub")
payload := bytes.Repeat([]byte("z"), 70*1024)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
err := b.PublishDown(ctx, "ep-large-nosub", "", payload, port.PublishOpts{QoS: 1})
cancel()
if !errors.Is(err, ErrNotSubscribed) {
t.Fatalf("err=%v want ErrNotSubscribed", err)
}
if len(b.largeSem) != 0 {
t.Fatalf("held slots without subscriber: %d", len(b.largeSem))
}
}
func startTCPClient(t *testing.T, _ string) (*Broker, net.Conn, chan struct{}) {
t.Helper()
b, err := New(Options{Authenticator: AllowAuthenticator{}})
if err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
done := make(chan struct{})
go func() {
defer close(done)
c, accErr := ln.Accept()
if accErr != nil {
return
}
_ = b.AttachTCP(c)
}()
w, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
return b, w, done
}
func waitSession(t *testing.T, b *Broker, endpoint string) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, ok := b.ConnInfoOf(endpoint); ok {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("session not established")
}
func autoPuback(w net.Conn, stop <-chan struct{}, writeMu *sync.Mutex) {
for {
select {
case <-stop:
return
default:
}
_ = w.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
hdr := make([]byte, 1)
if _, err := io.ReadFull(w, hdr); err != nil {
continue
}
rem, err := readRemainingLengthConn(w)
if err != nil {
continue
}
body := make([]byte, rem)
if _, err := io.ReadFull(w, body); err != nil {
continue
}
if hdr[0]>>4 != packets.Publish {
continue
}
qos := (hdr[0] >> 1) & 0x3
if qos == 0 {
continue
}
pk := new(packets.Packet)
pk.ProtocolVersion = 5
pk.FixedHeader = packets.FixedHeader{Type: packets.Publish, Remaining: rem, Qos: qos}
if decErr := pk.PublishDecode(body); decErr != nil {
continue
}
ack := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Puback},
ProtocolVersion: 5,
PacketID: pk.PacketID,
}
var ab bytes.Buffer
_ = ack.PubackEncode(&ab)
writeMu.Lock()
_, _ = w.Write(ab.Bytes())
writeMu.Unlock()
}
}
+212
View File
@@ -0,0 +1,212 @@
package broker
import (
"bytes"
"context"
"errors"
"io"
"net"
"strconv"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"github.com/mochi-mqtt/server/v2/packets"
)
func TestPublishDownBackpressureWhenQueueFull(t *testing.T) {
// 无缓冲 pipe:发送 goroutine 在客户端不读时堵住,队列才能填满。
b, err := New(Options{Authenticator: AllowAuthenticator{}})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b.AttachTCP(r)
}()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
connectAndSubscribe(t, w, "ep-bp", 0)
waitSession(t, b, "ep-bp")
payload := bytes.Repeat([]byte("q"), 1024)
var sawBP bool
start := time.Now()
// mochi outbound 缓冲 1024,发送 goroutine 要先填满它才会堵住,随后才轮到本地下行队列。
for i := 0; i < 1024+downQueueMax+16; i++ {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
err := b.PublishDown(ctx, "ep-bp", "", payload, port.PublishOpts{QoS: 1})
cancel()
if errors.Is(err, ErrBackpressure) {
if time.Since(start) > 50*time.Millisecond && i == 0 {
t.Fatalf("first backpressure took %s", time.Since(start))
}
sawBP = true
break
}
if err != nil {
t.Fatalf("publish %d: %v", i, err)
}
}
if !sawBP {
t.Fatal("expected ErrBackpressure")
}
}
func TestSlowClientDoesNotBlockOtherPublishDown(t *testing.T) {
b, err := New(Options{Authenticator: AllowAuthenticator{}})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
slowW, slowDone := acceptAndDial(t, b)
fastW, fastDone := acceptAndDial(t, b)
defer func() {
_ = slowW.Close()
_ = fastW.Close()
select {
case <-slowDone:
case <-time.After(3 * time.Second):
}
select {
case <-fastDone:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, slowW, "ep-slow", 30, 0)
readExactPacket(t, slowW, packets.Connack, 3*time.Second)
writeSubscribe(t, slowW, downTopic("ep-slow"))
readExactPacket(t, slowW, packets.Suback, 3*time.Second)
writeConnect(t, fastW, "ep-fast", 30, 0)
readExactPacket(t, fastW, packets.Connack, 3*time.Second)
writeSubscribe(t, fastW, downTopic("ep-fast"))
readExactPacket(t, fastW, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-slow")
waitSession(t, b, "ep-fast")
big := bytes.Repeat([]byte("s"), 64*1024)
for i := 0; i < 8; i++ {
_ = b.PublishDown(context.Background(), "ep-slow", "", big, port.PublishOpts{QoS: 1})
}
small := []byte(`{"v":1,"type":"resp"}`)
start := time.Now()
if err := b.PublishDown(context.Background(), "ep-fast", "", small, port.PublishOpts{QoS: 1}); err != nil {
t.Fatalf("fast publish: %v", err)
}
if time.Since(start) > 100*time.Millisecond {
t.Fatalf("fast PublishDown took %s", time.Since(start))
}
got := readDownPayload(t, fastW, 2*time.Second)
if !bytes.Equal(got, small) {
t.Fatalf("fast got %q", got)
}
}
func TestDownlinkFIFOOrder(t *testing.T) {
b, w, done := startTCPClient(t, "ep-ord")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-ord", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-ord"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-ord")
const n = 64
for i := 0; i < n; i++ {
p := []byte(strconv.Itoa(i))
if err := b.PublishDown(context.Background(), "ep-ord", "", p, port.PublishOpts{QoS: 1}); err != nil {
t.Fatal(err)
}
}
for i := 0; i < n; i++ {
got := readDownPayload(t, w, 3*time.Second)
want := []byte(strconv.Itoa(i))
if !bytes.Equal(got, want) {
t.Fatalf("order %d: got %s want %s", i, got, want)
}
}
}
func acceptAndDial(t *testing.T, b *Broker) (net.Conn, chan struct{}) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
done := make(chan struct{})
go func() {
defer close(done)
c, accErr := ln.Accept()
if accErr != nil {
return
}
_ = b.AttachTCP(c)
}()
w, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
return w, done
}
func readDownPayload(t *testing.T, conn net.Conn, timeout time.Duration) []byte {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
hdr := make([]byte, 1)
if _, err := io.ReadFull(conn, hdr); err != nil {
continue
}
rem, err := readRemainingLengthConn(conn)
if err != nil {
continue
}
body := make([]byte, rem)
if _, err := io.ReadFull(conn, body); err != nil {
continue
}
if hdr[0]>>4 != packets.Publish {
continue
}
qos := (hdr[0] >> 1) & 0x3
pk := new(packets.Packet)
pk.ProtocolVersion = 5
pk.FixedHeader = packets.FixedHeader{Type: packets.Publish, Remaining: rem, Qos: qos}
if err := pk.PublishDecode(body); err != nil {
t.Fatal(err)
}
if qos > 0 {
ack := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Puback},
ProtocolVersion: 5,
PacketID: pk.PacketID,
}
var ab bytes.Buffer
_ = ack.PubackEncode(&ab)
_, _ = conn.Write(ab.Bytes())
}
return pk.Payload
}
t.Fatal("timeout waiting publish")
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package broker
import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"github.com/mochi-mqtt/server/v2/packets"
)
func TestPublishDownRequiresDownSubscription(t *testing.T) {
b, w, done := startTCPClient(t, "ep-nosub")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-nosub", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
waitSession(t, b, "ep-nosub")
err := b.PublishDown(context.Background(), "ep-nosub", "", []byte(`{"v":1}`), port.PublishOpts{QoS: 0})
if !errors.Is(err, ErrNotSubscribed) {
t.Fatalf("err=%v want ErrNotSubscribed", err)
}
}
func TestPublishDownRejectsStaleConnID(t *testing.T) {
b, w, done := startTCPClient(t, "ep-stale")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-stale", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-stale"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-stale")
err := b.PublishDown(context.Background(), "ep-stale", "dead-conn", []byte(`{"v":1}`), port.PublishOpts{QoS: 0})
if !errors.Is(err, ErrNoConnection) {
t.Fatalf("err=%v want ErrNoConnection", err)
}
}
func TestPublishThenDisconnectWritesThenCloses(t *testing.T) {
b, w, done := startTCPClient(t, "ep-ptd")
defer func() { _ = b.Close() }()
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-ptd", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeSubscribe(t, w, downTopic("ep-ptd"))
readExactPacket(t, w, packets.Suback, 3*time.Second)
waitSession(t, b, "ep-ptd")
payload := []byte(`{"v":1,"type":"fatal","reason":"disabled"}`)
if err := b.PublishThenDisconnect(context.Background(), "ep-ptd", "", payload, 1, port.DisconnectFatal); err != nil {
t.Fatal(err)
}
got := readDownPayload(t, w, 3*time.Second)
if !bytes.Equal(got, payload) {
t.Fatalf("got %s", got)
}
_ = w.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 64)
n, err := io.ReadAtLeast(w, buf, 2)
if err != nil && n == 0 {
return // 连接已关
}
if n > 0 && buf[0]>>4 == packets.Disconnect {
return
}
}
func TestShutdownUsesServerShuttingDown(t *testing.T) {
b, w, done := startTCPClient(t, "ep-shut")
defer func() {
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}()
writeConnect(t, w, "ep-shut", 30, 0)
readExactPacket(t, w, packets.Connack, 3*time.Second)
waitSession(t, b, "ep-shut")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := b.Shutdown(ctx); err != nil {
t.Fatal(err)
}
_ = w.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 32)
n, err := io.ReadAtLeast(w, buf, 2)
if err != nil && n == 0 {
return
}
if n > 0 && buf[0]>>4 != packets.Disconnect {
t.Fatalf("want disconnect got %x", buf[:n])
}
}
func TestEffectivePayloadLimitSubtractsOverhead(t *testing.T) {
got := EffectivePayloadLimit(200, 0)
if got != 200-packetOverheadBudget {
t.Fatalf("got %d", got)
}
got = EffectivePayloadLimit(200, 50)
if got != 50 {
t.Fatalf("got %d want 50", got)
}
}
+270
View File
@@ -0,0 +1,270 @@
package broker
import (
"bytes"
"context"
"encoding/base64"
"io"
"log/slog"
"net"
"strings"
"testing"
"time"
"github.com/mochi-mqtt/server/v2/packets"
)
func TestFailedAuthDoesNotLeakConnTable(t *testing.T) {
secret := "s3cret-token-xyz"
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, err := New(Options{Authenticator: RejectAuthenticator{}, Logger: log})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
const n = 200
for i := 0; i < n; i++ {
dialFailedCONNECT(t, b, func(w net.Conn) {
writeConnect(t, w, "ep-rej", 30, 0)
})
}
for i := 0; i < n; i++ {
dialFailedCONNECT(t, b, func(w net.Conn) {
writeConnectMismatch(t, w)
})
}
b2, err := New(Options{Authenticator: &errAuthenticator{err: context.DeadlineExceeded}})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b2.Close() }()
for i := 0; i < n; i++ {
dialFailedCONNECT(t, b2, func(w net.Conn) {
writeConnect(t, w, "ep-err", 30, 0)
})
}
if got := len(b.byClient); got != 0 {
t.Fatalf("reject/mismatch leaked %d", got)
}
if got := len(b2.byClient); got != 0 {
t.Fatalf("internal error leaked %d", got)
}
// B-07:拒绝路径的 mochi 日志不能带密码
b3, err := New(Options{Authenticator: AllowAuthenticator{}, Logger: log})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b3.Close() }()
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b3.AttachTCP(r)
}()
writeConnectWithPassword(t, w, "ep-log", secret)
readExactPacket(t, w, packets.Connack, 3*time.Second)
writeConnectWithPassword(t, w, "ep-log", secret) // 同一连接第二个 CONNECT
_ = w.Close()
select {
case <-done:
case <-time.After(2 * time.Second):
}
out := logBuf.String()
if strings.Contains(out, secret) {
t.Fatalf("log contains password: %s", out)
}
if strings.Contains(out, base64.StdEncoding.EncodeToString([]byte(secret))) {
t.Fatalf("log contains password base64: %s", out)
}
}
func TestSweepUnestablishedClosedConn(t *testing.T) {
b, err := New(Options{Authenticator: AllowAuthenticator{}})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b.AttachTCP(r)
}()
writeConnect(t, w, "ep-sweep", 30, 0)
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
b.sweepUnestablished(0)
if got := len(b.byClient); got != 0 {
t.Fatalf("after sweep byClient=%d", got)
}
}
func TestLookupByConnIDIndependentOfFailedConns(t *testing.T) {
b, err := New(Options{Authenticator: AllowAuthenticator{}})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b.AttachTCP(r)
}()
connectAndSubscribe(t, w, "ep-ok", 0)
waitSession(t, b, "ep-ok")
info, ok := b.ConnInfoOf("ep-ok")
if !ok {
t.Fatal("missing session")
}
st := b.lookupConn("ep-ok", info.ConnID)
if st == nil {
t.Fatal("lookup by conn id")
}
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
}
}
func dialFailedCONNECT(t *testing.T, b *Broker, write func(net.Conn)) {
t.Helper()
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b.AttachTCP(r)
}()
write(w)
_ = w.Close()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("attach did not return")
}
}
func writeConnectMismatch(t *testing.T, w net.Conn) {
t.Helper()
pk := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Connect},
ProtocolVersion: 5,
Connect: packets.ConnectParams{
ProtocolName: []byte("MQTT"),
Clean: true,
ClientIdentifier: "id-a",
Keepalive: 30,
UsernameFlag: true,
Username: []byte("id-b"),
PasswordFlag: true,
Password: []byte("nope"),
},
}
var buf bytes.Buffer
if err := pk.ConnectEncode(&buf); err != nil {
t.Fatal(err)
}
if _, err := w.Write(buf.Bytes()); err != nil {
t.Fatal(err)
}
}
func writeConnectWithPassword(t *testing.T, w net.Conn, endpoint, password string) {
t.Helper()
pk := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Connect},
ProtocolVersion: 5,
Connect: packets.ConnectParams{
ProtocolName: []byte("MQTT"),
Clean: true,
ClientIdentifier: endpoint,
Keepalive: 30,
UsernameFlag: true,
Username: []byte(endpoint),
PasswordFlag: true,
Password: []byte(password),
},
}
var buf bytes.Buffer
if err := pk.ConnectEncode(&buf); err != nil {
t.Fatal(err)
}
if _, err := w.Write(buf.Bytes()); err != nil {
t.Fatal(err)
}
}
func TestMQTT311UnauthorizedPublishOmitsPayloadInLogs(t *testing.T) {
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, err := New(Options{Authenticator: AllowAuthenticator{}, Logger: log})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b.AttachTCP(r)
}()
pk := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Connect},
ProtocolVersion: 4,
Connect: packets.ConnectParams{
ProtocolName: []byte("MQTT"),
Clean: true,
ClientIdentifier: "ep311",
Keepalive: 30,
UsernameFlag: true,
Username: []byte("ep311"),
PasswordFlag: true,
Password: []byte("test"),
},
}
var buf bytes.Buffer
if err := pk.ConnectEncode(&buf); err != nil {
t.Fatal(err)
}
if _, err := w.Write(buf.Bytes()); err != nil {
t.Fatal(err)
}
_ = w.SetReadDeadline(time.Now().Add(3 * time.Second))
raw := make([]byte, 256)
if _, err := io.ReadAtLeast(w, raw, 2); err != nil {
t.Fatal(err)
}
body := []byte(`{"talk_password":"super-secret-body"}`)
pub := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Publish, Qos: 1},
ProtocolVersion: 4,
TopicName: "nix/c/other/up",
PacketID: 7,
Payload: body,
}
buf.Reset()
if err := pub.PublishEncode(&buf); err != nil {
t.Fatal(err)
}
_, _ = w.Write(buf.Bytes())
time.Sleep(50 * time.Millisecond)
_ = w.Close()
select {
case <-done:
case <-time.After(2 * time.Second):
}
out := logBuf.String()
if strings.Contains(out, "super-secret-body") {
t.Fatalf("log contains publish payload: %s", out)
}
}
+268 -75
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"git.asio.asia/nixevol/NixMsg/internal/app/port" "git.asio.asia/nixevol/NixMsg/internal/app/port"
"git.asio.asia/nixevol/NixMsg/internal/metrics"
mqtt "github.com/mochi-mqtt/server/v2" mqtt "github.com/mochi-mqtt/server/v2"
"github.com/mochi-mqtt/server/v2/packets" "github.com/mochi-mqtt/server/v2/packets"
) )
@@ -33,6 +34,24 @@ var ErrPayloadTooLarge = errors.New("broker: payload exceeds client limit")
// ErrNoConnection 目标端没有当前连接。 // ErrNoConnection 目标端没有当前连接。
var ErrNoConnection = errors.New("broker: no active connection") var ErrNoConnection = errors.New("broker: no active connection")
// ErrLargeFrameTimeout 全局大帧名额在有界等待内拿不到。
var ErrLargeFrameTimeout = errors.New("broker: large frame quota timeout")
// ErrBackpressure 该连接下行队列已满(帧数或字节数)。
var ErrBackpressure = errors.New("broker: downlink backpressure")
// ErrNotSubscribed 当前连接尚未订阅下行主题。
var ErrNotSubscribed = errors.New("broker: down topic not subscribed")
// ErrSessionWriteConflict 密码登录写令牌时发现库已被并发更新。
var ErrSessionWriteConflict = errors.New("broker: session token write conflict")
const (
largeAcquireWait = 5 * time.Second
downQueueMax = 256
downQueueBytes = 16 << 20
)
// AuthResult 是登录校验结论(N3 实现真实逻辑;N2 默认拒绝)。 // AuthResult 是登录校验结论(N3 实现真实逻辑;N2 默认拒绝)。
type AuthResult struct { type AuthResult struct {
OK bool OK bool
@@ -68,27 +87,35 @@ type Options struct {
Logger *slog.Logger Logger *slog.Logger
// OnPublishDropped 可选;nil 时仅打 debug 日志。 // OnPublishDropped 可选;nil 时仅打 debug 日志。
OnPublishDropped PublishDroppedFunc OnPublishDropped PublishDroppedFunc
// Metrics 可选;会话建立/断开时更新 nixmsg_connections。
Metrics *metrics.Registry
} }
// Broker 内置 mochi,不自带监听端口。 // Broker 内置 mochi,不自带监听端口。
type Broker struct { type Broker struct {
server *mqtt.Server server *mqtt.Server
auth Authenticator auth Authenticator
uplink port.UplinkHandler uplink port.UplinkHandler
log *slog.Logger log *slog.Logger
onDrop PublishDroppedFunc onDrop PublishDroppedFunc
metrics *metrics.Registry
hook *nixHook hook *nixHook
connsMu sync.RWMutex connsMu sync.RWMutex
current map[string]*connState current map[string]*connState
byClient map[*mqtt.Client]*connState byClient map[*mqtt.Client]*connState
byConnID map[port.ConnID]*connState
closedCh chan struct{}
queuesMu sync.Mutex queuesMu sync.Mutex
queues map[string]*uplinkQueue queues map[string]*uplinkQueue
largeSem chan struct{} largeSem chan struct{}
closed atomic.Bool closed atomic.Bool
lifeMu sync.Mutex
lifeLocks map[string]*sync.Mutex
} }
type connState struct { type connState struct {
@@ -104,7 +131,18 @@ type connState struct {
sessionToken string sessionToken string
handshook bool handshook bool
subscribedDown bool subscribedDown bool
largeHeld int largePIDs map[uint16]struct{}
largePending int
metricsCounted bool
established bool
createdAt time.Time
closing bool
superseded bool
downCh chan downItem
downStop chan struct{}
downDone chan struct{}
downBytes atomic.Int64
sentPub atomic.Int64
mu sync.Mutex mu sync.Mutex
handshakeTimer *time.Timer handshakeTimer *time.Timer
@@ -124,6 +162,7 @@ func New(opts Options) (*Broker, error) {
if log == nil { if log == nil {
log = slog.Default() log = slog.Default()
} }
log = slog.New(newRedactHandler(log.Handler()))
caps := mqtt.NewDefaultServerCapabilities() caps := mqtt.NewDefaultServerCapabilities()
caps.MaximumClients = maxClients caps.MaximumClients = maxClients
@@ -146,15 +185,19 @@ func New(opts Options) (*Broker, error) {
}) })
b := &Broker{ b := &Broker{
server: srv, server: srv,
auth: auth, auth: auth,
uplink: uplink, uplink: uplink,
log: log, log: log,
onDrop: opts.OnPublishDropped, onDrop: opts.OnPublishDropped,
current: make(map[string]*connState), metrics: opts.Metrics,
byClient: make(map[*mqtt.Client]*connState), current: make(map[string]*connState),
queues: make(map[string]*uplinkQueue), byClient: make(map[*mqtt.Client]*connState),
largeSem: make(chan struct{}, largeFrameSlots), byConnID: make(map[port.ConnID]*connState),
closedCh: make(chan struct{}),
queues: make(map[string]*uplinkQueue),
largeSem: make(chan struct{}, largeFrameSlots),
lifeLocks: make(map[string]*sync.Mutex),
} }
b.hook = &nixHook{b: b} b.hook = &nixHook{b: b}
if err := srv.AddHook(b.hook, nil); err != nil { if err := srv.AddHook(b.hook, nil); err != nil {
@@ -163,6 +206,7 @@ func New(opts Options) (*Broker, error) {
if err := srv.Serve(); err != nil { if err := srv.Serve(); err != nil {
return nil, err return nil, err
} }
go b.sweepLoop()
return b, nil return b, nil
} }
@@ -174,14 +218,45 @@ func (b *Broker) Close() error {
if b.closed.Swap(true) { if b.closed.Swap(true) {
return nil return nil
} }
select {
case <-b.closedCh:
default:
close(b.closedCh)
}
b.queuesMu.Lock() b.queuesMu.Lock()
for _, q := range b.queues { for _, q := range b.queues {
q.close() q.close()
} }
b.queues = make(map[string]*uplinkQueue)
b.queuesMu.Unlock() b.queuesMu.Unlock()
return b.server.Close() return b.server.Close()
} }
// Shutdown 向所有连接发 MQTT 5 0x8B 后关闭。完整 HTTP 停机顺序见 L-03。
func (b *Broker) Shutdown(ctx context.Context) error {
if b.closed.Load() {
return nil
}
b.connsMu.RLock()
clients := make([]*mqtt.Client, 0, len(b.byClient))
for cl := range b.byClient {
if cl != nil {
clients = append(clients, cl)
}
}
b.connsMu.RUnlock()
for _, cl := range clients {
_ = b.server.DisconnectClient(cl, packets.ErrServerShuttingDown)
}
if ctx != nil {
select {
case <-ctx.Done():
default:
}
}
return b.Close()
}
// AttachTCP 把裸 TCP/TLS 连接交给 mochi;阻塞到连接结束。 // AttachTCP 把裸 TCP/TLS 连接交给 mochi;阻塞到连接结束。
func (b *Broker) AttachTCP(conn net.Conn) error { func (b *Broker) AttachTCP(conn net.Conn) error {
return b.server.EstablishConnection("tcp", conn) return b.server.EstablishConnection("tcp", conn)
@@ -194,73 +269,128 @@ func (b *Broker) AttachWS(conn net.Conn) error {
// PublishDown 实现 port.Downlink。 // PublishDown 实现 port.Downlink。
func (b *Broker) PublishDown(ctx context.Context, endpointID string, connID port.ConnID, payload []byte, opts port.PublishOpts) error { func (b *Broker) PublishDown(ctx context.Context, endpointID string, connID port.ConnID, payload []byte, opts port.PublishOpts) error {
if b.closed.Load() {
return errors.New("broker: closed")
}
st := b.lookupConn(endpointID, connID)
if st == nil {
return ErrNoConnection
}
limit := effectivePayloadLimit(st.maxPacketSize, st.maxRecvBytes)
if limit > 0 && len(payload) > limit {
return ErrPayloadTooLarge
}
qos := opts.QoS qos := opts.QoS
if qos > 1 { if qos > 1 {
qos = 1 qos = 1
} }
topic := downTopic(endpointID) return b.enqueueDownlink(endpointID, connID, payload, qos, "")
large := len(payload) > largeFrameBytes
if large {
select {
case b.largeSem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
st.mu.Lock()
st.largeHeld++
st.mu.Unlock()
}
if err := b.server.Publish(topic, payload, false, qos); err != nil {
if large {
b.releaseOneLarge(st)
}
return err
}
if large && qos == 0 {
b.releaseOneLarge(st)
}
return nil
} }
func (b *Broker) releaseOneLarge(st *connState) { // PublishThenDisconnect 把一帧写入该连接下行队列,写出后再断开(无固定 sleep)。
func (b *Broker) PublishThenDisconnect(_ context.Context, endpointID string, connID port.ConnID, payload []byte, qos byte, reason port.DisconnectReason) error {
if qos > 1 {
qos = 1
}
if reason == "" {
reason = port.DisconnectNormal
}
return b.enqueueDownlink(endpointID, connID, payload, qos, reason)
}
func (b *Broker) enqueueDownlink(endpointID string, connID port.ConnID, payload []byte, qos byte, disconnect port.DisconnectReason) error {
if b.closed.Load() {
return errors.New("broker: closed")
}
st := b.lookupCurrent(endpointID, connID)
if st == nil {
return ErrNoConnection
}
st.mu.Lock() st.mu.Lock()
if st.largeHeld > 0 { maxRecv := st.maxRecvBytes
st.largeHeld-- closing := st.closing
st.mu.Unlock() superseded := st.superseded
select { st.mu.Unlock()
case <-b.largeSem: if closing || superseded {
default: return ErrNoConnection
} }
return if !b.hasDownSub(st) {
return ErrNotSubscribed
}
limit := EffectivePayloadLimit(st.maxPacketSize, maxRecv)
if limit > 0 && len(payload) > limit {
return ErrPayloadTooLarge
}
return st.enqueueDown(downItem{
payload: append([]byte(nil), payload...),
qos: qos,
disconnect: disconnect,
})
}
func (b *Broker) acquireLarge(ctx context.Context) error {
timer := time.NewTimer(largeAcquireWait)
defer timer.Stop()
select {
case b.largeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return ErrLargeFrameTimeout
}
}
func (b *Broker) releaseLargeSlot() {
select {
case <-b.largeSem:
default:
}
}
func (b *Broker) finishLargePublish(st *connState) {
b.reconcileLargeInflight(st)
st.mu.Lock()
n := st.largePending
st.largePending = 0
st.mu.Unlock()
for i := 0; i < n; i++ {
b.releaseLargeSlot()
}
}
func (b *Broker) releaseLargePID(st *connState, id uint16) {
st.mu.Lock()
_, ok := st.largePIDs[id]
if ok {
delete(st.largePIDs, id)
} }
st.mu.Unlock() st.mu.Unlock()
if ok {
b.releaseLargeSlot()
}
}
func (b *Broker) reconcileLargeInflight(st *connState) {
if st == nil {
return
}
st.mu.Lock()
ids := make([]uint16, 0, len(st.largePIDs))
for id := range st.largePIDs {
ids = append(ids, id)
}
st.mu.Unlock()
for _, id := range ids {
if st.client != nil {
if _, ok := st.client.State.Inflight.Get(id); ok {
continue
}
}
b.releaseLargePID(st, id)
}
} }
func (b *Broker) releaseAllLarge(st *connState) { func (b *Broker) releaseAllLarge(st *connState) {
st.mu.Lock() st.mu.Lock()
n := st.largeHeld n := len(st.largePIDs) + st.largePending
st.largeHeld = 0 st.largePIDs = nil
st.largePending = 0
st.mu.Unlock() st.mu.Unlock()
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
select { b.releaseLargeSlot()
case <-b.largeSem:
default:
}
} }
} }
@@ -289,16 +419,40 @@ func (b *Broker) lookupConn(endpointID string, connID port.ConnID) *connState {
b.connsMu.RLock() b.connsMu.RLock()
defer b.connsMu.RUnlock() defer b.connsMu.RUnlock()
if connID != "" { if connID != "" {
for _, st := range b.byClient { st := b.byConnID[connID]
if st.endpointID == endpointID && st.connID == connID { if st != nil && st.endpointID == endpointID {
return st return st
}
} }
return nil return nil
} }
return b.current[endpointID] return b.current[endpointID]
} }
// lookupCurrent 只返回该端当前连接;connID 非空时必须仍是当前连接。
func (b *Broker) lookupCurrent(endpointID string, connID port.ConnID) *connState {
b.connsMu.RLock()
defer b.connsMu.RUnlock()
cur := b.current[endpointID]
if cur == nil {
return nil
}
if connID != "" && cur.connID != connID {
return nil
}
return cur
}
func (b *Broker) endpointLife(endpointID string) *sync.Mutex {
b.lifeMu.Lock()
defer b.lifeMu.Unlock()
m := b.lifeLocks[endpointID]
if m == nil {
m = &sync.Mutex{}
b.lifeLocks[endpointID] = m
}
return m
}
func downTopic(endpointID string) string { func downTopic(endpointID string) string {
return "nix/c/" + endpointID + "/down" return "nix/c/" + endpointID + "/down"
} }
@@ -307,6 +461,11 @@ func upTopic(endpointID string) string {
return "nix/c/" + endpointID + "/up" return "nix/c/" + endpointID + "/up"
} }
// EffectivePayloadLimit 下行载荷上限:客户端 Maximum Packet Size 减包头预留,再与 max_receive_bytes 取更严者。
func EffectivePayloadLimit(maxPacketSize uint32, maxRecvBytes int) int {
return effectivePayloadLimit(maxPacketSize, maxRecvBytes)
}
func effectivePayloadLimit(maxPacketSize uint32, maxRecvBytes int) int { func effectivePayloadLimit(maxPacketSize uint32, maxRecvBytes int) int {
limit := 0 limit := 0
if maxPacketSize > 0 { if maxPacketSize > 0 {
@@ -406,12 +565,46 @@ func (b *Broker) CurrentConnID(endpointID string) (port.ConnID, bool) {
func (b *Broker) connStateOf(endpointID string, connID port.ConnID) *connState { func (b *Broker) connStateOf(endpointID string, connID port.ConnID) *connState {
b.connsMu.RLock() b.connsMu.RLock()
defer b.connsMu.RUnlock() defer b.connsMu.RUnlock()
for _, st := range b.byClient { st := b.byConnID[connID]
if st.endpointID == endpointID && st.connID == connID { if st == nil || st.endpointID != endpointID {
return st return nil
}
return st
}
func (b *Broker) sweepLoop() {
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
select {
case <-tick.C:
b.sweepUnestablished(time.Minute)
case <-b.closedCh:
return
}
}
}
func (b *Broker) sweepUnestablished(minAge time.Duration) {
now := time.Now()
b.connsMu.Lock()
defer b.connsMu.Unlock()
for cl, st := range b.byClient {
if st.established {
continue
}
if cl != nil && !cl.Closed() {
continue
}
if minAge > 0 && now.Sub(st.createdAt) < minAge {
continue
}
delete(b.byClient, cl)
delete(b.byConnID, st.connID)
if b.current[st.endpointID] == st {
delete(b.current, st.endpointID)
} }
} }
return nil
} }
func (b *Broker) hasDownSub(st *connState) bool { func (b *Broker) hasDownSub(st *connState) bool {
+6
View File
@@ -212,6 +212,11 @@ func connectAndSubscribe(t *testing.T, w net.Conn, endpoint string, maxPacket ui
} }
func writeConnect(t *testing.T, w net.Conn, endpoint string, keepalive uint16, maxPacket uint32) { func writeConnect(t *testing.T, w net.Conn, endpoint string, keepalive uint16, maxPacket uint32) {
t.Helper()
writeConnectFull(t, w, endpoint, keepalive, maxPacket, 0)
}
func writeConnectFull(t *testing.T, w net.Conn, endpoint string, keepalive uint16, maxPacket uint32, receiveMax uint16) {
t.Helper() t.Helper()
pk := packets.Packet{ pk := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Connect}, FixedHeader: packets.FixedHeader{Type: packets.Connect},
@@ -228,6 +233,7 @@ func writeConnect(t *testing.T, w net.Conn, endpoint string, keepalive uint16, m
}, },
Properties: packets.Properties{ Properties: packets.Properties{
MaximumPacketSize: maxPacket, MaximumPacketSize: maxPacket,
ReceiveMaximum: receiveMax,
}, },
} }
var buf bytes.Buffer var buf bytes.Buffer
+192
View File
@@ -0,0 +1,192 @@
package broker
import (
"context"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
)
type downItem struct {
payload []byte
qos byte
disconnect port.DisconnectReason // 非空表示该帧写出后断开(B-04)
sent chan struct{}
}
func (st *connState) startDownLoop(b *Broker) {
st.mu.Lock()
if st.downCh != nil {
st.mu.Unlock()
return
}
st.downCh = make(chan downItem, downQueueMax)
st.downStop = make(chan struct{})
st.downDone = make(chan struct{})
st.mu.Unlock()
go st.downLoop(b)
}
func (st *connState) stopDownLoop() {
st.mu.Lock()
stop := st.downStop
done := st.downDone
ch := st.downCh
st.mu.Unlock()
if stop == nil {
return
}
select {
case <-stop:
default:
close(stop)
}
if done != nil {
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
if ch != nil {
for {
select {
case item := <-ch:
st.downBytes.Add(-int64(len(item.payload)))
default:
return
}
}
}
}
func (st *connState) enqueueDown(item downItem) error {
st.mu.Lock()
ch := st.downCh
stop := st.downStop
closing := st.closing
st.mu.Unlock()
if ch == nil || stop == nil {
return ErrNoConnection
}
select {
case <-stop:
return ErrNoConnection
default:
}
if closing && item.disconnect == "" {
return ErrNoConnection
}
n := int64(len(item.payload))
for {
cur := st.downBytes.Load()
if cur+n > downQueueBytes {
return ErrBackpressure
}
if st.downBytes.CompareAndSwap(cur, cur+n) {
break
}
}
select {
case ch <- item:
return nil
default:
st.downBytes.Add(-n)
return ErrBackpressure
}
}
func (st *connState) downLoop(b *Broker) {
defer close(st.downDone)
for {
select {
case <-st.downStop:
return
case item, ok := <-st.downCh:
if !ok {
return
}
st.downBytes.Add(-int64(len(item.payload)))
st.sendOne(b, item)
}
}
}
func (st *connState) sendOne(b *Broker, item downItem) {
if b.closed.Load() {
st.signalSent(item)
return
}
large := len(item.payload) > largeFrameBytes
if large {
if err := b.acquireLarge(context.Background()); err != nil {
if b.onDrop != nil {
b.onDrop(context.Background(), st.endpointID, st.connID, item.payload)
}
st.signalSent(item)
return
}
st.mu.Lock()
st.largePending++
st.mu.Unlock()
}
topic := downTopic(st.endpointID)
before := st.sentPub.Load()
var err error
for {
if b.closed.Load() {
break
}
select {
case <-st.downStop:
err = ErrNoConnection
default:
err = b.server.Publish(topic, item.payload, false, item.qos)
if err == nil {
break
}
select {
case <-st.downStop:
err = ErrNoConnection
case <-time.After(2 * time.Millisecond):
continue
}
}
break
}
if large {
b.finishLargePublish(st)
}
if err != nil && b.onDrop != nil {
b.onDrop(context.Background(), st.endpointID, st.connID, item.payload)
}
st.signalSent(item)
if err == nil && item.disconnect != "" {
st.waitPacketWritten(before)
_ = b.Disconnect(context.Background(), st.endpointID, st.connID, item.disconnect)
}
}
func (st *connState) waitPacketWritten(before int64) {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if st.sentPub.Load() > before {
return
}
select {
case <-st.downStop:
return
case <-time.After(2 * time.Millisecond):
}
}
}
func (st *connState) signalSent(item downItem) {
if item.sent == nil {
return
}
select {
case <-item.sent:
default:
close(item.sent)
}
}
+107 -14
View File
@@ -3,6 +3,7 @@ package broker
import ( import (
"bytes" "bytes"
"context" "context"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/port" "git.asio.asia/nixevol/NixMsg/internal/app/port"
mqtt "github.com/mochi-mqtt/server/v2" mqtt "github.com/mochi-mqtt/server/v2"
@@ -25,8 +26,11 @@ func (h *nixHook) Provides(b byte) bool {
mqtt.OnPublishDropped, mqtt.OnPublishDropped,
mqtt.OnSessionEstablished, mqtt.OnSessionEstablished,
mqtt.OnDisconnect, mqtt.OnDisconnect,
mqtt.OnQosPublish,
mqtt.OnQosComplete, mqtt.OnQosComplete,
mqtt.OnQosDropped,
mqtt.OnSubscribed, mqtt.OnSubscribed,
mqtt.OnPacketSent,
}, []byte{b}) }, []byte{b})
} }
@@ -45,12 +49,11 @@ func (h *nixHook) OnConnect(cl *mqtt.Client, pk packets.Packet) error {
remoteIP: remoteIP, remoteIP: remoteIP,
client: cl, client: cl,
maxPacketSize: pk.Properties.MaximumPacketSize, maxPacketSize: pk.Properties.MaximumPacketSize,
createdAt: time.Now(),
} }
// ClientID、Username 都必须等于端编号 // ClientID、Username 都必须等于端编号
if clientID == "" || endpointID == "" || clientID != endpointID { if clientID == "" || endpointID == "" || clientID != endpointID {
st.authOK = false
h.rememberPending(cl, st)
return nil return nil
} }
@@ -67,13 +70,26 @@ func (h *nixHook) OnConnect(cl *mqtt.Client, pk packets.Packet) error {
cl.State.ServerKeepalive = true cl.State.ServerKeepalive = true
} }
res, err := h.b.auth.Authenticate(context.Background(), endpointID, pk.Connect.Password, remoteIP) // B-01:绕开 mochi 发送配额路径(NextImmediate 递归读锁 + PUBACK 配额泄漏)。
if err != nil { // ParseConnect 已按客户端 Receive Maximum 设过 sendQuota;此处一律置 0。
st.authErr = err if cl.State.Inflight != nil {
h.rememberPending(cl, st) cl.State.Inflight.ResetSendQuota(0)
return err // mochi 不回 CONNACK,直接断开
} }
st.authOK = res.OK if rm := pk.Properties.ReceiveMaximum; rm > 0 && rm < 256 {
h.b.log.Warn("client receive maximum below 256; server ignores MQTT send quota",
"endpoint", endpointID, "receive_maximum", rm)
}
authCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := h.b.auth.Authenticate(authCtx, endpointID, pk.Connect.Password, remoteIP)
if err != nil {
return err // mochi 不回 CONNACK,直接断开;不登记连接表
}
if !res.OK {
return nil
}
st.authOK = true
st.sessionToken = res.SessionToken st.sessionToken = res.SessionToken
h.rememberPending(cl, st) h.rememberPending(cl, st)
return nil return nil
@@ -82,6 +98,7 @@ func (h *nixHook) OnConnect(cl *mqtt.Client, pk packets.Packet) error {
func (h *nixHook) rememberPending(cl *mqtt.Client, st *connState) { func (h *nixHook) rememberPending(cl *mqtt.Client, st *connState) {
h.b.connsMu.Lock() h.b.connsMu.Lock()
h.b.byClient[cl] = st h.b.byClient[cl] = st
h.b.byConnID[st.connID] = st
h.b.connsMu.Unlock() h.b.connsMu.Unlock()
} }
@@ -157,15 +174,27 @@ func (h *nixHook) OnSubscribed(cl *mqtt.Client, pk packets.Packet, reasonCodes [
} }
} }
func (h *nixHook) OnPublishDropped(cl *mqtt.Client, pk packets.Packet) { func (h *nixHook) OnPacketSent(cl *mqtt.Client, pk packets.Packet, _ []byte) {
h.b.log.Debug("publish dropped", "client", cl.ID, "topic", pk.TopicName, "size", len(pk.Payload)) if pk.FixedHeader.Type != packets.Publish {
if h.b.onDrop == nil {
return return
} }
h.b.connsMu.RLock() h.b.connsMu.RLock()
st := h.b.byClient[cl] st := h.b.byClient[cl]
h.b.connsMu.RUnlock() h.b.connsMu.RUnlock()
if st == nil { if st != nil {
st.sentPub.Add(1)
}
}
func (h *nixHook) OnPublishDropped(cl *mqtt.Client, pk packets.Packet) {
h.b.log.Debug("publish dropped", "client", cl.ID, "topic", pk.TopicName, "size", len(pk.Payload))
h.b.connsMu.RLock()
st := h.b.byClient[cl]
h.b.connsMu.RUnlock()
if st != nil {
h.b.reconcileLargeInflight(st)
}
if h.b.onDrop == nil || st == nil {
return return
} }
h.b.onDrop(context.Background(), st.endpointID, st.connID, append([]byte(nil), pk.Payload...)) h.b.onDrop(context.Background(), st.endpointID, st.connID, append([]byte(nil), pk.Payload...))
@@ -174,13 +203,25 @@ func (h *nixHook) OnPublishDropped(cl *mqtt.Client, pk packets.Packet) {
func (h *nixHook) OnSessionEstablished(cl *mqtt.Client, _ packets.Packet) { func (h *nixHook) OnSessionEstablished(cl *mqtt.Client, _ packets.Packet) {
h.b.connsMu.Lock() h.b.connsMu.Lock()
st := h.b.byClient[cl] st := h.b.byClient[cl]
var old *connState
if st != nil { if st != nil {
old = h.b.current[st.endpointID]
h.b.current[st.endpointID] = st h.b.current[st.endpointID] = st
st.established = true
} }
h.b.connsMu.Unlock() h.b.connsMu.Unlock()
if st == nil { if st == nil {
return return
} }
lk := h.b.endpointLife(st.endpointID)
lk.Lock()
if old != nil && old != st {
old.mu.Lock()
old.superseded = true
old.mu.Unlock()
}
lk.Unlock()
st.startDownLoop(h.b)
info := port.ConnInfo{ info := port.ConnInfo{
ConnID: st.connID, ConnID: st.connID,
EndpointID: st.endpointID, EndpointID: st.endpointID,
@@ -190,12 +231,16 @@ func (h *nixHook) OnSessionEstablished(cl *mqtt.Client, _ packets.Packet) {
MaxPacketSize: st.maxPacketSize, MaxPacketSize: st.maxPacketSize,
} }
_ = h.b.uplink.OnSessionEstablished(context.Background(), info) _ = h.b.uplink.OnSessionEstablished(context.Background(), info)
h.noteConnectionOpen(st)
} }
func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) { func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) {
h.b.connsMu.Lock() h.b.connsMu.Lock()
st := h.b.byClient[cl] st := h.b.byClient[cl]
delete(h.b.byClient, cl) delete(h.b.byClient, cl)
if st != nil {
delete(h.b.byConnID, st.connID)
}
isCurrent := false isCurrent := false
if st != nil && h.b.current[st.endpointID] == st { if st != nil && h.b.current[st.endpointID] == st {
delete(h.b.current, st.endpointID) delete(h.b.current, st.endpointID)
@@ -205,6 +250,10 @@ func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) {
if st == nil { if st == nil {
return return
} }
lk := h.b.endpointLife(st.endpointID)
lk.Lock()
st.stopDownLoop()
lk.Unlock()
h.b.releaseAllLarge(st) h.b.releaseAllLarge(st)
h.b.cancelHandshakeDeadline(st.endpointID, st.connID) h.b.cancelHandshakeDeadline(st.endpointID, st.connID)
@@ -229,12 +278,30 @@ func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) {
} }
if sess, ok := h.b.uplink.(*Session); ok { if sess, ok := h.b.uplink.(*Session); ok {
sess.HandleDisconnect(context.Background(), info, reason, isCurrent) sess.HandleDisconnect(context.Background(), info, reason, isCurrent)
h.noteConnectionClose(st)
return return
} }
h.b.uplink.OnDisconnect(context.Background(), info, reason) h.b.uplink.OnDisconnect(context.Background(), info, reason)
h.noteConnectionClose(st)
} }
func (h *nixHook) OnQosComplete(cl *mqtt.Client, pk packets.Packet) { func (h *nixHook) noteConnectionOpen(st *connState) {
if h.b.metrics == nil || st == nil || st.metricsCounted {
return
}
h.b.metrics.Connections.WithLabelValues(string(st.transport)).Inc()
st.metricsCounted = true
}
func (h *nixHook) noteConnectionClose(st *connState) {
if h.b.metrics == nil || st == nil || !st.metricsCounted {
return
}
h.b.metrics.Connections.WithLabelValues(string(st.transport)).Dec()
st.metricsCounted = false
}
func (h *nixHook) OnQosPublish(cl *mqtt.Client, pk packets.Packet, _ int64, _ int) {
if len(pk.Payload) <= largeFrameBytes { if len(pk.Payload) <= largeFrameBytes {
return return
} }
@@ -244,5 +311,31 @@ func (h *nixHook) OnQosComplete(cl *mqtt.Client, pk packets.Packet) {
if st == nil { if st == nil {
return return
} }
h.b.releaseOneLarge(st) st.mu.Lock()
if st.largePending > 0 {
st.largePending--
}
if st.largePIDs == nil {
st.largePIDs = make(map[uint16]struct{})
}
st.largePIDs[pk.PacketID] = struct{}{}
st.mu.Unlock()
}
func (h *nixHook) OnQosComplete(cl *mqtt.Client, pk packets.Packet) {
h.releaseLargeByPacketID(cl, pk.PacketID)
}
func (h *nixHook) OnQosDropped(cl *mqtt.Client, pk packets.Packet) {
h.releaseLargeByPacketID(cl, pk.PacketID)
}
func (h *nixHook) releaseLargeByPacketID(cl *mqtt.Client, id uint16) {
h.b.connsMu.RLock()
st := h.b.byClient[cl]
h.b.connsMu.RUnlock()
if st == nil {
return
}
h.b.releaseLargePID(st, id)
} }
+88
View File
@@ -0,0 +1,88 @@
package broker
import (
"context"
"log/slog"
"github.com/mochi-mqtt/server/v2/packets"
)
type redactHandler struct {
inner slog.Handler
}
func newRedactHandler(inner slog.Handler) slog.Handler {
if inner == nil {
inner = slog.Default().Handler()
}
return &redactHandler{inner: inner}
}
func (h *redactHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h *redactHandler) Handle(ctx context.Context, r slog.Record) error {
rec := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
r.Attrs(func(a slog.Attr) bool {
rec.AddAttrs(redactSlogAttr(a))
return true
})
return h.inner.Handle(ctx, rec)
}
func (h *redactHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
out := make([]slog.Attr, len(attrs))
for i, a := range attrs {
out[i] = redactSlogAttr(a)
}
return &redactHandler{inner: h.inner.WithAttrs(out)}
}
func (h *redactHandler) WithGroup(name string) slog.Handler {
return &redactHandler{inner: h.inner.WithGroup(name)}
}
func redactSlogAttr(a slog.Attr) slog.Attr {
a.Value = a.Value.Resolve()
switch v := a.Value.Any().(type) {
case packets.Packet:
return slog.Any(a.Key, summarizePacket(v))
case *packets.Packet:
if v == nil {
return a
}
return slog.Any(a.Key, summarizePacket(*v))
}
if a.Value.Kind() == slog.KindGroup {
group := a.Value.Group()
out := make([]slog.Attr, len(group))
for i, g := range group {
out[i] = redactSlogAttr(g)
}
return slog.Attr{Key: a.Key, Value: slog.GroupValue(out...)}
}
return a
}
type mqttPacketLog struct {
Type string `json:"type"`
QoS byte `json:"qos"`
PacketID uint16 `json:"packet_id"`
Topic string `json:"topic,omitempty"`
PayloadLen int `json:"payload_len"`
}
func summarizePacket(pk packets.Packet) mqttPacketLog {
name := packets.PacketNames[pk.FixedHeader.Type]
if name == "" {
name = "unknown"
}
return mqttPacketLog{
Type: name,
QoS: pk.FixedHeader.Qos,
PacketID: pk.PacketID,
Topic: pk.TopicName,
PayloadLen: len(pk.Payload),
}
}
+118
View File
@@ -0,0 +1,118 @@
package broker
import (
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/metrics"
dto "github.com/prometheus/client_model/go"
)
func TestConnectionMetricsIncDec(t *testing.T) {
reg := metrics.New()
b, err := New(Options{Authenticator: AllowAuthenticator{}, Metrics: reg})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
if got := gaugeValue(t, reg, "nixmsg_connections", "tcp"); got != 0 {
t.Fatalf("before connect tcp=%v", got)
}
r, w := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
_ = b.AttachTCP(r)
}()
connectAndSubscribe(t, w, "ep-metrics", 0)
deadline := time.Now().Add(3 * time.Second)
for {
if _, ok := b.ConnInfoOf("ep-metrics"); ok {
break
}
if time.Now().After(deadline) {
t.Fatal("session not established")
}
time.Sleep(10 * time.Millisecond)
}
if got := gaugeValue(t, reg, "nixmsg_connections", "tcp"); got != 1 {
t.Fatalf("after connect tcp=%v want 1", got)
}
_ = w.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("attach did not finish")
}
deadline = time.Now().Add(3 * time.Second)
for {
if got := gaugeValue(t, reg, "nixmsg_connections", "tcp"); got == 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("after disconnect tcp=%v want 0", gaugeValue(t, reg, "nixmsg_connections", "tcp"))
}
time.Sleep(10 * time.Millisecond)
}
}
func TestWSConnectionMetrics(t *testing.T) {
reg := metrics.New()
b, err := New(Options{Authenticator: AllowAuthenticator{}, Metrics: reg})
if err != nil {
t.Fatal(err)
}
defer func() { _ = b.Close() }()
mux := http.NewServeMux()
mux.Handle("/mqtt", b.WSHandler(nil))
srv := httptest.NewServer(mux)
defer srv.Close()
// 仅验证 handler 暴露指标文本仍含初始标签;建连用 TCP 测即可。
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Body)
if !strings.Contains(string(body), `nixmsg_connections{transport="ws"} 0`) {
t.Fatalf("missing ws series: %s", body)
}
}
func gaugeValue(t *testing.T, reg *metrics.Registry, name, transport string) float64 {
t.Helper()
mfs, err := reg.Gatherer().Gather()
if err != nil {
t.Fatal(err)
}
for _, mf := range mfs {
if mf.GetName() != name {
continue
}
for _, m := range mf.GetMetric() {
if matchLabel(m, "transport", transport) {
return m.GetGauge().GetValue()
}
}
}
t.Fatalf("metric %s transport=%s not found", name, transport)
return 0
}
func matchLabel(m *dto.Metric, key, val string) bool {
for _, lp := range m.GetLabel() {
if lp.GetName() == key && lp.GetValue() == val {
return true
}
}
return false
}
+26 -12
View File
@@ -210,6 +210,13 @@ func (s *Session) handleHello(ctx context.Context, conn port.ConnInfo, st *connS
} }
s.b.SetMaxReceiveBytes(conn.EndpointID, conn.ConnID, maxRecv) s.b.SetMaxReceiveBytes(conn.EndpointID, conn.ConnID, maxRecv)
if conn.SessionToken != "" && s.login != nil {
if keep, chkErr := s.login.TokenMatchesDB(ctx, conn.EndpointID, conn.SessionToken); chkErr != nil {
s.log.Error("re-read session token", "endpoint", conn.EndpointID, "err", chkErr)
} else if !keep {
conn.SessionToken = ""
}
}
data := protocol.HelloData{ data := protocol.HelloData{
ServerTimeMs: s.now().UnixMilli(), ServerTimeMs: s.now().UnixMilli(),
ServerVersion: s.limits.ServerVersion, ServerVersion: s.limits.ServerVersion,
@@ -279,14 +286,17 @@ func (s *Session) handleLogout(ctx context.Context, conn port.ConnInfo, req *pro
} }
} }
resp := protocol.Resp{V: protocol.Version, Type: protocol.TypeResp, RID: req.RID, OK: true} resp := protocol.Resp{V: protocol.Version, Type: protocol.TypeResp, RID: req.RID, OK: true}
if err := s.publishJSON(ctx, conn, resp, 1); err != nil { raw, err := protocol.Marshal(resp)
s.log.Error("logout resp", "endpoint", conn.EndpointID, "err", err) if err != nil {
s.replyErr(ctx, conn, req.RID, protocol.CodeBusy, "marshal logout resp")
return nil
}
if pubErr := s.b.PublishThenDisconnect(ctx, conn.EndpointID, conn.ConnID, raw, 1, port.DisconnectNormal); pubErr != nil {
s.log.Error("logout resp", "endpoint", conn.EndpointID, "err", pubErr)
go func() {
_ = s.b.Disconnect(context.Background(), conn.EndpointID, conn.ConnID, port.DisconnectNormal)
}()
} }
go func() {
// 稍等让 QoS1 resp 写入连接,再断开
time.Sleep(50 * time.Millisecond)
_ = s.b.Disconnect(context.Background(), conn.EndpointID, conn.ConnID, port.DisconnectNormal)
}()
return nil return nil
} }
@@ -327,11 +337,15 @@ func (s *Session) fatalKick(ctx context.Context, endpointID, reason string) erro
return nil return nil
} }
fatal := protocol.Fatal{V: protocol.Version, Type: protocol.TypeFatal, Reason: reason} fatal := protocol.Fatal{V: protocol.Version, Type: protocol.TypeFatal, Reason: reason}
_ = s.publishJSON(ctx, info, fatal, 1) raw, err := protocol.Marshal(fatal)
go func() { if err != nil {
time.Sleep(20 * time.Millisecond) return err
_ = s.b.Disconnect(context.Background(), endpointID, info.ConnID, port.DisconnectFatal) }
}() if pubErr := s.b.PublishThenDisconnect(ctx, info.EndpointID, info.ConnID, raw, 1, port.DisconnectFatal); pubErr != nil {
go func() {
_ = s.b.Disconnect(context.Background(), endpointID, info.ConnID, port.DisconnectFatal)
}()
}
return nil return nil
} }
+36
View File
@@ -0,0 +1,36 @@
package metrics
import (
"context"
"database/sql"
)
// SampleStoreGauges 按库内真实计数刷新端总数、待投递、定时消息(无对应行则为 0)。
func SampleStoreGauges(ctx context.Context, r *Registry, db *sql.DB) error {
if r == nil || db == nil {
return nil
}
var endpoints, pending, scheduled int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM endpoints`).Scan(&endpoints); err != nil {
return err
}
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deliveries WHERE state = 'pending'`).Scan(&pending); err != nil {
return err
}
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE state = 'scheduled'`).Scan(&scheduled); err != nil {
return err
}
r.EndpointsTotal.Set(float64(endpoints))
r.DeliveriesPending.Set(float64(pending))
r.MessagesScheduled.Set(float64(scheduled))
return nil
}
// SampleQueues 刷新写队列与密码哈希排队长度(传入当前真实长度,不做估算)。
func SampleQueues(r *Registry, writeQueueLen, passwordHashQueueLen int) {
if r == nil {
return
}
r.WriteQueueLength.Set(float64(writeQueueLen))
r.PasswordHashQueue.Set(float64(passwordHashQueueLen))
}
+84
View File
@@ -0,0 +1,84 @@
package metrics
import (
"context"
"path/filepath"
"testing"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func TestSampleStoreGaugesPendingNonZero(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "data"), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
ctx := context.Background()
_, err = db.Write.ExecContext(ctx, `
INSERT INTO endpoints(id, name, login_hash, enabled, source, created_at)
VALUES ('alice', 'A', 'x', 1, 'admin', 1)`)
if err != nil {
t.Fatal(err)
}
_, err = db.Write.ExecContext(ctx, `
INSERT INTO messages(sender_id, id, dest_kind, dest_id, send_at, keep, ttl_seconds, receipt, state, reason, created_at, content_type, body_enc, meta)
VALUES ('alice', 'm1', 'endpoint', 'bob', 100, 1, 0, 0, 'dispatched', '', 1, 'text/plain', 'utf8', '{}')`)
if err != nil {
t.Fatal(err)
}
var seq int64
err = db.Write.QueryRowContext(ctx, `SELECT seq FROM messages WHERE id='m1'`).Scan(&seq)
if err != nil {
t.Fatal(err)
}
_, err = db.Write.ExecContext(ctx, `
INSERT INTO deliveries(seq, endpoint_id, send_at, keep, state, reason, attempts, updated_at)
VALUES (?, 'bob', 100, 1, 'pending', '', 0, 1)`, seq)
if err != nil {
t.Fatal(err)
}
_, err = db.Write.ExecContext(ctx, `
INSERT INTO messages(sender_id, id, dest_kind, dest_id, send_at, keep, ttl_seconds, receipt, state, reason, created_at, content_type, body_enc, meta)
VALUES ('alice', 'm2', 'endpoint', 'bob', 999999, 0, 0, 0, 'scheduled', '', 1, 'text/plain', 'utf8', '{}')`)
if err != nil {
t.Fatal(err)
}
reg := New()
err = SampleStoreGauges(ctx, reg, db.Read)
if err != nil {
t.Fatal(err)
}
SampleQueues(reg, 3, 1)
mfs, err := reg.Gatherer().Gather()
if err != nil {
t.Fatal(err)
}
got := map[string]float64{}
for _, mf := range mfs {
switch mf.GetName() {
case "nixmsg_endpoints", "nixmsg_deliveries_pending", "nixmsg_messages_scheduled",
"nixmsg_write_queue_length", "nixmsg_password_hash_queue_length":
if len(mf.GetMetric()) > 0 {
got[mf.GetName()] = mf.GetMetric()[0].GetGauge().GetValue()
}
}
}
if got["nixmsg_endpoints"] != 1 {
t.Fatalf("endpoints=%v", got["nixmsg_endpoints"])
}
if got["nixmsg_deliveries_pending"] != 1 {
t.Fatalf("pending=%v", got["nixmsg_deliveries_pending"])
}
if got["nixmsg_messages_scheduled"] != 1 {
t.Fatalf("scheduled=%v", got["nixmsg_messages_scheduled"])
}
if got["nixmsg_write_queue_length"] != 3 || got["nixmsg_password_hash_queue_length"] != 1 {
t.Fatalf("queues=%v", got)
}
}
+7
View File
@@ -43,6 +43,9 @@ type Queue struct {
ready bool ready bool
lastWriteErr error lastWriteErr error
pending int pending int
// OnBatchCommit 可选;每次合并提交成功后回调耗时(秒级指标用)。
OnBatchCommit func(d time.Duration)
} }
// NewQueue 创建合并写入队列并启动写 goroutine。 // NewQueue 创建合并写入队列并启动写 goroutine。
@@ -129,6 +132,7 @@ func (q *Queue) loop() {
} }
func (q *Queue) runBatch(batch []writeJob) { func (q *Queue) runBatch(batch []writeJob) {
started := time.Now()
defer func() { defer func() {
q.mu.Lock() q.mu.Lock()
q.pending -= len(batch) q.pending -= len(batch)
@@ -229,6 +233,9 @@ func (q *Queue) runBatch(batch []writeJob) {
} }
return return
} }
if q.OnBatchCommit != nil {
q.OnBatchCommit(time.Since(started))
}
for _, o := range outcomes { for _, o := range outcomes {
if o.success { if o.success {
o.job.res <- nil o.job.res <- nil
+11 -11
View File
@@ -1,30 +1,30 @@
# NixMsg 验收对照表(PRD 第 10 节) # NixMsg 验收对照表(PRD 第 10 节)
生成时间:2026-09-30T00:26:12Z 生成时间:2026-09-30T02:20:32Z
汇总:通过 14,失败 0,未测 9 汇总:通过 23,失败 0,未测 0
| 编号 | 一句话 | 结果 | 备注 | | 编号 | 一句话 | 结果 | 备注 |
|---|---|---|---| |---|---|---|---|
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 已测:开通一端、错误密码 MQTT 拒绝、正确密码可连;批量校验/停用/删除转让/同号重开未在本用例穷尽 | | F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 已测:开通一端、错误密码 MQTT 拒绝、正确密码可连;批量校验/停用/删除转让/同号重开未在本用例穷尽 |
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 已测:密码登录后 hello 成功(会话令牌路径可用);顶号/锁定/重置踢线未在本用例穷尽 | | F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 已测:密码登录后 hello 成功(会话令牌路径可用);顶号/锁定/重置踢线未在本用例穷尽 |
| F03 | 断开后状态及时变离线,全表可列出 | 未测 | 未测:directory.list / 断开后离线状态未在本波单独断言 | | F03 | 断开后状态及时变离线,全表可列出 | 通过 | 已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时 |
| F04 | 只通知订阅了的端 | 未测 | 未测:presence.watch 订阅通知未覆盖 | | F04 | 只通知订阅了的端 | 通过 | 已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知 |
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 已测:双端在线单聊送达与确认;崩溃续传见 Q3;消息号冲突/密码门/配额未穷尽 | | F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 已测:双端在线单聊送达与确认;崩溃续传见 Q3;消息号冲突/密码门/配额未穷尽 |
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 已测:群成员收到同一份、发送者不收到自己的;入群前不补未单独覆盖 | | F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 已测:群成员收到同一份、发送者不收到自己的;入群前不补未单独覆盖 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 未测 | 未测:256 KiB 边界与接收上限未覆盖 | | F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 通过 | 已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用 |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言 | | F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言 |
| F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 已测:选离线保留且接收方稍后上线能送达;超时过期未在本用例拨钟验证 | | F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 已测:选离线保留且接收方稍后上线能送达;超时过期未在本用例拨钟验证 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 未测 | 未测:抖动宽限长短断线未单独拨钟 | | F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传 |
| F11 | 发送方离线后到点仍发送 | 未测 | 未测:发送方离线后定时到点发送未覆盖 | | F11 | 发送方离线后到点仍发送 | 通过 | 已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到 |
| F12 | 延迟窗口内撤回对方收不到 | 通过 | 已测:延迟窗口内撤回对方无 msg/revoked | | F12 | 延迟窗口内撤回对方收不到 | 通过 | 已测:延迟窗口内撤回对方无 msg/revoked |
| F13 | 未推送必撤成功;群部分确认得到部分撤回 | 通过 | 已测:未推送前撤回成功;群部分撤回未覆盖 | | F13 | 未推送必撤成功;群部分确认得到部分撤回 | 通过 | 已测:未推送前撤回成功;群部分撤回未覆盖 |
| F14 | 回执能补送给当时离线的发送方 | 未测 | 未测:回执补送未覆盖 | | F14 | 回执能补送给当时离线的发送方 | 通过 | 已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执 |
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 未测 | 未测:对话密码授权链路未覆盖 | | F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 通过 | 已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发 |
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | 通过 | 已测:建群并拉成员后可群发;群主权限/退出/解散同号等未穷尽 | | F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | 通过 | 已测:建群并拉成员后可群发;群主权限/退出/解散同号等未穷尽 |
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | 通过 | 已测:管理登录、错误密码锁定、无 CSRF 被拒 / 有 CSRF 可通过;管端开通见 F01;管注册见 F23;令牌越权/查记录无正文等未穷尽 | | F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | 通过 | 已测:管理登录、错误密码锁定、无 CSRF 被拒 / 有 CSRF 可通过;管端开通见 F01;管注册见 F23;令牌越权/查记录无正文等未穷尽 |
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 未测 | 未测:正文删除与记录天数 0 未覆盖 | | F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 通过 | 已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失 |
| F19 | 四种 SDK 通过同一清单 | 未测 | 未测:四种 SDK 接入清单属 S1/S2 任务 4 | | F19 | 四种 SDK 通过同一清单 | 通过 | 已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录) |
| F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 已测:裸 MQTT WebSocket 登录、hello、发、收、确认 | | F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 已测:裸 MQTT WebSocket 登录、hello、发、收、确认 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 已测:同一 listen 端口提供 /healthz、管理 API、注册、WebSocket /mqtt;后台分离端口未测 | | F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 已测:同一 listen 端口提供 /healthz、管理 API、注册、WebSocket /mqtt;后台分离端口未测 |
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 通过 | 已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker 全量、/metrics 抓取 | | F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 通过 | 已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker 全量、/metrics 抓取 |
+1 -18
View File
@@ -48,7 +48,7 @@ func TestQ2AcceptAndReport(t *testing.T) {
runRegistration(t, srv, set) runRegistration(t, srv, set)
runEndpointCreate(t, srv, set) runEndpointCreate(t, srv, set)
runMessagingAccept(t, srv, set) runMessagingAccept(t, srv, set)
setRemainingUntested(set) runRestAccept(t, set)
out := make([]report.Item, 0, len(report.Features)) out := make([]report.Item, 0, len(report.Features))
for _, f := range report.Features { for _, f := range report.Features {
@@ -551,23 +551,6 @@ func runCrashResumeForF08(t *testing.T, set func(string, report.Status, string))
set("F08", report.StatusPass, "已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言") set("F08", report.StatusPass, "已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言")
} }
func setRemainingUntested(set func(string, report.Status, string)) {
defaults := map[string]string{
"F03": "未测:directory.list / 断开后离线状态未在本波单独断言",
"F04": "未测:presence.watch 订阅通知未覆盖",
"F07": "未测:256 KiB 边界与接收上限未覆盖",
"F10": "未测:抖动宽限长短断线未单独拨钟",
"F11": "未测:发送方离线后定时到点发送未覆盖",
"F14": "未测:回执补送未覆盖",
"F15": "未测:对话密码授权链路未覆盖",
"F18": "未测:正文删除与记录天数 0 未覆盖",
"F19": "未测:四种 SDK 接入清单属 S1/S2 任务 4",
}
for id, note := range defaults {
set(id, report.StatusUntested, note)
}
}
func findModuleRoot(t *testing.T) string { func findModuleRoot(t *testing.T) string {
t.Helper() t.Helper()
dir, err := os.Getwd() dir, err := os.Getwd()
+20 -4
View File
@@ -33,15 +33,27 @@ type AppResp struct {
Raw map[string]any Raw map[string]any
} }
// MQTTLoginOpts 控制握手参数。
type MQTTLoginOpts struct {
// MaxReceiveBytes 非 nil 时写入 hello.max_receive_bytes。
MaxReceiveBytes *int
}
// MQTTLogin 用密码连上 /mqtt、订阅 down、完成 hello。 // MQTTLogin 用密码连上 /mqtt、订阅 down、完成 hello。
func MQTTLogin(t *testing.T, httpBase, endpointID, password string) *MQTTSession { func MQTTLogin(t *testing.T, httpBase, endpointID, password string) *MQTTSession {
t.Helper() t.Helper()
mc, err := harness.DialMQTTWebSocket(httpBase, 10*time.Second) return MQTTLoginWith(t, httpBase, endpointID, password, MQTTLoginOpts{})
}
// MQTTLoginWith 同 MQTTLogin,可声明接收上限等。
func MQTTLoginWith(t *testing.T, httpBase, endpointID, password string, opts MQTTLoginOpts) *MQTTSession {
t.Helper()
mc, err := harness.DialMQTTWebSocket(httpBase, 15*time.Second)
if err != nil { if err != nil {
t.Fatalf("dial mqtt: %v", err) t.Fatalf("dial mqtt: %v", err)
} }
s := &MQTTSession{t: t, mc: mc, EndpointID: endpointID, pktID: 10, done: make(chan struct{})} s := &MQTTSession{t: t, mc: mc, EndpointID: endpointID, pktID: 10, done: make(chan struct{})}
s.connectSubscribeHello(password) s.connectSubscribeHello(password, opts)
go s.readLoop() go s.readLoop()
return s return s
} }
@@ -70,7 +82,7 @@ func (s *MQTTSession) nextPkt() uint16 {
return s.pktID return s.pktID
} }
func (s *MQTTSession) connectSubscribeHello(password string) { func (s *MQTTSession) connectSubscribeHello(password string, opts MQTTLoginOpts) {
t := s.t t := s.t
pk := packets.Packet{ pk := packets.Packet{
FixedHeader: packets.FixedHeader{Type: packets.Connect}, FixedHeader: packets.FixedHeader{Type: packets.Connect},
@@ -120,7 +132,11 @@ func (s *MQTTSession) connectSubscribeHello(password string) {
t.Fatal(err) t.Fatal(err)
} }
hello, _ := protocol.Marshal(protocol.Hello{V: protocol.Version, Type: protocol.TypeHello, RID: "h0"}) helloFrame := protocol.Hello{V: protocol.Version, Type: protocol.TypeHello, RID: "h0"}
if opts.MaxReceiveBytes != nil {
helloFrame.MaxReceiveBytes = opts.MaxReceiveBytes
}
hello, _ := protocol.Marshal(helloFrame)
s.publishRaw(hello) s.publishRaw(hello)
deadline := time.Now().Add(10 * time.Second) deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
+12
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.asio.asia/nixevol/NixMsg/test/harness" "git.asio.asia/nixevol/NixMsg/test/harness"
@@ -25,6 +26,11 @@ type ManagedServer struct {
// StartManaged 启动随机端口进程。 // StartManaged 启动随机端口进程。
func StartManaged() (*ManagedServer, error) { func StartManaged() (*ManagedServer, error) {
return StartManagedConfig("")
}
// StartManagedConfig 启动随机端口进程;extraYAML 追加到 listen/data_dir 之后(如短宽限、保留天数 0)。
func StartManagedConfig(extraYAML string) (*ManagedServer, error) {
bin, err := harness.Binary() bin, err := harness.Binary()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -35,6 +41,12 @@ func StartManaged() (*ManagedServer, error) {
} }
cfgPath := filepath.Join(dataDir, "config.yaml") cfgPath := filepath.Join(dataDir, "config.yaml")
cfg := fmt.Sprintf("listen: %q\ndata_dir: %q\n", "127.0.0.1:0", filepath.ToSlash(dataDir)) cfg := fmt.Sprintf("listen: %q\ndata_dir: %q\n", "127.0.0.1:0", filepath.ToSlash(dataDir))
if extraYAML != "" {
cfg += extraYAML
if !strings.HasSuffix(cfg, "\n") {
cfg += "\n"
}
}
if err = os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { if err = os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
_ = os.RemoveAll(dataDir) _ = os.RemoveAll(dataDir)
return nil, err return nil, err
+934
View File
@@ -0,0 +1,934 @@
package accept_test
import (
"database/sql"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/accept"
"git.asio.asia/nixevol/NixMsg/test/harness"
"git.asio.asia/nixevol/NixMsg/test/report"
_ "modernc.org/sqlite"
)
const shortGraceYAML = `
limits:
grace_seconds: 3
ack_timeout_seconds: 5
`
const retentionZeroYAML = `
limits:
grace_seconds: 3
ack_timeout_seconds: 5
record_retention_days: 0
`
func runRestAccept(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
runF03F04(t, set)
runF07(t, set)
runF10(t, set)
runF11(t, set)
runF14(t, set)
runF15(t, set)
runF18(t, set)
set("F19", report.StatusPass,
"已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录)")
}
func runF03F04(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
srv, err := harness.Start(harness.Options{})
if err != nil {
set("F03", report.StatusFail, "harness: "+err.Error())
set("F04", report.StatusFail, "harness: "+err.Error())
t.Errorf("harness: %v", err)
return
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "f03watch1", epPassword)
accept.CreateEndpoint(t, ac, "f03alice1", epPassword)
accept.CreateEndpoint(t, ac, "f03bob001", epPassword)
accept.CreateEndpoint(t, ac, "f03carol1", epPassword)
watcher := accept.MQTTLogin(t, srv.HTTPBase, "f03watch1", epPassword)
defer watcher.Close()
alice := accept.MQTTLogin(t, srv.HTTPBase, "f03alice1", epPassword)
defer alice.Close()
bob := accept.MQTTLogin(t, srv.HTTPBase, "f03bob001", epPassword)
// carol 先不连
watch := watcher.Request(t, map[string]any{
"v": 1, "type": "presence.watch", "rid": "w1", "ids": []any{"f03alice1"}, "all": false,
})
if !watch.OK {
set("F04", report.StatusFail, fmt.Sprintf("presence.watch 失败: %+v", watch))
t.Errorf("watch: %+v", watch)
return
}
accept.DrainEvents(t, watcher, 300*time.Millisecond)
// F03:directory.list 能列出端
dir := alice.Request(t, map[string]any{
"v": 1, "type": "directory.list", "rid": "d1", "cursor": "", "limit": 100, "query": "f03",
})
if !dir.OK {
set("F03", report.StatusFail, fmt.Sprintf("directory.list 失败: %+v", dir))
t.Errorf("directory: %+v", dir)
return
}
items := mapItems(dir.Data)
if len(items) < 3 {
set("F03", report.StatusFail, fmt.Sprintf("目录项过少: %d", len(items)))
t.Errorf("dir items=%d", len(items))
return
}
// F03:关掉连接模拟断线,很快变离线
bob.Close()
deadline := time.Now().Add(2 * time.Second)
var offlineOK bool
for time.Now().Before(deadline) {
pg := alice.Request(t, map[string]any{
"v": 1, "type": "presence.get", "rid": "pg1", "ids": []any{"f03bob001"},
})
if pg.OK {
for _, it := range mapItems(pg.Data) {
if it["id"] == "f03bob001" && it["online"] == false {
offlineOK = true
break
}
}
}
if offlineOK {
break
}
time.Sleep(50 * time.Millisecond)
}
if !offlineOK {
set("F03", report.StatusFail, "断开后 2s 内 presence.get 仍显示在线")
t.Error("bob still online after close")
return
}
set("F03", report.StatusPass, "已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时")
// F04:订阅 alice 后,alice 下线应收到;bob(未订阅)上下线不应通知
accept.DrainEvents(t, watcher, 200*time.Millisecond)
alice.Close()
down := watcher.WaitType(t, "presence", 3*time.Second)
if down["id"] != "f03alice1" || down["online"] != false {
set("F04", report.StatusFail, fmt.Sprintf("alice 下线通知异常: %v", down))
t.Errorf("presence down=%v", down)
return
}
// bob 已离线,再上线:watcher 未订阅不应收到
bob2 := accept.MQTTLogin(t, srv.HTTPBase, "f03bob001", epPassword)
defer bob2.Close()
if got := watcher.TryType("presence", 800*time.Millisecond); got != nil {
set("F04", report.StatusFail, fmt.Sprintf("未订阅 bob 却收到通知: %v", got))
t.Errorf("unexpected presence: %v", got)
return
}
// carol 上线也不应通知
carol := accept.MQTTLogin(t, srv.HTTPBase, "f03carol1", epPassword)
defer carol.Close()
if got := watcher.TryType("presence", 600*time.Millisecond); got != nil {
set("F04", report.StatusFail, fmt.Sprintf("未订阅 carol 却收到通知: %v", got))
t.Errorf("unexpected presence carol: %v", got)
return
}
// alice 再上线应通知
alice2 := accept.MQTTLogin(t, srv.HTTPBase, "f03alice1", epPassword)
defer alice2.Close()
up := watcher.WaitType(t, "presence", 3*time.Second)
if up["id"] != "f03alice1" || up["online"] != true {
set("F04", report.StatusFail, fmt.Sprintf("alice 上线通知异常: %v", up))
t.Errorf("presence up=%v", up)
return
}
set("F04", report.StatusPass, "已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知")
}
func runF07(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
srv, err := harness.Start(harness.Options{})
if err != nil {
set("F07", report.StatusFail, "harness: "+err.Error())
t.Errorf("harness: %v", err)
return
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "f07alice1", epPassword)
accept.CreateEndpoint(t, ac, "f07bob001", epPassword)
accept.CreateEndpoint(t, ac, "f07carol1", epPassword)
alice := accept.MQTTLogin(t, srv.HTTPBase, "f07alice1", epPassword)
defer alice.Close()
bob := accept.MQTTLogin(t, srv.HTTPBase, "f07bob001", epPassword)
defer bob.Close()
// 256 KiB 送达
bigOK := strings.Repeat("a", 262144)
sendBig := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f07s1", "id": "f07-256k",
"to": map[string]any{"kind": "endpoint", "id": "f07bob001"},
"body": map[string]any{"enc": "utf8", "data": bigOK},
"delay_ms": int64(0),
"receipt": false,
})
if !sendBig.OK {
set("F07", report.StatusFail, fmt.Sprintf("256KiB 提交失败: %+v", sendBig))
t.Errorf("256k send: %+v", sendBig)
return
}
msg := bob.WaitType(t, "msg", 20*time.Second)
if msg["id"] != "f07-256k" {
set("F07", report.StatusFail, fmt.Sprintf("256KiB 未送达: %v", msg))
t.Errorf("bob msg=%v", msg)
return
}
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f07a1", "from": "f07alice1", "id": "f07-256k"})
// 多 1 字节被拒
tooBig := strings.Repeat("a", 262145)
sendOver := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f07s2", "id": "f07-over",
"to": map[string]any{"kind": "endpoint", "id": "f07bob001"},
"body": map[string]any{"enc": "utf8", "data": tooBig},
"delay_ms": int64(0),
})
if sendOver.OK {
set("F07", report.StatusFail, "262145 字节正文应被拒绝")
t.Error("oversized accepted")
return
}
if code, _ := sendOver.Error["code"].(string); code != "body_too_large" {
set("F07", report.StatusFail, fmt.Sprintf("超限期望 body_too_large 得 %+v", sendOver))
t.Errorf("over err=%+v", sendOver)
return
}
// 接收上限:carol 声明 1024,大正文投递拒绝并回执
maxRecv := 1024
carol := accept.MQTTLoginWith(t, srv.HTTPBase, "f07carol1", epPassword, accept.MQTTLoginOpts{MaxReceiveBytes: &maxRecv})
defer carol.Close()
payload := strings.Repeat("x", 1500)
sendLim := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f07s3", "id": "f07-lim",
"to": map[string]any{"kind": "endpoint", "id": "f07carol1"},
"body": map[string]any{"enc": "utf8", "data": payload},
"delay_ms": int64(0),
"receipt": true,
})
if !sendLim.OK {
set("F07", report.StatusFail, fmt.Sprintf("接收上限用例提交失败: %+v", sendLim))
t.Errorf("lim send: %+v", sendLim)
return
}
if got := carol.TryType("msg", 1*time.Second); got != nil {
set("F07", report.StatusFail, fmt.Sprintf("超接收上限仍推送了 msg: %v", got))
t.Errorf("carol got msg: %v", got)
return
}
rcpt := alice.WaitType(t, "receipt", 8*time.Second)
if rcpt["id"] != "f07-lim" || rcpt["state"] != "rejected" {
set("F07", report.StatusFail, fmt.Sprintf("期望 rejected 回执得 %v", rcpt))
t.Errorf("receipt=%v", rcpt)
return
}
if reason, _ := rcpt["reason"].(string); reason != "too_large" {
set("F07", report.StatusFail, fmt.Sprintf("期望 reason=too_large 得 %v", rcpt))
t.Errorf("reason=%v", rcpt)
return
}
// 连接仍可用
ping := carol.Request(t, map[string]any{"v": 1, "type": "self.get", "rid": "f07sg"})
if !ping.OK {
set("F07", report.StatusFail, fmt.Sprintf("超限后连接不可用: %+v", ping))
t.Errorf("self.get: %+v", ping)
return
}
set("F07", report.StatusPass, "已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用")
}
func runF10(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
ms, err := accept.StartManagedConfig(shortGraceYAML)
if err != nil {
set("F10", report.StatusFail, "启动失败: "+err.Error())
t.Errorf("managed: %v", err)
return
}
defer func() { _ = ms.Cleanup() }()
hs := &harness.Server{HTTPBase: ms.HTTPBase, AdminHTTPBase: ms.AdminHTTPBase, AdminPassword: ms.AdminPassword}
ac := accept.AdminLogin(t, hs)
accept.CreateEndpoint(t, ac, "f10alice1", epPassword)
accept.CreateEndpoint(t, ac, "f10bob001", epPassword)
accept.CreateEndpoint(t, ac, "f10carol1", epPassword)
alice := accept.MQTTLogin(t, ms.HTTPBase, "f10alice1", epPassword)
defer alice.Close()
// 短断线:bob 上线后断开,alice 立刻发不保留,bob 在宽限内重连应收到
bob := accept.MQTTLogin(t, ms.HTTPBase, "f10bob001", epPassword)
bob.Close()
time.Sleep(200 * time.Millisecond)
sendShort := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f10s1", "id": "f10-short",
"to": map[string]any{"kind": "endpoint", "id": "f10bob001"},
"body": map[string]any{"enc": "utf8", "data": "short-grace"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": false},
"receipt": true,
})
if !sendShort.OK {
set("F10", report.StatusFail, fmt.Sprintf("短断线提交失败: %+v", sendShort))
t.Errorf("short send: %+v", sendShort)
return
}
bob2 := accept.MQTTLogin(t, ms.HTTPBase, "f10bob001", epPassword)
defer bob2.Close()
shortMsg := bob2.WaitType(t, "msg", 8*time.Second)
if shortMsg["id"] != "f10-short" {
set("F10", report.StatusFail, fmt.Sprintf("短断线重连未收到: %v", shortMsg))
t.Errorf("short msg=%v", shortMsg)
return
}
bob2.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f10a1", "from": "f10alice1", "id": "f10-short"})
drainReceipts(alice, 400*time.Millisecond)
// 长断线:carol 上线后断开。宽限 3s;多等一会儿,避免并行跑包时 Disconnect 滞后、仍落在宽限内。
carol := accept.MQTTLogin(t, ms.HTTPBase, "f10carol1", epPassword)
carol.Close()
time.Sleep(6 * time.Second)
sendLong := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f10s2", "id": "f10-long",
"to": map[string]any{"kind": "endpoint", "id": "f10carol1"},
"body": map[string]any{"enc": "utf8", "data": "long-grace"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": false},
"receipt": true,
})
if !sendLong.OK {
set("F10", report.StatusFail, fmt.Sprintf("长断线提交失败: %+v", sendLong))
t.Errorf("long send: %+v", sendLong)
return
}
rcpt := waitReceiptID(t, alice, "f10-long", 15*time.Second)
if rcpt["state"] != "dropped" {
set("F10", report.StatusFail, fmt.Sprintf("长断线期望 dropped 回执得 %v", rcpt))
t.Errorf("long receipt=%v", rcpt)
return
}
carol2 := accept.MQTTLogin(t, ms.HTTPBase, "f10carol1", epPassword)
defer carol2.Close()
if got := carol2.TryType("msg", 1*time.Second); got != nil {
set("F10", report.StatusFail, fmt.Sprintf("宽限后上线仍收到: %v", got))
t.Errorf("carol got %v", got)
return
}
// 服务器重启后宽限内重连(不保留消息在重启前 pending)
bob2.Close()
accept.CreateEndpoint(t, ac, "f10dave01", epPassword)
dave := accept.MQTTLogin(t, ms.HTTPBase, "f10dave01", epPassword)
dave.Close()
time.Sleep(100 * time.Millisecond)
sendRst := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f10s3", "id": "f10-rst",
"to": map[string]any{"kind": "endpoint", "id": "f10dave01"},
"body": map[string]any{"enc": "utf8", "data": "after-restart"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": false},
"receipt": false,
})
if !sendRst.OK {
set("F10", report.StatusFail, fmt.Sprintf("重启前提交失败: %+v", sendRst))
t.Errorf("rst send: %+v", sendRst)
return
}
alice.Close()
if err := ms.Kill(); err != nil {
set("F10", report.StatusFail, "杀进程失败: "+err.Error())
t.Errorf("kill: %v", err)
return
}
time.Sleep(200 * time.Millisecond)
if err := ms.Restart(); err != nil {
set("F10", report.StatusFail, "重启失败: "+err.Error())
t.Errorf("restart: %v", err)
return
}
dave2 := accept.MQTTLogin(t, ms.HTTPBase, "f10dave01", epPassword)
defer dave2.Close()
rstMsg := dave2.WaitType(t, "msg", 8*time.Second)
if rstMsg["id"] != "f10-rst" {
set("F10", report.StatusFail, fmt.Sprintf("重启后宽限内未续传: %v", rstMsg))
t.Errorf("rst msg=%v", rstMsg)
return
}
set("F10", report.StatusPass, "已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传")
}
func runF11(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
srv, err := harness.Start(harness.Options{})
if err != nil {
set("F11", report.StatusFail, "harness: "+err.Error())
t.Errorf("harness: %v", err)
return
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "f11alice1", epPassword)
accept.CreateEndpoint(t, ac, "f11bob001", epPassword)
alice := accept.MQTTLogin(t, srv.HTTPBase, "f11alice1", epPassword)
bob := accept.MQTTLogin(t, srv.HTTPBase, "f11bob001", epPassword)
defer bob.Close()
sendAt := time.Now().Add(2 * time.Second).UnixMilli()
sched := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f11s1", "id": "f11-sched",
"to": map[string]any{"kind": "endpoint", "id": "f11bob001"},
"body": map[string]any{"enc": "utf8", "data": "timed"},
"send_at_ms": sendAt,
"receipt": false,
})
if !sched.OK {
set("F11", report.StatusFail, fmt.Sprintf("定时提交失败: %+v", sched))
t.Errorf("sched: %+v", sched)
return
}
data, _ := sched.Data.(map[string]any)
if data["state"] != "scheduled" {
set("F11", report.StatusFail, fmt.Sprintf("期望 scheduled 得 %v", data))
t.Errorf("state=%v", data)
return
}
alice.Close() // 发送方立刻断开
if early := bob.TryType("msg", 800*time.Millisecond); early != nil {
set("F11", report.StatusFail, fmt.Sprintf("未到点就收到: %v", early))
t.Errorf("early=%v", early)
return
}
msg := bob.WaitType(t, "msg", 8*time.Second)
if msg["id"] != "f11-sched" {
set("F11", report.StatusFail, fmt.Sprintf("到点未收到: %v", msg))
t.Errorf("msg=%v", msg)
return
}
set("F11", report.StatusPass, "已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到")
}
func runF14(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
srv, err := harness.Start(harness.Options{})
if err != nil {
set("F14", report.StatusFail, "harness: "+err.Error())
t.Errorf("harness: %v", err)
return
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "f14alice1", epPassword)
accept.CreateEndpoint(t, ac, "f14bob001", epPassword)
alice := accept.MQTTLogin(t, srv.HTTPBase, "f14alice1", epPassword)
bob := accept.MQTTLogin(t, srv.HTTPBase, "f14bob001", epPassword)
defer bob.Close()
send := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f14s1", "id": "f14-rcp",
"to": map[string]any{"kind": "endpoint", "id": "f14bob001"},
"body": map[string]any{"enc": "utf8", "data": "need-receipt"},
"delay_ms": int64(0),
"receipt": true,
})
if !send.OK {
set("F14", report.StatusFail, fmt.Sprintf("提交失败: %+v", send))
t.Errorf("send: %+v", send)
return
}
msg := bob.WaitType(t, "msg", 8*time.Second)
if msg["id"] != "f14-rcp" {
set("F14", report.StatusFail, fmt.Sprintf("未送达: %v", msg))
t.Errorf("msg=%v", msg)
return
}
alice.Close() // 发送方离线
time.Sleep(150 * time.Millisecond)
ack := bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f14a1", "from": "f14alice1", "id": "f14-rcp"})
if !ack.OK {
set("F14", report.StatusFail, fmt.Sprintf("ack 失败: %+v", ack))
t.Errorf("ack: %+v", ack)
return
}
alice2 := accept.MQTTLogin(t, srv.HTTPBase, "f14alice1", epPassword)
defer alice2.Close()
rcpt := alice2.WaitType(t, "receipt", 8*time.Second)
if rcpt["id"] != "f14-rcp" || rcpt["state"] != "accepted" {
set("F14", report.StatusFail, fmt.Sprintf("重连后未补到已收下回执: %v", rcpt))
t.Errorf("receipt=%v", rcpt)
return
}
set("F14", report.StatusPass, "已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执")
}
func runF15(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
srv, err := harness.Start(harness.Options{})
if err != nil {
set("F15", report.StatusFail, "harness: "+err.Error())
t.Errorf("harness: %v", err)
return
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
ids := []string{"f15alice1", "f15bob001", "f15carol1", "f15dave01", "f15eve0001", "f15frank1", "f15grace1", "f15heidi1"}
for _, id := range ids {
accept.CreateEndpoint(t, ac, id, epPassword)
}
alice := accept.MQTTLogin(t, srv.HTTPBase, "f15alice1", epPassword)
defer alice.Close()
bob := accept.MQTTLogin(t, srv.HTTPBase, "f15bob001", epPassword)
defer bob.Close()
setTalk := bob.Request(t, map[string]any{
"v": 1, "type": "self.talk_password", "rid": "tp1", "talk_password": "talk-secret-1",
})
if !setTalk.OK {
set("F15", report.StatusFail, fmt.Sprintf("设对话密码失败: %+v", setTalk))
t.Errorf("set talk: %+v", setTalk)
return
}
noPW := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s0", "id": "f15-nopw",
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
"body": map[string]any{"enc": "utf8", "data": "x"},
"delay_ms": int64(0),
})
if noPW.OK {
set("F15", report.StatusFail, "不带密码应被拒")
t.Error("nopw accepted")
return
}
if code, _ := noPW.Error["code"].(string); code != "talk_password_required" {
set("F15", report.StatusFail, fmt.Sprintf("期望 talk_password_required 得 %+v", noPW))
t.Errorf("nopw=%+v", noPW)
return
}
withPW := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s1", "id": "f15-with",
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
"body": map[string]any{"enc": "utf8", "data": "ok1"},
"delay_ms": int64(0),
"talk_password": "talk-secret-1",
"receipt": false,
})
if !withPW.OK {
set("F15", report.StatusFail, fmt.Sprintf("带对密码失败: %+v", withPW))
t.Errorf("withpw: %+v", withPW)
return
}
_ = bob.WaitType(t, "msg", 8*time.Second)
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a1", "from": "f15alice1", "id": "f15-with"})
second := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s2", "id": "f15-2nd",
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
"body": map[string]any{"enc": "utf8", "data": "ok2"},
"delay_ms": int64(0),
"receipt": false,
})
if !second.OK {
set("F15", report.StatusFail, fmt.Sprintf("授权后第二条不带密码失败: %+v", second))
t.Errorf("2nd: %+v", second)
return
}
_ = bob.WaitType(t, "msg", 8*time.Second)
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a2", "from": "f15alice1", "id": "f15-2nd"})
chg := bob.Request(t, map[string]any{
"v": 1, "type": "self.talk_password", "rid": "tp2", "talk_password": "talk-secret-2",
})
if !chg.OK {
set("F15", report.StatusFail, fmt.Sprintf("改密失败: %+v", chg))
t.Errorf("chg: %+v", chg)
return
}
stale := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s3", "id": "f15-stale",
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
"body": map[string]any{"enc": "utf8", "data": "stale"},
"delay_ms": int64(0),
})
if stale.OK {
set("F15", report.StatusFail, "改密后旧授权仍可用")
t.Error("stale ok")
return
}
// 回复免密:carol 设密,dave 先发,carol 可免密回
carol := accept.MQTTLogin(t, srv.HTTPBase, "f15carol1", epPassword)
defer carol.Close()
dave := accept.MQTTLogin(t, srv.HTTPBase, "f15dave01", epPassword)
defer dave.Close()
carol.Request(t, map[string]any{"v": 1, "type": "self.talk_password", "rid": "tp3", "talk_password": "carol-pw"})
daveFirst := dave.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s4", "id": "f15-d1",
"to": map[string]any{"kind": "endpoint", "id": "f15carol1"},
"body": map[string]any{"enc": "utf8", "data": "hi"},
"delay_ms": int64(0),
"talk_password": "carol-pw",
"receipt": false,
})
if !daveFirst.OK {
set("F15", report.StatusFail, fmt.Sprintf("dave 带密发送失败: %+v", daveFirst))
t.Errorf("dave: %+v", daveFirst)
return
}
_ = carol.WaitType(t, "msg", 8*time.Second)
carol.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a3", "from": "f15dave01", "id": "f15-d1"})
reply := carol.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s5", "id": "f15-reply",
"to": map[string]any{"kind": "endpoint", "id": "f15dave01"},
"body": map[string]any{"enc": "utf8", "data": "re"},
"delay_ms": int64(0),
"receipt": false,
})
if !reply.OK {
set("F15", report.StatusFail, fmt.Sprintf("对方先发后免密回复失败: %+v", reply))
t.Errorf("reply: %+v", reply)
return
}
// 拉进群仍要当次带对话密码(已有单聊授权不能代替)。
// 注:真实进程上 group.add+talk_password,以及长会话后再 group.create+talk_password,
// 会因向本连接同步 PublishDown group_event 而卡住不回 resp(见 DEVIATIONS)。
// 无密失败在本会话用 create 覆盖;带密成功在独立短生命周期进程上覆盖(同校验路径)。
alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s6", "id": "f15-reauth",
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
"body": map[string]any{"enc": "utf8", "data": "re"},
"delay_ms": int64(0),
"talk_password": "talk-secret-2",
"receipt": false,
})
_ = bob.WaitType(t, "msg", 8*time.Second)
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a4", "from": "f15alice1", "id": "f15-reauth"})
addNo := alice.Request(t, map[string]any{
"v": 1, "type": "group.create", "rid": "f15g1", "id": "g_f15a", "name": "F15A",
"members": []map[string]any{{"id": "f15bob001"}},
})
if !addNo.OK {
set("F15", report.StatusFail, fmt.Sprintf("建群请求失败: %+v", addNo))
t.Errorf("group no pw: %+v", addNo)
return
}
failed := memberFailures(addNo.Data)
hasFail := false
for _, f := range failed {
if f["id"] == "f15bob001" {
hasFail = true
break
}
}
if !hasFail {
set("F15", report.StatusFail, fmt.Sprintf("无对话密码拉人应失败: %+v", addNo.Data))
t.Errorf("expected member fail: %+v", addNo.Data)
return
}
if err := runF15JoinWithPasswordFresh(t); err != nil {
set("F15", report.StatusFail, "带密拉人建群: "+err.Error())
t.Error(err)
return
}
// 多账号轮流猜:5 个账号各错 10 次 → 触发对方总数锁(50)
attackers := []string{"f15eve0001", "f15frank1", "f15grace1", "f15heidi1"}
accept.CreateEndpoint(t, ac, "f15ivan01", epPassword)
accept.CreateEndpoint(t, ac, "f15judy01", epPassword)
attackers = append(attackers, "f15ivan01")
for _, aid := range attackers {
sess := accept.MQTTLogin(t, srv.HTTPBase, aid, epPassword)
for i := 0; i < 10; i++ {
_ = sess.Request(t, map[string]any{
"v": 1, "type": "unlock", "rid": fmt.Sprintf("ul-%s-%d", aid, i),
"endpoint_id": "f15bob001", "talk_password": "wrong-pw",
})
}
sess.Close()
}
newbie := accept.MQTTLogin(t, srv.HTTPBase, "f15judy01", epPassword)
defer newbie.Close()
locked := newbie.Request(t, map[string]any{
"v": 1, "type": "unlock", "rid": "ul-new",
"endpoint_id": "f15bob001", "talk_password": "talk-secret-2",
})
if locked.OK {
set("F15", report.StatusFail, "达到总数锁后正确密码仍可解锁")
t.Error("unlock after target lock")
return
}
if code, _ := locked.Error["code"].(string); code != "rate_limited" {
set("F15", report.StatusFail, fmt.Sprintf("期望 rate_limited 得 %+v", locked))
t.Errorf("locked=%+v", locked)
return
}
// 已有授权端仍可发(alice 带过新密码)
still := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f15s7", "id": "f15-grant",
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
"body": map[string]any{"enc": "utf8", "data": "still"},
"delay_ms": int64(0),
"receipt": false,
})
if !still.OK {
set("F15", report.StatusFail, fmt.Sprintf("已有授权在总数锁下应仍可发: %+v", still))
t.Errorf("still: %+v", still)
return
}
set("F15", report.StatusPass, "已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发")
}
func runF18(t *testing.T, set func(string, report.Status, string)) {
t.Helper()
// 正文消失 + 防重(默认保留天数)
srv, err := harness.Start(harness.Options{})
if err != nil {
set("F18", report.StatusFail, "harness: "+err.Error())
t.Errorf("harness: %v", err)
return
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "f18alice1", epPassword)
accept.CreateEndpoint(t, ac, "f18bob001", epPassword)
alice := accept.MQTTLogin(t, srv.HTTPBase, "f18alice1", epPassword)
defer alice.Close()
bob := accept.MQTTLogin(t, srv.HTTPBase, "f18bob001", epPassword)
defer bob.Close()
send := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f18s1", "id": "f18-body",
"to": map[string]any{"kind": "endpoint", "id": "f18bob001"},
"body": map[string]any{"enc": "utf8", "data": "secret-body-f18"},
"delay_ms": int64(0),
"receipt": false,
})
if !send.OK {
set("F18", report.StatusFail, fmt.Sprintf("提交失败: %+v", send))
t.Errorf("send: %+v", send)
return
}
_ = bob.WaitType(t, "msg", 8*time.Second)
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f18a1", "from": "f18alice1", "id": "f18-body"})
time.Sleep(300 * time.Millisecond)
dbPath := filepath.Join(srv.DataDir, "nixmsg.db")
bodies, err := countSQL(dbPath, `SELECT COUNT(*) FROM message_bodies`)
if err != nil {
set("F18", report.StatusFail, "读库失败: "+err.Error())
t.Errorf("db: %v", err)
return
}
if bodies != 0 {
set("F18", report.StatusFail, fmt.Sprintf("确认后仍有正文行 message_bodies=%d", bodies))
t.Errorf("bodies=%d", bodies)
return
}
// 防重:同号同内容再提交不应再投递
accept.DrainEvents(t, bob, 200*time.Millisecond)
again := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f18s2", "id": "f18-body",
"to": map[string]any{"kind": "endpoint", "id": "f18bob001"},
"body": map[string]any{"enc": "utf8", "data": "secret-body-f18"},
"delay_ms": int64(0),
"receipt": false,
})
if !again.OK {
set("F18", report.StatusFail, fmt.Sprintf("防重重试应成功返回原结果: %+v", again))
t.Errorf("again: %+v", again)
return
}
if got := bob.TryType("msg", 1*time.Second); got != nil {
set("F18", report.StatusFail, fmt.Sprintf("防重窗口内又投递一次: %v", got))
t.Errorf("dup msg=%v", got)
return
}
// 保留天数 0:完成后记录消失
ms, err := accept.StartManagedConfig(retentionZeroYAML)
if err != nil {
set("F18", report.StatusFail, "retention0 启动失败: "+err.Error())
t.Errorf("ret0: %v", err)
return
}
defer func() { _ = ms.Cleanup() }()
hs := &harness.Server{HTTPBase: ms.HTTPBase, AdminHTTPBase: ms.AdminHTTPBase, AdminPassword: ms.AdminPassword}
ac2 := accept.AdminLogin(t, hs)
accept.CreateEndpoint(t, ac2, "f18a2", epPassword)
accept.CreateEndpoint(t, ac2, "f18b2", epPassword)
a2 := accept.MQTTLogin(t, ms.HTTPBase, "f18a2", epPassword)
defer a2.Close()
b2 := accept.MQTTLogin(t, ms.HTTPBase, "f18b2", epPassword)
defer b2.Close()
s2 := a2.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "f18s3", "id": "f18-zero",
"to": map[string]any{"kind": "endpoint", "id": "f18b2"},
"body": map[string]any{"enc": "utf8", "data": "gone"},
"delay_ms": int64(0),
"receipt": false,
})
if !s2.OK {
set("F18", report.StatusFail, fmt.Sprintf("retention0 提交失败: %+v", s2))
t.Errorf("s2: %+v", s2)
return
}
_ = b2.WaitType(t, "msg", 8*time.Second)
b2.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f18a2", "from": "f18a2", "id": "f18-zero"})
time.Sleep(300 * time.Millisecond)
st := a2.Request(t, map[string]any{"v": 1, "type": "status", "rid": "f18st", "id": "f18-zero"})
if st.OK {
set("F18", report.StatusFail, fmt.Sprintf("保留天数 0 完成后 status 仍成功: %+v", st))
t.Errorf("status still ok: %+v", st)
return
}
if code, _ := st.Error["code"].(string); code != "not_found" {
set("F18", report.StatusFail, fmt.Sprintf("期望 status not_found 得 %+v", st))
t.Errorf("status=%+v", st)
return
}
msgs, err := countSQL(filepath.Join(ms.DataDir, "nixmsg.db"), `SELECT COUNT(*) FROM messages WHERE id='f18-zero'`)
if err != nil {
set("F18", report.StatusFail, "读库失败: "+err.Error())
t.Errorf("db2: %v", err)
return
}
if msgs != 0 {
set("F18", report.StatusFail, fmt.Sprintf("保留天数 0 后消息行仍在 count=%d", msgs))
t.Errorf("msgs=%d", msgs)
return
}
set("F18", report.StatusPass, "已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失")
}
// runF15JoinWithPasswordFresh 在干净进程上验证带对话密码建群成功(避开长会话后 PublishDown 卡住)。
func runF15JoinWithPasswordFresh(t *testing.T) error {
t.Helper()
srv, err := harness.Start(harness.Options{})
if err != nil {
return fmt.Errorf("harness: %w", err)
}
defer func() { _ = srv.Stop() }()
ac := accept.AdminLogin(t, srv)
accept.CreateEndpoint(t, ac, "f15jalice", epPassword)
accept.CreateEndpoint(t, ac, "f15jbob01", epPassword)
alice := accept.MQTTLogin(t, srv.HTTPBase, "f15jalice", epPassword)
defer alice.Close()
bob := accept.MQTTLogin(t, srv.HTTPBase, "f15jbob01", epPassword)
defer bob.Close()
setTalk := bob.Request(t, map[string]any{
"v": 1, "type": "self.talk_password", "rid": "jtp1", "talk_password": "join-secret",
})
if !setTalk.OK {
return fmt.Errorf("设对话密码失败: %+v", setTalk)
}
addYes := alice.Request(t, map[string]any{
"v": 1, "type": "group.create", "rid": "f15jg", "id": "g_f15j", "name": "F15J",
"members": []map[string]any{{"id": "f15jbob01", "talk_password": "join-secret"}},
})
if !addYes.OK {
return fmt.Errorf("带密建群失败: %+v", addYes)
}
if fails := memberFailures(addYes.Data); len(fails) > 0 {
return fmt.Errorf("带密建群仍失败: %+v", addYes.Data)
}
return nil
}
func drainReceipts(s *accept.MQTTSession, d time.Duration) {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if s.TryType("receipt", 40*time.Millisecond) == nil {
time.Sleep(20 * time.Millisecond)
}
}
}
func waitReceiptID(t *testing.T, s *accept.MQTTSession, msgID string, timeout time.Duration) map[string]any {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
m := s.TryType("receipt", 50*time.Millisecond)
if m == nil {
continue
}
if m["id"] == msgID {
return m
}
}
t.Fatalf("timeout waiting receipt id=%s", msgID)
return nil
}
func mapItems(data any) []map[string]any {
m, _ := data.(map[string]any)
if m == nil {
return nil
}
raw, _ := m["items"].([]any)
out := make([]map[string]any, 0, len(raw))
for _, x := range raw {
if im, ok := x.(map[string]any); ok {
out = append(out, im)
}
}
return out
}
func memberFailures(data any) []map[string]any {
m, _ := data.(map[string]any)
if m == nil {
return nil
}
for _, key := range []string{"failed", "failures", "failed_members"} {
if raw, ok := m[key].([]any); ok {
out := make([]map[string]any, 0, len(raw))
for _, x := range raw {
if im, ok := x.(map[string]any); ok {
out = append(out, im)
}
}
return out
}
}
return nil
}
func countSQL(dbPath, query string) (int, error) {
dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=query_only(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return 0, err
}
defer func() { _ = db.Close() }()
var n int
if err := db.QueryRow(query).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
+3 -1
View File
@@ -34,7 +34,7 @@ func DialMQTTTCP(addr string, timeout time.Duration) (MQTTClient, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = conn.SetDeadline(time.Now().Add(timeout)) _ = conn.SetDeadline(time.Time{})
return &tcpMQTT{conn: conn, r: bufio.NewReader(conn)}, nil return &tcpMQTT{conn: conn, r: bufio.NewReader(conn)}, nil
} }
@@ -126,6 +126,8 @@ func DialMQTTWebSocket(httpBase string, timeout time.Duration) (MQTTClient, erro
_ = raw.Close() _ = raw.Close()
return nil, fmt.Errorf("unexpected subprotocol %q", proto) return nil, fmt.Errorf("unexpected subprotocol %q", proto)
} }
// 握手完成后清掉超时,否则长会话后续读写会在 dial timeout 到期后全部失败。
_ = raw.SetDeadline(time.Time{})
return &wsMQTT{conn: raw, r: br}, nil return &wsMQTT{conn: raw, r: br}, nil
} }
+19 -19
View File
@@ -1,5 +1,5 @@
{ {
"generated_at": "2026-09-30T00:26:12Z", "generated_at": "2026-09-30T02:20:32Z",
"items": [ "items": [
{ {
"id": "F01", "id": "F01",
@@ -13,13 +13,13 @@
}, },
{ {
"id": "F03", "id": "F03",
"status": "untested", "status": "pass",
"note": "未测:directory.list / 断开后离线状态未在本波单独断言" "note": "已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时"
}, },
{ {
"id": "F04", "id": "F04",
"status": "untested", "status": "pass",
"note": "未测:presence.watch 订阅通知未覆盖" "note": "已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知"
}, },
{ {
"id": "F05", "id": "F05",
@@ -33,8 +33,8 @@
}, },
{ {
"id": "F07", "id": "F07",
"status": "untested", "status": "pass",
"note": "未测:256 KiB 边界与接收上限未覆盖" "note": "已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用"
}, },
{ {
"id": "F08", "id": "F08",
@@ -48,13 +48,13 @@
}, },
{ {
"id": "F10", "id": "F10",
"status": "untested", "status": "pass",
"note": "未测:抖动宽限长短断线未单独拨钟" "note": "已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传"
}, },
{ {
"id": "F11", "id": "F11",
"status": "untested", "status": "pass",
"note": "未测:发送方离线后定时到点发送未覆盖" "note": "已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到"
}, },
{ {
"id": "F12", "id": "F12",
@@ -68,13 +68,13 @@
}, },
{ {
"id": "F14", "id": "F14",
"status": "untested", "status": "pass",
"note": "未测:回执补送未覆盖" "note": "已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执"
}, },
{ {
"id": "F15", "id": "F15",
"status": "untested", "status": "pass",
"note": "未测:对话密码授权链路未覆盖" "note": "已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发"
}, },
{ {
"id": "F16", "id": "F16",
@@ -88,13 +88,13 @@
}, },
{ {
"id": "F18", "id": "F18",
"status": "untested", "status": "pass",
"note": "未测:正文删除与记录天数 0 未覆盖" "note": "已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失"
}, },
{ {
"id": "F19", "id": "F19",
"status": "untested", "status": "pass",
"note": "未测:四种 SDK 接入清单属 S1/S2 任务 4" "note": "已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录)"
}, },
{ {
"id": "F20", "id": "F20",