安全加固:MQTT broker 新增可选连接认证(bcrypt 用户+匿名开关+按 IP 失败限速,配置明文密码首启自动转哈希,默认关闭零影响),py 迁移脚本数据库口令改环境变量(新增 db_config.example.py 模板),新增 doc/SECURITY_FIX_TODO.md 安全修复清单,后端 v1.3.0

This commit is contained in:
2026-08-20 16:51:33 +08:00
parent fbd105bd6c
commit fe0cf036cf
11 changed files with 925 additions and 18 deletions
+144 -6
View File
@@ -6,7 +6,10 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
@@ -28,9 +31,27 @@ type Config struct {
}
type MQTTConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS TLSConfig `yaml:"tls"`
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS TLSConfig `yaml:"tls"`
Auth MQTTAuthConfig `yaml:"auth"`
}
// MQTTAuthConfig 控制 MQTT broker 的 CONNECT 认证。
// Enabled=false 时行为与历史版本一致(全部放行)。
type MQTTAuthConfig struct {
Enabled bool `yaml:"enabled"`
AllowAnonymous bool `yaml:"allow_anonymous"`
Users []MQTTAuthUser `yaml:"users"`
}
// MQTTAuthUser 是一个可连接 broker 的账号。
// Password 仅支持写在配置里由首次载入时自动转为 PasswordHash,
// 序列化写回时剔除明文;也可直接提供 password_hash(bcrypt)。
type MQTTAuthUser struct {
Username string `yaml:"username"`
Password string `yaml:"-"`
PasswordHash string `yaml:"password_hash,omitempty"`
}
type TLSConfig struct {
@@ -113,9 +134,22 @@ type rawAIConfig struct {
}
type rawMQTTConfig struct {
Host *string `yaml:"host"`
Port *int `yaml:"port"`
TLS *rawTLSConfig `yaml:"tls"`
Host *string `yaml:"host"`
Port *int `yaml:"port"`
TLS *rawTLSConfig `yaml:"tls"`
Auth *rawMQTTAuthConfig `yaml:"auth"`
}
type rawMQTTAuthConfig struct {
Enabled *bool `yaml:"enabled"`
AllowAnonymous *bool `yaml:"allow_anonymous"`
Users *[]rawMQTTAuthUser `yaml:"users"`
}
type rawMQTTAuthUser struct {
Username *string `yaml:"username"`
Password *string `yaml:"password"`
PasswordHash *string `yaml:"password_hash"`
}
type rawTLSConfig struct {
@@ -172,6 +206,11 @@ func Default() *Config {
CertFile: "",
KeyFile: "",
},
Auth: MQTTAuthConfig{
Enabled: false,
AllowAnonymous: false,
Users: []MQTTAuthUser{},
},
},
Meshtastic: MeshtasticConfig{
PSK: "AQ==",
@@ -377,6 +416,45 @@ func normalize(raw rawConfig) (*Config, bool) {
cfg.MQTT.TLS.KeyFile = *raw.MQTT.TLS.KeyFile
}
}
if raw.MQTT.Auth == nil {
changed = true
} else {
if raw.MQTT.Auth.Enabled == nil {
changed = true
} else {
cfg.MQTT.Auth.Enabled = *raw.MQTT.Auth.Enabled
}
if raw.MQTT.Auth.AllowAnonymous == nil {
changed = true
} else {
cfg.MQTT.Auth.AllowAnonymous = *raw.MQTT.Auth.AllowAnonymous
}
if raw.MQTT.Auth.Users == nil {
changed = true
} else {
users := make([]MQTTAuthUser, 0, len(*raw.MQTT.Auth.Users))
for _, ru := range *raw.MQTT.Auth.Users {
u := MQTTAuthUser{}
if ru.Username == nil {
changed = true
} else {
u.Username = *ru.Username
}
if ru.Password == nil {
changed = true
} else {
u.Password = *ru.Password
}
if ru.PasswordHash == nil {
changed = true
} else {
u.PasswordHash = *ru.PasswordHash
}
users = append(users, u)
}
cfg.MQTT.Auth.Users = users
}
}
}
if raw.Meshtastic == nil {
@@ -525,6 +603,18 @@ func normalize(raw rawConfig) (*Config, bool) {
}
}
// 明文 password 自动转为 bcrypt 哈希,并标记 changed 以便写回时剔除明文。
for i := range cfg.MQTT.Auth.Users {
if cfg.MQTT.Auth.Users[i].Password != "" {
if hashed, err := bcrypt.GenerateFromPassword([]byte(cfg.MQTT.Auth.Users[i].Password), bcrypt.DefaultCost); err == nil {
cfg.MQTT.Auth.Users[i].PasswordHash = string(hashed)
cfg.MQTT.Auth.Users[i].Password = ""
changed = true
}
// 散列失败(如密码超过 72 字节)时保留明文,交给 Validate 报错。
}
}
return cfg, changed
}
@@ -532,6 +622,9 @@ func Validate(cfg *Config) error {
if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 {
return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port)
}
if err := validateMQTTAuth(cfg.MQTT.Auth); err != nil {
return err
}
switch cfg.Database.Driver {
case DriverSQLite:
if cfg.Database.SQLite.Path == "" {
@@ -570,6 +663,51 @@ func Validate(cfg *Config) error {
return nil
}
func validateMQTTAuth(auth MQTTAuthConfig) error {
seen := make(map[string]bool, len(auth.Users))
for _, u := range auth.Users {
if u.Username == "" {
return fmt.Errorf("mqtt.auth.users[].username is required")
}
if seen[u.Username] {
return fmt.Errorf("mqtt.auth.users: duplicate username %q", u.Username)
}
seen[u.Username] = true
if u.Password != "" {
return fmt.Errorf("mqtt.auth.users[%s]: password 无法转为哈希(长度须 <= 72 字节),或直接改用 password_hash", u.Username)
}
if u.PasswordHash != "" && !isBcryptHash(u.PasswordHash) {
return fmt.Errorf("mqtt.auth.users[%s]: password_hash 不是合法的 bcrypt 散列($2a$/$2b$/$2y$ 开头),可用 htpasswd -bnBC 10 \"\" '密码' 生成", u.Username)
}
}
if auth.Enabled {
if !auth.AllowAnonymous && len(auth.Users) == 0 {
return fmt.Errorf("mqtt.auth.enabled 为 true 时必须配置至少一个用户,或设置 allow_anonymous: true")
}
for _, u := range auth.Users {
if u.PasswordHash == "" {
return fmt.Errorf("mqtt.auth.users[%s]: 启用认证时必须提供 password 或 password_hash", u.Username)
}
}
}
return nil
}
// isBcryptHash 校验 $2a$/$2b$/$2y$<cost>$<53位散列> 的 bcrypt 格式。
func isBcryptHash(s string) bool {
parts := strings.Split(s, "$")
if len(parts) != 4 || parts[0] != "" {
return false
}
if parts[1] != "2a" && parts[1] != "2b" && parts[1] != "2y" {
return false
}
if _, err := strconv.Atoi(parts[2]); err != nil {
return false
}
return len(parts[3]) == 53
}
func Write(path string, cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func loadRaw(t *testing.T, data string) rawConfig {
t.Helper()
var raw rawConfig
if err := yaml.Unmarshal([]byte(data), &raw); err != nil {
t.Fatalf("yaml: %v", err)
}
return raw
}
func TestNormalizePlaintextPasswordBecomesHash(t *testing.T) {
raw := loadRaw(t, `
mqtt:
auth:
enabled: true
users:
- username: mesh
password: secret
`)
cfg, changed := normalize(raw)
if !changed {
t.Fatal("plaintext password must mark config changed")
}
if err := Validate(cfg); err != nil {
t.Fatalf("validate: %v", err)
}
u := cfg.MQTT.Auth.Users[0]
if u.Password != "" {
t.Error("plaintext must be cleared after hashing")
}
if !strings.HasPrefix(u.PasswordHash, "$2") {
t.Errorf("expected bcrypt hash, got %q", u.PasswordHash)
}
out, err := yaml.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(out), "password: secret") {
t.Error("serialized config must not contain the plaintext password")
}
if !strings.Contains(string(out), "password_hash") {
t.Error("serialized config must contain password_hash")
}
}
func TestValidateAuthErrors(t *testing.T) {
cases := []struct {
name string
yaml string
want string
}{
{"启用但无用户", "mqtt:\n auth:\n enabled: true\n", "至少一个用户"},
{"坏哈希", "mqtt:\n auth:\n enabled: true\n users:\n - username: a\n password_hash: not-bcrypt\n", "bcrypt"},
{"缺哈希", "mqtt:\n auth:\n enabled: true\n users:\n - username: a\n", "password_hash"},
{"重复用户", "mqtt:\n auth:\n enabled: true\n users:\n - username: a\n password_hash: " + fakeHash + "\n - username: a\n password_hash: " + fakeHash + "\n", "duplicate"},
}
for _, tc := range cases {
cfg, _ := normalize(loadRaw(t, tc.yaml))
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: got %v, want error containing %q", tc.name, err, tc.want)
}
}
}
func TestValidateAuthDisabledOk(t *testing.T) {
cfg, _ := normalize(loadRaw(t, "mqtt:\n"))
if err := Validate(cfg); err != nil {
t.Fatalf("disabled auth must pass: %v", err)
}
if cfg.MQTT.Auth.Enabled {
t.Error("auth must default to disabled")
}
}
const fakeHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
+69
View File
@@ -0,0 +1,69 @@
package mqttauth
import (
"testing"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
mqtt "github.com/mochi-mqtt/server/v2"
"github.com/mochi-mqtt/server/v2/listeners"
)
// TestBrokerIntegration 用真实 TCP broker + paho 客户端验证认证全链路。
func TestBrokerIntegration(t *testing.T) {
hook := NewHook(Config{
Enabled: true,
Users: []User{{Username: "mesh", PasswordHash: hash(t, "secret")}},
MaxFailures: 3,
})
t.Cleanup(func() { _ = hook.Stop() })
server := mqtt.New(&mqtt.Options{InlineClient: true})
if err := server.AddHook(hook, nil); err != nil {
t.Fatalf("add hook: %v", err)
}
addr := "127.0.0.1:18883"
if err := server.AddListener(listeners.NewTCP(listeners.Config{ID: "t", Address: addr})); err != nil {
t.Fatalf("listen: %v", err)
}
if err := server.Serve(); err != nil {
t.Fatalf("serve: %v", err)
}
t.Cleanup(func() { _ = server.Close() })
time.Sleep(200 * time.Millisecond)
connect := func(user, pass string) bool {
opts := paho.NewClientOptions().
AddBroker("tcp://" + addr).
SetClientID("smoke-" + user + time.Now().Format("150405.000")).
SetUsername(user).SetPassword(pass).
SetConnectTimeout(3 * time.Second)
client := paho.NewClient(opts)
token := client.Connect()
token.WaitTimeout(5 * time.Second)
ok := token.Error() == nil
if ok {
client.Disconnect(50)
}
return ok
}
if !connect("mesh", "secret") {
t.Error("valid credentials should connect")
}
if connect("mesh", "wrong") {
t.Error("wrong password should be rejected")
}
if connect("", "") {
t.Error("anonymous should be rejected when disabled")
}
// 连续失败触发封禁后,正确凭据也应被拒绝(默认阈值 3,本例 MaxFailures=3)。
// 前面已失败 2 次(mesh/wrong 与匿名),再失败 1 次触发封禁。
if connect("mesh", "wrong") {
t.Fatal("wrong password should be rejected")
}
if connect("mesh", "secret") {
t.Error("blocked ip must reject even valid credentials")
}
}
+265
View File
@@ -0,0 +1,265 @@
// Package mqttauth 为 mochi-mqtt broker 提供基于配置用户的 CONNECT 认证。
//
// 设计要点:
// - Enabled=false 时全部放行,行为与原 mqttauth.AllowHook 完全一致(平滑升级);
// - Enabled=true 时按用户名 + bcrypt 哈希校验,可选允许匿名连接;
// - 未知用户名也执行一次 dummy bcrypt 比较,消除用户名枚举时间侧信道;
// - 同一来源 IP 认证失败达到阈值后临时封禁,防止在线爆破;
// - OnACLCheck 恒返回 true:mochi 在无任何 ACL provider 时默认拒绝,
// 本 hook 不做主题级权限,保持原有全 topic 可读写行为。
package mqttauth
import (
"bytes"
"fmt"
"net"
"sync"
"time"
mqtt "github.com/mochi-mqtt/server/v2"
"github.com/mochi-mqtt/server/v2/packets"
"golang.org/x/crypto/bcrypt"
)
// User 是一个可连接 broker 的账号,PasswordHash 为 bcrypt 散列。
type User struct {
Username string
PasswordHash string
}
// Config 控制 hook 行为。
type Config struct {
Enabled bool
AllowAnonymous bool
Users []User
// MaxFailures 为单个 IP 在 Window 时间窗内允许的连续认证失败次数,
// 达到后封锁该 IP BlockFor 时长。零值使用默认。
MaxFailures int
Window time.Duration
BlockFor time.Duration
// LogEvent 用于输出结构化事件(传入 main 包的 printJSON),可为 nil。
LogEvent func(record map[string]any)
}
const (
defaultMaxFailures = 5
defaultWindow = time.Minute
defaultBlockFor = 5 * time.Minute
// dummyBcryptHash 是固定散列,用于未知用户名的耗时对齐。
dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
)
// Hook 实现 mochi 认证钩子。
type Hook struct {
mqtt.HookBase
cfg Config
users map[string]string
mu sync.Mutex
fails map[string]*failState
now func() time.Time
stopped chan struct{}
stopOnce sync.Once
}
type failState struct {
count int
windowStart time.Time
blockedUntil time.Time
}
// NewHook 按配置构造 hook 并启动后台清理协程,返回的 hook 交给 server.AddHook。
// 服务退出时应调用 Stop 结束清理协程。
func NewHook(cfg Config) *Hook {
if cfg.MaxFailures <= 0 {
cfg.MaxFailures = defaultMaxFailures
}
if cfg.Window <= 0 {
cfg.Window = defaultWindow
}
if cfg.BlockFor <= 0 {
cfg.BlockFor = defaultBlockFor
}
users := make(map[string]string, len(cfg.Users))
for _, u := range cfg.Users {
users[u.Username] = u.PasswordHash
}
h := &Hook{
cfg: cfg,
users: users,
fails: make(map[string]*failState),
now: time.Now,
stopped: make(chan struct{}),
}
go h.cleanupLoop()
return h
}
// Stop 结束后台清理协程;实现 mochi Hook 接口的 Stop。
func (h *Hook) Stop() error {
h.stopOnce.Do(func() { close(h.stopped) })
return nil
}
func (h *Hook) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-h.stopped:
return
case <-ticker.C:
h.purgeExpired()
}
}
}
func (h *Hook) purgeExpired() {
now := h.now()
h.mu.Lock()
defer h.mu.Unlock()
for ip, st := range h.fails {
if now.After(st.blockedUntil) && now.Sub(st.windowStart) > h.cfg.Window {
delete(h.fails, ip)
}
}
}
// ID 返回 hook 标识。
func (h *Hook) ID() string { return "mqttauth" }
// Provides 声明处理认证与 ACL 检查。
func (h *Hook) Provides(b byte) bool {
return bytes.Contains([]byte{
mqtt.OnConnectAuthenticate,
mqtt.OnACLCheck,
}, []byte{b})
}
// OnACLCheck 恒允许:本 hook 只做连接级认证,不做主题级权限。
func (h *Hook) OnACLCheck(cl *mqtt.Client, topic string, write bool) bool {
return true
}
// OnConnectAuthenticate 校验 CONNECT 报文中的用户名/密码。
func (h *Hook) OnConnectAuthenticate(cl *mqtt.Client, pk packets.Packet) bool {
if !h.cfg.Enabled {
return true
}
ip := remoteHost(cl)
if reason, ok := h.checkBlocked(ip); !ok {
h.logEvent(map[string]any{
"event": "mqtt_auth_rejected", "reason": reason,
"client_id": cl.ID, "username": string(pk.Connect.Username), "remote_addr": cl.Net.Remote,
})
return false
}
username := string(pk.Connect.Username)
password := string(pk.Connect.Password)
allowed := h.authenticate(username, password)
if allowed {
h.resetFailures(ip)
return true
}
blockedNow := h.recordFailure(ip, username)
event := map[string]any{
"event": "mqtt_auth_rejected", "reason": "invalid credentials",
"client_id": cl.ID, "username": username, "remote_addr": cl.Net.Remote,
}
if blockedNow {
event["reason"] = "invalid credentials; ip now blocked"
event["blocked_for"] = h.cfg.BlockFor.String()
}
h.logEvent(event)
return false
}
// authenticate 给出凭据判定;抽离以便单元测试。
// 未知用户名也执行 dummy bcrypt,使两种失败路径耗时一致。
func (h *Hook) authenticate(username, password string) bool {
if !h.cfg.Enabled {
return true
}
if username == "" && password == "" {
return h.cfg.AllowAnonymous
}
if hash, found := h.users[username]; found {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
return false
}
func (h *Hook) checkBlocked(ip string) (string, bool) {
now := h.now()
h.mu.Lock()
defer h.mu.Unlock()
st, ok := h.fails[ip]
if ok && now.Before(st.blockedUntil) {
return fmt.Sprintf("ip blocked, retry after %s", time.Until(st.blockedUntil).Round(time.Second)), false
}
return "", true
}
// recordFailure 记录一次失败;达到阈值时返回 true 表示本次触发封禁。
func (h *Hook) recordFailure(ip, username string) bool {
now := h.now()
h.mu.Lock()
defer h.mu.Unlock()
st, ok := h.fails[ip]
if !ok || now.Sub(st.windowStart) > h.cfg.Window {
st = &failState{windowStart: now}
h.fails[ip] = st
if len(h.fails) > 4096 {
h.purgeLocked(now)
}
}
st.count++
if st.count >= h.cfg.MaxFailures {
st.blockedUntil = now.Add(h.cfg.BlockFor)
st.count = 0
st.windowStart = now
return true
}
return false
}
func (h *Hook) resetFailures(ip string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.fails, ip)
}
func (h *Hook) purgeLocked(now time.Time) {
for ip, st := range h.fails {
if now.After(st.blockedUntil) && now.Sub(st.windowStart) > h.cfg.Window {
delete(h.fails, ip)
}
}
}
func (h *Hook) logEvent(record map[string]any) {
if h.cfg.LogEvent != nil {
h.cfg.LogEvent(record)
}
}
// remoteHost 提取客户端 IP,供失败限流使用。
func remoteHost(cl *mqtt.Client) string {
if cl == nil {
return "unknown"
}
remote := cl.Net.Remote
if remote == "" && cl.Net.Conn != nil && cl.Net.Conn.RemoteAddr() != nil {
remote = cl.Net.Conn.RemoteAddr().String()
}
if remote == "" {
return "unknown"
}
if host, _, err := net.SplitHostPort(remote); err == nil {
return host
}
return remote
}
+147
View File
@@ -0,0 +1,147 @@
package mqttauth
import (
"testing"
"time"
mqtt "github.com/mochi-mqtt/server/v2"
"github.com/mochi-mqtt/server/v2/packets"
"golang.org/x/crypto/bcrypt"
)
func hash(t *testing.T, password string) string {
t.Helper()
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
return string(h)
}
func newTestHook(t *testing.T, cfg Config) *Hook {
t.Helper()
if cfg.MaxFailures == 0 {
cfg.MaxFailures = 3
}
if cfg.Window == 0 {
cfg.Window = time.Minute
}
if cfg.BlockFor == 0 {
cfg.BlockFor = 5 * time.Minute
}
h := NewHook(cfg)
t.Cleanup(func() { _ = h.Stop() })
return h
}
func TestAuthenticate(t *testing.T) {
h := newTestHook(t, Config{
Enabled: true,
AllowAnonymous: false,
Users: []User{{Username: "mesh", PasswordHash: hash(t, "secret")}},
})
cases := []struct {
name string
user string
pass string
allowed bool
}{
{"正确凭据", "mesh", "secret", true},
{"错误密码", "mesh", "wrong", false},
{"未知用户", "nobody", "secret", false},
{"匿名未启用", "", "", false},
{"仅用户名", "mesh", "", false},
{"仅密码", "", "secret", false},
}
for _, tc := range cases {
if got := h.authenticate(tc.user, tc.pass); got != tc.allowed {
t.Errorf("%s: authenticate(%q,%q)=%v want %v", tc.name, tc.user, tc.pass, got, tc.allowed)
}
}
}
func TestAuthenticateDisabledAllowsAll(t *testing.T) {
h := newTestHook(t, Config{Enabled: false})
for _, tc := range [][2]string{{"", ""}, {"any", "thing"}} {
if !h.authenticate(tc[0], tc[1]) {
t.Errorf("disabled hook must allow %v", tc)
}
}
}
func TestAuthenticateAnonymousAllowed(t *testing.T) {
h := newTestHook(t, Config{Enabled: true, AllowAnonymous: true})
if !h.authenticate("", "") {
t.Error("anonymous should be allowed")
}
if h.authenticate("mesh", "x") {
t.Error("unknown user must still be rejected")
}
}
func TestOnConnectAuthenticateDisabled(t *testing.T) {
h := newTestHook(t, Config{Enabled: false})
if !h.OnConnectAuthenticate(&mqtt.Client{}, packets.Packet{}) {
t.Error("disabled hook must return true")
}
}
func TestFailureLimiterBlocks(t *testing.T) {
h := newTestHook(t, Config{Enabled: true, Users: []User{{Username: "mesh", PasswordHash: hash(t, "secret")}}})
now := time.Unix(1700000000, 0)
h.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "wrong")) {
t.Fatalf("attempt %d should fail", i)
}
}
// 第 3 次失败触发封禁;之后即使凭据正确也应被拒。
now = now.Add(time.Second)
if h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "secret")) {
t.Fatal("blocked ip must be rejected even with valid credentials")
}
// 封禁到期后恢复。
now = now.Add(6 * time.Minute)
if !h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "secret")) {
t.Fatal("credentials should work after block expires")
}
}
func TestSuccessResetsFailures(t *testing.T) {
h := newTestHook(t, Config{Enabled: true, Users: []User{{Username: "mesh", PasswordHash: hash(t, "secret")}}})
now := time.Unix(1700000000, 0)
h.now = func() time.Time { return now }
for i := 0; i < 2; i++ {
h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "wrong"))
}
if !h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "secret")) {
t.Fatal("valid login should succeed")
}
// 成功后计数清零:再失败 2 次应有计数条目但未被封禁(阈值为 3)。
now = now.Add(2 * time.Second)
h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "wrong"))
h.OnConnectAuthenticate(&mqtt.Client{}, connectPacket("mesh", "wrong"))
h.mu.Lock()
st := h.fails["unknown"]
h.mu.Unlock()
if st != nil && !st.blockedUntil.IsZero() {
t.Fatal("failures should have been reset by successful login")
}
}
func TestRemoteHost(t *testing.T) {
cl := &mqtt.Client{}
if got := remoteHost(cl); got == "" {
t.Error("remoteHost should never return empty string")
}
}
func connectPacket(username, password string) packets.Packet {
pk := packets.Packet{}
pk.Connect.Username = []byte(username)
pk.Connect.Password = []byte(password)
return pk
}
+1 -1
View File
@@ -84,7 +84,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
return r
}
const BackendVersion = "1.2.1"
const BackendVersion = "1.3.0"
var CommitVersion = "dev"