新增mqtt转发功能
This commit is contained in:
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mqttForwarderRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SourceHost string `json:"source_host"`
|
||||||
|
SourcePort int `json:"source_port"`
|
||||||
|
SourceUsername string `json:"source_username"`
|
||||||
|
SourcePassword *string `json:"source_password"`
|
||||||
|
SourcePasswordClear bool `json:"source_password_clear"`
|
||||||
|
SourceClientID string `json:"source_client_id"`
|
||||||
|
SourceTLS bool `json:"source_tls"`
|
||||||
|
TargetHost string `json:"target_host"`
|
||||||
|
TargetPort int `json:"target_port"`
|
||||||
|
TargetUsername string `json:"target_username"`
|
||||||
|
TargetPassword *string `json:"target_password"`
|
||||||
|
TargetPasswordClear bool `json:"target_password_clear"`
|
||||||
|
TargetClientID string `json:"target_client_id"`
|
||||||
|
TargetTLS bool `json:"target_tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwardTopicRequest struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
SourcePrefix string `json:"source_prefix"`
|
||||||
|
TargetPrefix string `json:"target_prefix"`
|
||||||
|
QoS int `json:"qos"`
|
||||||
|
Retain bool `json:"retain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttForwardReloader) {
|
||||||
|
r.GET("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListMQTTForwarders(opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts, err, mqttForwarderDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountMQTTForwarders(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
|
||||||
|
})
|
||||||
|
r.POST("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||||
|
var req mqttForwarderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forwarder request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input := mqttForwarderInputFromRequest(req)
|
||||||
|
row, err := store.CreateMQTTForwarder(input)
|
||||||
|
writeMQTTForwardMutationResponse(c, http.StatusCreated, row, err, func() error {
|
||||||
|
return reloadMQTTForwarder(forwarder, row.ID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.PUT("/mqtt-forward/forwarders/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req mqttForwarderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forwarder request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input := mqttForwarderInputFromRequest(req)
|
||||||
|
row, err := store.UpdateMQTTForwarder(id, input)
|
||||||
|
writeMQTTForwardMutationResponse(c, http.StatusOK, row, err, func() error {
|
||||||
|
return reloadMQTTForwarder(forwarder, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.DELETE("/mqtt-forward/forwarders/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if forwarder != nil {
|
||||||
|
forwarder.StopForwarder(id)
|
||||||
|
}
|
||||||
|
writeMQTTForwardDeleteResponse(c, store.DeleteMQTTForwarder(id), nil)
|
||||||
|
})
|
||||||
|
r.POST("/mqtt-forward/forwarders/:id/restart", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := reloadMQTTForwarder(forwarder, id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.GET("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListMQTTForwardTopics(id, opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts, err, mqttForwardTopicDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountMQTTForwardTopics(id)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
|
||||||
|
})
|
||||||
|
r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req mqttForwardTopicRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forward topic request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.CreateMQTTForwardTopic(id, mqttForwardTopicInputFromRequest(req))
|
||||||
|
writeMQTTForwardTopicMutationResponse(c, http.StatusCreated, row, err, func() error {
|
||||||
|
return reloadMQTTForwarder(forwarder, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.PUT("/mqtt-forward/topics/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forward topic id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req mqttForwardTopicRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mqtt forward topic request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.UpdateMQTTForwardTopic(id, mqttForwardTopicInputFromRequest(req))
|
||||||
|
writeMQTTForwardTopicMutationResponse(c, http.StatusOK, row, err, func() error {
|
||||||
|
return reloadMQTTForwarder(forwarder, row.ForwarderID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.DELETE("/mqtt-forward/topics/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forward topic id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.GetMQTTForwardTopic(id)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forward topic not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parentID := row.ForwarderID
|
||||||
|
writeMQTTForwardDeleteResponse(c, store.DeleteMQTTForwardTopic(id), func() error {
|
||||||
|
return reloadMQTTForwarder(forwarder, parentID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.GET("/mqtt-forward/status", func(c *gin.Context) {
|
||||||
|
items := []mqttForwardRuntimeStatus{}
|
||||||
|
if forwarder != nil {
|
||||||
|
items = forwarder.Status()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput {
|
||||||
|
sourcePassword := req.SourcePassword
|
||||||
|
if req.SourcePasswordClear {
|
||||||
|
empty := ""
|
||||||
|
sourcePassword = &empty
|
||||||
|
}
|
||||||
|
targetPassword := req.TargetPassword
|
||||||
|
if req.TargetPasswordClear {
|
||||||
|
empty := ""
|
||||||
|
targetPassword = &empty
|
||||||
|
}
|
||||||
|
return mqttForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) mqttForwardTopicInput {
|
||||||
|
return mqttForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadMQTTForwarder(forwarder mqttForwardReloader, id uint64) error {
|
||||||
|
if forwarder == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return forwarder.ReloadForwarder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwarderRecord, err error, afterSuccess func() error) {
|
||||||
|
if errors.Is(err, errMQTTForwarderAlreadyExists) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forwarder not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if afterSuccess != nil {
|
||||||
|
if err := afterSuccess(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "mqtt forwarder saved but reload failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *mqttForwardTopicRecord, err error, afterSuccess func() error) {
|
||||||
|
if errors.Is(err, errMQTTForwardTopicAlreadyExists) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forward topic not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if afterSuccess != nil {
|
||||||
|
if err := afterSuccess(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "mqtt forward topic saved but reload failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"item": mqttForwardTopicDTO(*row)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMQTTForwardDeleteResponse(c *gin.Context, err error, afterSuccess func() error) {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt forward item not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if afterSuccess != nil {
|
||||||
|
if err := afterSuccess(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "mqtt forward item deleted but reload failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwarderDTO(row mqttForwarderRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "name": row.Name, "enabled": row.Enabled, "source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername, "source_password_set": row.SourcePassword != "", "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS, "target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername, "target_password_set": row.TargetPassword != "", "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwardTopicDTO(row mqttForwardTopicRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "forwarder_id": row.ForwarderID, "topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction, "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
|
}
|
||||||
@@ -153,6 +153,48 @@ func (forbiddenWordBlockingRecord) TableName() string {
|
|||||||
return "forbidden_word_blocking"
|
return "forbidden_word_blocking"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mqttForwarderRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Name string `gorm:"column:name;not null;uniqueIndex"`
|
||||||
|
Enabled bool `gorm:"column:enabled;not null;index"`
|
||||||
|
SourceHost string `gorm:"column:source_host;not null"`
|
||||||
|
SourcePort int `gorm:"column:source_port;not null"`
|
||||||
|
SourceUsername string `gorm:"column:source_username"`
|
||||||
|
SourcePassword string `gorm:"column:source_password"`
|
||||||
|
SourceClientID string `gorm:"column:source_client_id"`
|
||||||
|
SourceTLS bool `gorm:"column:source_tls;not null"`
|
||||||
|
TargetHost string `gorm:"column:target_host;not null"`
|
||||||
|
TargetPort int `gorm:"column:target_port;not null"`
|
||||||
|
TargetUsername string `gorm:"column:target_username"`
|
||||||
|
TargetPassword string `gorm:"column:target_password"`
|
||||||
|
TargetClientID string `gorm:"column:target_client_id"`
|
||||||
|
TargetTLS bool `gorm:"column:target_tls;not null"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mqttForwarderRecord) TableName() string {
|
||||||
|
return "mqtt_forwarders"
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwardTopicRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
ForwarderID uint64 `gorm:"column:forwarder_id;not null;index;uniqueIndex:idx_mqtt_forward_topic_unique,priority:1"`
|
||||||
|
Topic string `gorm:"column:topic;not null;uniqueIndex:idx_mqtt_forward_topic_unique,priority:2"`
|
||||||
|
Enabled bool `gorm:"column:enabled;not null;index"`
|
||||||
|
Direction string `gorm:"column:direction;not null;index"`
|
||||||
|
SourcePrefix string `gorm:"column:source_prefix"`
|
||||||
|
TargetPrefix string `gorm:"column:target_prefix"`
|
||||||
|
QoS int `gorm:"column:qos;not null"`
|
||||||
|
Retain bool `gorm:"column:retain;not null"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mqttForwardTopicRecord) TableName() string {
|
||||||
|
return "mqtt_forward_topics"
|
||||||
|
}
|
||||||
|
|
||||||
type nodeInfoRecord struct {
|
type nodeInfoRecord struct {
|
||||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||||
@@ -351,6 +393,8 @@ func (s *store) migrate() error {
|
|||||||
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
||||||
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
||||||
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
|
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
|
||||||
|
{label: "mqtt_forwarders", model: &mqttForwarderRecord{}},
|
||||||
|
{label: "mqtt_forward_topics", model: &mqttForwardTopicRecord{}},
|
||||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||||
{label: "map_report", model: &mapReportRecord{}},
|
{label: "map_report", model: &mapReportRecord{}},
|
||||||
{label: "text_message", model: &textMessageRecord{}},
|
{label: "text_message", model: &textMessageRecord{}},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
@@ -50,6 +51,7 @@ require (
|
|||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ func run(cfg *config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
forwardManager := newMQTTForwardManager(store)
|
||||||
|
if err := forwardManager.StartFromStore(); err != nil {
|
||||||
|
server.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer forwardManager.StopAll()
|
||||||
|
|
||||||
var httpServer *http.Server
|
var httpServer *http.Server
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
@@ -228,7 +234,7 @@ func run(cfg *config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
||||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking)
|
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager)
|
||||||
webAddress := httpServer.Addr
|
webAddress := httpServer.Addr
|
||||||
go func() {
|
go func() {
|
||||||
if cfg.Web.SocketPath != "" {
|
if cfg.Web.SocketPath != "" {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AdminDashboard from './components/AdminDashboard.vue'
|
|||||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||||
import AdminLogin from './components/AdminLogin.vue'
|
import AdminLogin from './components/AdminLogin.vue'
|
||||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||||
|
import AdminMqttForward from './components/AdminMqttForward.vue'
|
||||||
import AdminUsers from './components/AdminUsers.vue'
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue'
|
import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue'
|
||||||
@@ -18,6 +19,7 @@ import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, M
|
|||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const adminPath = currentPath
|
const adminPath = currentPath
|
||||||
const isAdminPage = adminPath.startsWith('/admin')
|
const isAdminPage = adminPath.startsWith('/admin')
|
||||||
|
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
||||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
const isDetailedPage = !!detailedNodeId
|
const isDetailedPage = !!detailedNodeId
|
||||||
@@ -454,6 +456,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
|
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
|
||||||
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
||||||
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
||||||
|
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -491,6 +494,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||||
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
||||||
|
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||||
<AdminDashboard v-else />
|
<AdminDashboard v-else />
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import type {
|
|||||||
MapBoundsQuery,
|
MapBoundsQuery,
|
||||||
MapReport,
|
MapReport,
|
||||||
MapViewportResponse,
|
MapViewportResponse,
|
||||||
|
MQTTForwarder,
|
||||||
|
MQTTForwarderPayload,
|
||||||
|
MQTTForwardMutationResponse,
|
||||||
|
MQTTForwardStatusResponse,
|
||||||
|
MQTTForwardTopic,
|
||||||
|
MQTTForwardTopicPayload,
|
||||||
NodeBlockingRule,
|
NodeBlockingRule,
|
||||||
NodeBlockingRulePayload,
|
NodeBlockingRulePayload,
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
@@ -214,3 +220,43 @@ export function updateForbiddenWordBlockingRule(id: number, payload: ForbiddenWo
|
|||||||
export function deleteForbiddenWordBlockingRule(id: number): Promise<{ status: string }> {
|
export function deleteForbiddenWordBlockingRule(id: number): Promise<{ status: string }> {
|
||||||
return deleteJSON<{ status: string }>(`/api/admin/blocking/words/${id}`)
|
return deleteJSON<{ status: string }>(`/api/admin/blocking/words/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMQTTForwarders(limit = 100, offset = 0): Promise<ListResponse<MQTTForwarder>> {
|
||||||
|
return getJSON<ListResponse<MQTTForwarder>>(listPath('/api/admin/mqtt-forward/forwarders', limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMQTTForwarder(payload: MQTTForwarderPayload): Promise<MQTTForwardMutationResponse<MQTTForwarder>> {
|
||||||
|
return postJSON<MQTTForwardMutationResponse<MQTTForwarder>>('/api/admin/mqtt-forward/forwarders', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMQTTForwarder(id: number, payload: MQTTForwarderPayload): Promise<MQTTForwardMutationResponse<MQTTForwarder>> {
|
||||||
|
return putJSON<MQTTForwardMutationResponse<MQTTForwarder>>(`/api/admin/mqtt-forward/forwarders/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMQTTForwarder(id: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/mqtt-forward/forwarders/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restartMQTTForwarder(id: number): Promise<{ status: string }> {
|
||||||
|
return postJSON<{ status: string }>(`/api/admin/mqtt-forward/forwarders/${id}/restart`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMQTTForwardTopics(forwarderId: number, limit = 100, offset = 0): Promise<ListResponse<MQTTForwardTopic>> {
|
||||||
|
return getJSON<ListResponse<MQTTForwardTopic>>(listPath(`/api/admin/mqtt-forward/forwarders/${forwarderId}/topics`, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMQTTForwardTopic(forwarderId: number, payload: MQTTForwardTopicPayload): Promise<MQTTForwardMutationResponse<MQTTForwardTopic>> {
|
||||||
|
return postJSON<MQTTForwardMutationResponse<MQTTForwardTopic>>(`/api/admin/mqtt-forward/forwarders/${forwarderId}/topics`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMQTTForwardTopic(id: number, payload: MQTTForwardTopicPayload): Promise<MQTTForwardMutationResponse<MQTTForwardTopic>> {
|
||||||
|
return putJSON<MQTTForwardMutationResponse<MQTTForwardTopic>>(`/api/admin/mqtt-forward/topics/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMQTTForwardTopic(id: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/mqtt-forward/topics/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMQTTForwardStatus(): Promise<MQTTForwardStatusResponse> {
|
||||||
|
return getJSON<MQTTForwardStatusResponse>('/api/admin/mqtt-forward/status')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,923 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
createMQTTForwarder,
|
||||||
|
createMQTTForwardTopic,
|
||||||
|
deleteMQTTForwarder,
|
||||||
|
deleteMQTTForwardTopic,
|
||||||
|
getMQTTForwarders,
|
||||||
|
getMQTTForwardStatus,
|
||||||
|
getMQTTForwardTopics,
|
||||||
|
restartMQTTForwarder,
|
||||||
|
updateMQTTForwarder,
|
||||||
|
updateMQTTForwardTopic,
|
||||||
|
} from '../api'
|
||||||
|
import type {
|
||||||
|
MQTTForwarder,
|
||||||
|
MQTTForwarderPayload,
|
||||||
|
MQTTForwardRuntimeStatus,
|
||||||
|
MQTTForwardTopic,
|
||||||
|
MQTTForwardTopicPayload,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const pageSize = 25
|
||||||
|
const topicPageSize = 100
|
||||||
|
|
||||||
|
const forwarders = ref<MQTTForwarder[]>([])
|
||||||
|
const topics = ref<Record<number, MQTTForwardTopic[]>>({})
|
||||||
|
const statuses = ref<Record<number, MQTTForwardRuntimeStatus>>({})
|
||||||
|
const edits = ref<Record<number, ForwarderEdit>>({})
|
||||||
|
const topicEdits = ref<Record<number, MQTTForwardTopicPayload>>({})
|
||||||
|
const expanded = ref<Record<number, boolean>>({})
|
||||||
|
const newTopics = ref<Record<number, MQTTForwardTopicPayload>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
let statusTimer: number | undefined
|
||||||
|
|
||||||
|
type ForwarderEdit = {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
source_host: string
|
||||||
|
source_port: string
|
||||||
|
source_username: string
|
||||||
|
source_password: string
|
||||||
|
source_password_clear: boolean
|
||||||
|
source_client_id: string
|
||||||
|
source_tls: boolean
|
||||||
|
target_host: string
|
||||||
|
target_port: string
|
||||||
|
target_username: string
|
||||||
|
target_password: string
|
||||||
|
target_password_clear: boolean
|
||||||
|
target_client_id: string
|
||||||
|
target_tls: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const newForwarder = ref<ForwarderEdit>({
|
||||||
|
name: '',
|
||||||
|
enabled: false,
|
||||||
|
source_host: 'mqtt.mess.host',
|
||||||
|
source_port: '1883',
|
||||||
|
source_username: '',
|
||||||
|
source_password: '',
|
||||||
|
source_password_clear: false,
|
||||||
|
source_client_id: '',
|
||||||
|
source_tls: false,
|
||||||
|
target_host: '127.0.0.1',
|
||||||
|
target_port: '1883',
|
||||||
|
target_username: '',
|
||||||
|
target_password: '',
|
||||||
|
target_password_clear: false,
|
||||||
|
target_client_id: '',
|
||||||
|
target_tls: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canPrev = computed(() => page.value > 1)
|
||||||
|
const canNext = computed(() => page.value * pageSize < total.value || forwarders.value.length === pageSize)
|
||||||
|
|
||||||
|
function formatTime(value: string | null): string {
|
||||||
|
return value ? new Date(value).toLocaleString() : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTopic(): MQTTForwardTopicPayload {
|
||||||
|
return { topic: 'msh/#', enabled: true, direction: 'source_to_target', source_prefix: '', target_prefix: '', qos: 0, retain: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEdits(items: MQTTForwarder[]) {
|
||||||
|
edits.value = Object.fromEntries(items.map((item) => [item.id, forwarderToEdit(item)]))
|
||||||
|
for (const item of items) {
|
||||||
|
if (!newTopics.value[item.id]) {
|
||||||
|
newTopics.value[item.id] = defaultTopic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwarderToEdit(item: MQTTForwarder): ForwarderEdit {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
enabled: item.enabled,
|
||||||
|
source_host: item.source_host,
|
||||||
|
source_port: String(item.source_port),
|
||||||
|
source_username: item.source_username,
|
||||||
|
source_password: '',
|
||||||
|
source_password_clear: false,
|
||||||
|
source_client_id: item.source_client_id,
|
||||||
|
source_tls: item.source_tls,
|
||||||
|
target_host: item.target_host,
|
||||||
|
target_port: String(item.target_port),
|
||||||
|
target_username: item.target_username,
|
||||||
|
target_password: '',
|
||||||
|
target_password_clear: false,
|
||||||
|
target_client_id: item.target_client_id,
|
||||||
|
target_tls: item.target_tls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTopicEdits(forwarderId: number, items: MQTTForwardTopic[]) {
|
||||||
|
topics.value = { ...topics.value, [forwarderId]: items }
|
||||||
|
topicEdits.value = {
|
||||||
|
...topicEdits.value,
|
||||||
|
...Object.fromEntries(
|
||||||
|
items.map((item) => [
|
||||||
|
item.id,
|
||||||
|
{
|
||||||
|
topic: item.topic,
|
||||||
|
enabled: item.enabled,
|
||||||
|
direction: item.direction,
|
||||||
|
source_prefix: item.source_prefix,
|
||||||
|
target_prefix: item.target_prefix,
|
||||||
|
qos: item.qos,
|
||||||
|
retain: item.retain,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(value: string, label: string): number {
|
||||||
|
const parsed = Number.parseInt(value.trim(), 10)
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||||
|
throw new Error(`${label}必须是 1-65535`)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwarderPayload(edit: ForwarderEdit, includePasswords: boolean): MQTTForwarderPayload {
|
||||||
|
if (!edit.name.trim()) {
|
||||||
|
throw new Error('名称不能为空')
|
||||||
|
}
|
||||||
|
if (!edit.source_host.trim()) {
|
||||||
|
throw new Error('源 Host 不能为空')
|
||||||
|
}
|
||||||
|
if (!edit.target_host.trim()) {
|
||||||
|
throw new Error('目标 Host 不能为空')
|
||||||
|
}
|
||||||
|
const payload: MQTTForwarderPayload = {
|
||||||
|
name: edit.name.trim(),
|
||||||
|
enabled: edit.enabled,
|
||||||
|
source_host: edit.source_host.trim(),
|
||||||
|
source_port: parsePort(edit.source_port, '源端口'),
|
||||||
|
source_username: edit.source_username.trim(),
|
||||||
|
source_client_id: edit.source_client_id.trim(),
|
||||||
|
source_tls: edit.source_tls,
|
||||||
|
target_host: edit.target_host.trim(),
|
||||||
|
target_port: parsePort(edit.target_port, '目标端口'),
|
||||||
|
target_username: edit.target_username.trim(),
|
||||||
|
target_client_id: edit.target_client_id.trim(),
|
||||||
|
target_tls: edit.target_tls,
|
||||||
|
}
|
||||||
|
if (includePasswords || edit.source_password.trim()) {
|
||||||
|
payload.source_password = edit.source_password
|
||||||
|
}
|
||||||
|
if (edit.source_password_clear) {
|
||||||
|
payload.source_password_clear = true
|
||||||
|
}
|
||||||
|
if (includePasswords || edit.target_password.trim()) {
|
||||||
|
payload.target_password = edit.target_password
|
||||||
|
}
|
||||||
|
if (edit.target_password_clear) {
|
||||||
|
payload.target_password_clear = true
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicPayload(edit: MQTTForwardTopicPayload): MQTTForwardTopicPayload {
|
||||||
|
if (!edit.topic.trim()) {
|
||||||
|
throw new Error('TOPIC 不能为空')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
topic: edit.topic.trim(),
|
||||||
|
enabled: edit.enabled,
|
||||||
|
direction: edit.direction,
|
||||||
|
source_prefix: edit.source_prefix.trim(),
|
||||||
|
target_prefix: edit.target_prefix.trim(),
|
||||||
|
qos: Number(edit.qos),
|
||||||
|
retain: edit.retain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshForwarders(targetPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const safePage = Math.max(1, targetPage)
|
||||||
|
const response = await getMQTTForwarders(pageSize, (safePage - 1) * pageSize)
|
||||||
|
forwarders.value = response.items
|
||||||
|
total.value = response.total ?? response.offset + response.items.length
|
||||||
|
page.value = safePage
|
||||||
|
resetEdits(response.items)
|
||||||
|
await refreshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const response = await getMQTTForwardStatus()
|
||||||
|
statuses.value = Object.fromEntries(response.items.map((item) => [item.forwarder_id, item]))
|
||||||
|
} catch {
|
||||||
|
// Keep the page usable if status polling fails; CRUD calls will surface errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createForwarder() {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
let payload: MQTTForwarderPayload
|
||||||
|
try {
|
||||||
|
payload = forwarderPayload(newForwarder.value, true)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await createMQTTForwarder(payload)
|
||||||
|
newForwarder.value.name = ''
|
||||||
|
newForwarder.value.source_password = ''
|
||||||
|
newForwarder.value.target_password = ''
|
||||||
|
message.value = 'MQTT 转发线程已新增'
|
||||||
|
await refreshForwarders(1)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForwarder(item: MQTTForwarder) {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
const edit = edits.value[item.id]
|
||||||
|
if (!edit) return
|
||||||
|
let payload: MQTTForwarderPayload
|
||||||
|
try {
|
||||||
|
payload = forwarderPayload(edit, false)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await updateMQTTForwarder(item.id, payload)
|
||||||
|
message.value = 'MQTT 转发线程已保存'
|
||||||
|
await refreshForwarders()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeForwarder(item: MQTTForwarder) {
|
||||||
|
if (!window.confirm(`确定删除 MQTT 转发线程「${item.name}」吗?`)) return
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await deleteMQTTForwarder(item.id)
|
||||||
|
message.value = 'MQTT 转发线程已删除'
|
||||||
|
await refreshForwarders(forwarders.value.length === 1 ? page.value - 1 : page.value)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartForwarder(item: MQTTForwarder) {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await restartMQTTForwarder(item.id)
|
||||||
|
message.value = 'MQTT 转发线程已重启'
|
||||||
|
await refreshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTopics(item: MQTTForwarder) {
|
||||||
|
expanded.value = { ...expanded.value, [item.id]: !expanded.value[item.id] }
|
||||||
|
if (expanded.value[item.id] && !topics.value[item.id]) {
|
||||||
|
await refreshTopics(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTopics(forwarderId: number) {
|
||||||
|
const response = await getMQTTForwardTopics(forwarderId, topicPageSize, 0)
|
||||||
|
resetTopicEdits(forwarderId, response.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTopic(forwarderId: number) {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
let payload: MQTTForwardTopicPayload
|
||||||
|
try {
|
||||||
|
payload = topicPayload(newTopics.value[forwarderId] ?? defaultTopic())
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createMQTTForwardTopic(forwarderId, payload)
|
||||||
|
newTopics.value = { ...newTopics.value, [forwarderId]: defaultTopic() }
|
||||||
|
message.value = 'TOPIC 已新增'
|
||||||
|
await refreshTopics(forwarderId)
|
||||||
|
await refreshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTopic(topic: MQTTForwardTopic) {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
const edit = topicEdits.value[topic.id]
|
||||||
|
if (!edit) return
|
||||||
|
try {
|
||||||
|
await updateMQTTForwardTopic(topic.id, topicPayload(edit))
|
||||||
|
message.value = 'TOPIC 已保存'
|
||||||
|
await refreshTopics(topic.forwarder_id)
|
||||||
|
await refreshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTopic(topic: MQTTForwardTopic) {
|
||||||
|
if (!window.confirm(`确定删除 TOPIC「${topic.topic}」吗?`)) return
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await deleteMQTTForwardTopic(topic.id)
|
||||||
|
message.value = 'TOPIC 已删除'
|
||||||
|
await refreshTopics(topic.forwarder_id)
|
||||||
|
await refreshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(item: MQTTForwarder): string {
|
||||||
|
const status = statuses.value[item.id]
|
||||||
|
if (!item.enabled) return '已禁用'
|
||||||
|
if (!status) return '未运行'
|
||||||
|
return status.source_connected && status.target_connected ? '已连接' : '连接中/异常'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshForwarders()
|
||||||
|
statusTimer = window.setInterval(refreshStatus, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (statusTimer !== undefined) {
|
||||||
|
window.clearInterval(statusTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mqtt-forward-page">
|
||||||
|
<div class="mqtt-hero panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">MQTT Forward</p>
|
||||||
|
<h2>MQTT 转发管理</h2>
|
||||||
|
<p class="muted">统一管理源 Broker、目标 Broker 和每个 TOPIC 的转发方向。保存配置后后端会自动重启对应线程。</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div>
|
||||||
|
<strong>{{ total }}</strong>
|
||||||
|
<span>线程配置</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ Object.keys(statuses).length }}</strong>
|
||||||
|
<span>运行中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel form-panel">
|
||||||
|
<div class="panel-heading compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Create</p>
|
||||||
|
<h2>新增转发线程</h2>
|
||||||
|
</div>
|
||||||
|
<label class="switch-card">
|
||||||
|
<input v-model="newForwarder.enabled" type="checkbox" />
|
||||||
|
<span>创建后启用</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<form class="forward-form" @submit.prevent="createForwarder">
|
||||||
|
<label class="field span-2">名称<input v-model="newForwarder.name" placeholder="例如:Meshtastic CN" /></label>
|
||||||
|
<fieldset class="broker-card source-card">
|
||||||
|
<legend>源 Broker</legend>
|
||||||
|
<label class="field span-2">Host<input v-model="newForwarder.source_host" /></label>
|
||||||
|
<label class="field small">Port<input v-model="newForwarder.source_port" /></label>
|
||||||
|
<label class="field">用户名<input v-model="newForwarder.source_username" /></label>
|
||||||
|
<label class="field">密码<input v-model="newForwarder.source_password" type="password" /></label>
|
||||||
|
<label class="field span-2">Client ID<input v-model="newForwarder.source_client_id" placeholder="留空自动生成" /></label>
|
||||||
|
<label class="switch-card"><input v-model="newForwarder.source_tls" type="checkbox" /> <span>TLS</span></label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="broker-card target-card">
|
||||||
|
<legend>目标 Broker</legend>
|
||||||
|
<label class="field span-2">Host<input v-model="newForwarder.target_host" /></label>
|
||||||
|
<label class="field small">Port<input v-model="newForwarder.target_port" /></label>
|
||||||
|
<label class="field">用户名<input v-model="newForwarder.target_username" /></label>
|
||||||
|
<label class="field">密码<input v-model="newForwarder.target_password" type="password" /></label>
|
||||||
|
<label class="field span-2">Client ID<input v-model="newForwarder.target_client_id" placeholder="留空自动生成" /></label>
|
||||||
|
<label class="switch-card"><input v-model="newForwarder.target_tls" type="checkbox" /> <span>TLS</span></label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="admin-button" type="submit" :disabled="loading">新增转发线程</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="message" class="success">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel list-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Forwarders</p>
|
||||||
|
<h2>转发线程</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button ghost" @click="refreshForwarders()" :disabled="loading">刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!forwarders.length" class="empty-state">暂无 MQTT 转发线程,先在上方创建一个配置。</div>
|
||||||
|
|
||||||
|
<article v-for="item in forwarders" :key="item.id" class="forwarder-card">
|
||||||
|
<header class="forwarder-title">
|
||||||
|
<div>
|
||||||
|
<h3>{{ item.name }}</h3>
|
||||||
|
<p class="endpoint-line">{{ item.source_host }}:{{ item.source_port }} → {{ item.target_host }}:{{ item.target_port }}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="status-pill"
|
||||||
|
:class="{
|
||||||
|
ok: item.enabled && statuses[item.id]?.source_connected && statuses[item.id]?.target_connected,
|
||||||
|
disabled: !item.enabled,
|
||||||
|
warn: item.enabled && (!statuses[item.id]?.source_connected || !statuses[item.id]?.target_connected),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ statusText(item) }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="runtime-grid">
|
||||||
|
<div><span>源连接</span><strong>{{ statuses[item.id]?.source_connected ? '已连接' : '未连接' }}</strong></div>
|
||||||
|
<div><span>目标连接</span><strong>{{ statuses[item.id]?.target_connected ? '已连接' : '未连接' }}</strong></div>
|
||||||
|
<div><span>已转发</span><strong>{{ statuses[item.id]?.messages_forwarded ?? 0 }}</strong></div>
|
||||||
|
<div><span>已丢弃</span><strong>{{ statuses[item.id]?.messages_dropped ?? 0 }}</strong></div>
|
||||||
|
<div class="span-2"><span>启动时间</span><strong>{{ formatTime(statuses[item.id]?.started_at ?? null) }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p v-if="statuses[item.id]?.last_error" class="inline-error">{{ statuses[item.id]?.last_error }}</p>
|
||||||
|
|
||||||
|
<div v-if="edits[item.id]" class="edit-shell">
|
||||||
|
<div class="edit-section main-section">
|
||||||
|
<label class="field">名称<input v-model="edits[item.id].name" /></label>
|
||||||
|
<label class="switch-card"><input v-model="edits[item.id].enabled" type="checkbox" /> <span>启用线程</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="edit-section source-card">
|
||||||
|
<h4>源 Broker</h4>
|
||||||
|
<label class="field span-2">Host<input v-model="edits[item.id].source_host" /></label>
|
||||||
|
<label class="field small">Port<input v-model="edits[item.id].source_port" /></label>
|
||||||
|
<label class="field">用户名<input v-model="edits[item.id].source_username" /></label>
|
||||||
|
<label class="field">密码<input v-model="edits[item.id].source_password" type="password" :placeholder="item.source_password_set ? '留空保持原密码' : ''" /></label>
|
||||||
|
<label class="field span-2">Client ID<input v-model="edits[item.id].source_client_id" /></label>
|
||||||
|
<label class="switch-card"><input v-model="edits[item.id].source_password_clear" type="checkbox" /> <span>清空源密码</span></label>
|
||||||
|
<label class="switch-card"><input v-model="edits[item.id].source_tls" type="checkbox" /> <span>源 TLS</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="edit-section target-card">
|
||||||
|
<h4>目标 Broker</h4>
|
||||||
|
<label class="field span-2">Host<input v-model="edits[item.id].target_host" /></label>
|
||||||
|
<label class="field small">Port<input v-model="edits[item.id].target_port" /></label>
|
||||||
|
<label class="field">用户名<input v-model="edits[item.id].target_username" /></label>
|
||||||
|
<label class="field">密码<input v-model="edits[item.id].target_password" type="password" :placeholder="item.target_password_set ? '留空保持原密码' : ''" /></label>
|
||||||
|
<label class="field span-2">Client ID<input v-model="edits[item.id].target_client_id" /></label>
|
||||||
|
<label class="switch-card"><input v-model="edits[item.id].target_password_clear" type="checkbox" /> <span>清空目标密码</span></label>
|
||||||
|
<label class="switch-card"><input v-model="edits[item.id].target_tls" type="checkbox" /> <span>目标 TLS</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="admin-button" @click="saveForwarder(item)" :disabled="loading">保存并重启</button>
|
||||||
|
<button class="admin-button ghost" @click="restartForwarder(item)">仅重启</button>
|
||||||
|
<button class="admin-button danger" @click="removeForwarder(item)" :disabled="loading">删除</button>
|
||||||
|
<button class="admin-button secondary" @click="toggleTopics(item)">{{ expanded[item.id] ? '收起 TOPICS' : '管理 TOPICS' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expanded[item.id]" class="topics-box">
|
||||||
|
<div class="topics-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Topics</p>
|
||||||
|
<h4>订阅规则</h4>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ topics[item.id]?.length ?? 0 }} 条</span>
|
||||||
|
</div>
|
||||||
|
<form v-if="newTopics[item.id]" class="topic-row new-topic" @submit.prevent="createTopic(item.id)">
|
||||||
|
<input v-model="newTopics[item.id].topic" placeholder="msh/#" />
|
||||||
|
<label class="mini-check"><input v-model="newTopics[item.id].enabled" type="checkbox" /> 启用</label>
|
||||||
|
<select v-model="newTopics[item.id].direction">
|
||||||
|
<option value="source_to_target">单向:源 → 目标</option>
|
||||||
|
<option value="bidirectional">双向</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="newTopics[item.id].source_prefix" placeholder="源前缀" />
|
||||||
|
<input v-model="newTopics[item.id].target_prefix" placeholder="目标前缀" />
|
||||||
|
<select v-model.number="newTopics[item.id].qos">
|
||||||
|
<option :value="0">QoS 0</option>
|
||||||
|
<option :value="1">QoS 1</option>
|
||||||
|
<option :value="2">QoS 2</option>
|
||||||
|
</select>
|
||||||
|
<label class="mini-check"><input v-model="newTopics[item.id].retain" type="checkbox" /> Retain</label>
|
||||||
|
<button class="admin-button" type="submit">新增</button>
|
||||||
|
</form>
|
||||||
|
<div v-for="topic in topics[item.id] ?? []" :key="topic.id" class="topic-row">
|
||||||
|
<input v-model="topicEdits[topic.id].topic" />
|
||||||
|
<label class="mini-check"><input v-model="topicEdits[topic.id].enabled" type="checkbox" /> 启用</label>
|
||||||
|
<select v-model="topicEdits[topic.id].direction">
|
||||||
|
<option value="source_to_target">单向:源 → 目标</option>
|
||||||
|
<option value="bidirectional">双向</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="topicEdits[topic.id].source_prefix" placeholder="源前缀" />
|
||||||
|
<input v-model="topicEdits[topic.id].target_prefix" placeholder="目标前缀" />
|
||||||
|
<select v-model.number="topicEdits[topic.id].qos">
|
||||||
|
<option :value="0">QoS 0</option>
|
||||||
|
<option :value="1">QoS 1</option>
|
||||||
|
<option :value="2">QoS 2</option>
|
||||||
|
</select>
|
||||||
|
<label class="mini-check"><input v-model="topicEdits[topic.id].retain" type="checkbox" /> Retain</label>
|
||||||
|
<button class="admin-button ghost" @click="saveTopic(topic)">保存</button>
|
||||||
|
<button class="admin-button danger" @click="removeTopic(topic)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="admin-button ghost" :disabled="!canPrev || loading" @click="refreshForwarders(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} 页 · 共 {{ total }} 条</span>
|
||||||
|
<button class="admin-button ghost" :disabled="!canNext || loading" @click="refreshForwarders(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mqtt-forward-page {
|
||||||
|
width: min(1440px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-forward-page :deep(input),
|
||||||
|
.mqtt-forward-page :deep(select) {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: #0f172a;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-forward-page :deep(input:focus),
|
||||||
|
.mqtt-forward-page :deep(select:focus) {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-hero,
|
||||||
|
.form-panel,
|
||||||
|
.list-panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-hero h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(110px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats div {
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats strong {
|
||||||
|
display: block;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats span,
|
||||||
|
.endpoint-line,
|
||||||
|
.runtime-grid span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading,
|
||||||
|
.forwarder-title,
|
||||||
|
.actions,
|
||||||
|
.pagination,
|
||||||
|
.topics-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading,
|
||||||
|
.forwarder-title,
|
||||||
|
.topics-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading.compact {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-card,
|
||||||
|
.edit-section,
|
||||||
|
.forwarder-card,
|
||||||
|
.topics-box {
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-card legend {
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-card {
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-card {
|
||||||
|
background: linear-gradient(180deg, #f8fffb 0%, #fff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-card,
|
||||||
|
.mini-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-card input,
|
||||||
|
.mini-check input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forwarder-card {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
box-shadow: inset 4px 0 0 #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forwarder-title h3 {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: #92400e;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.ok {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.warn {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.disabled {
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-grid div {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-error {
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fef2f2;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section h4 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button.ghost {
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button.secondary {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topics-box {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.6fr) minmax(90px, 0.7fr) minmax(150px, 1fr) repeat(2, minmax(120px, 1fr)) minmax(90px, 0.7fr) minmax(90px, 0.7fr) auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-row.new-topic {
|
||||||
|
border: 1px dashed #93c5fd;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.forward-form,
|
||||||
|
.edit-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-card,
|
||||||
|
.edit-section,
|
||||||
|
.topic-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-2,
|
||||||
|
.main-section {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.mqtt-hero,
|
||||||
|
.panel-heading,
|
||||||
|
.forwarder-title {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats,
|
||||||
|
.broker-card,
|
||||||
|
.edit-section,
|
||||||
|
.topic-row,
|
||||||
|
.main-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-2 {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -285,3 +285,87 @@ export interface ForbiddenWordBlockingRulePayload {
|
|||||||
export interface BlockingRuleResponse<T> {
|
export interface BlockingRuleResponse<T> {
|
||||||
item: T
|
item: T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MQTTForwardDirection = 'source_to_target' | 'bidirectional'
|
||||||
|
|
||||||
|
export interface MQTTForwarder {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
source_host: string
|
||||||
|
source_port: number
|
||||||
|
source_username: string
|
||||||
|
source_password_set: boolean
|
||||||
|
source_client_id: string
|
||||||
|
source_tls: boolean
|
||||||
|
target_host: string
|
||||||
|
target_port: number
|
||||||
|
target_username: string
|
||||||
|
target_password_set: boolean
|
||||||
|
target_client_id: string
|
||||||
|
target_tls: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MQTTForwarderPayload {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
source_host: string
|
||||||
|
source_port: number
|
||||||
|
source_username: string
|
||||||
|
source_password?: string
|
||||||
|
source_password_clear?: boolean
|
||||||
|
source_client_id: string
|
||||||
|
source_tls: boolean
|
||||||
|
target_host: string
|
||||||
|
target_port: number
|
||||||
|
target_username: string
|
||||||
|
target_password?: string
|
||||||
|
target_password_clear?: boolean
|
||||||
|
target_client_id: string
|
||||||
|
target_tls: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MQTTForwardTopic {
|
||||||
|
id: number
|
||||||
|
forwarder_id: number
|
||||||
|
topic: string
|
||||||
|
enabled: boolean
|
||||||
|
direction: MQTTForwardDirection
|
||||||
|
source_prefix: string
|
||||||
|
target_prefix: string
|
||||||
|
qos: number
|
||||||
|
retain: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MQTTForwardTopicPayload {
|
||||||
|
topic: string
|
||||||
|
enabled: boolean
|
||||||
|
direction: MQTTForwardDirection
|
||||||
|
source_prefix: string
|
||||||
|
target_prefix: string
|
||||||
|
qos: number
|
||||||
|
retain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MQTTForwardRuntimeStatus {
|
||||||
|
forwarder_id: number
|
||||||
|
running: boolean
|
||||||
|
source_connected: boolean
|
||||||
|
target_connected: boolean
|
||||||
|
last_error: string
|
||||||
|
started_at: string | null
|
||||||
|
messages_forwarded: number
|
||||||
|
messages_dropped: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MQTTForwardMutationResponse<T> {
|
||||||
|
item: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MQTTForwardStatusResponse {
|
||||||
|
items: MQTTForwardRuntimeStatus[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pahomqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mqttForwardDirectionTargetToSource = "target_to_source"
|
||||||
|
mqttForwardLoopTTL = 15 * time.Second
|
||||||
|
mqttForwardLoopMaxEntries = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
type mqttForwardReloader interface {
|
||||||
|
ReloadForwarder(id uint64) error
|
||||||
|
StopForwarder(id uint64)
|
||||||
|
Status() []mqttForwardRuntimeStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwardManager struct {
|
||||||
|
store *store
|
||||||
|
mu sync.Mutex
|
||||||
|
runners map[uint64]*mqttForwardRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwardRuntimeStatus struct {
|
||||||
|
ForwarderID uint64 `json:"forwarder_id"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
SourceConnected bool `json:"source_connected"`
|
||||||
|
TargetConnected bool `json:"target_connected"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
MessagesForwarded uint64 `json:"messages_forwarded"`
|
||||||
|
MessagesDropped uint64 `json:"messages_dropped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwardRunner struct {
|
||||||
|
config mqttForwarderConfig
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
source pahomqtt.Client
|
||||||
|
target pahomqtt.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastError string
|
||||||
|
startedAt time.Time
|
||||||
|
sourceConnected bool
|
||||||
|
targetConnected bool
|
||||||
|
messagesForwarded uint64
|
||||||
|
messagesDropped uint64
|
||||||
|
loopCache map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMQTTForwardManager(store *store) *mqttForwardManager {
|
||||||
|
return &mqttForwardManager{store: store, runners: make(map[uint64]*mqttForwardRunner)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mqttForwardManager) StartFromStore() error {
|
||||||
|
configs, err := m.store.ListEnabledMQTTForwarderConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if len(cfg.Topics) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runner := newMQTTForwardRunner(cfg)
|
||||||
|
runner.Start()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.runners[cfg.Forwarder.ID] = runner
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
||||||
|
m.StopForwarder(id)
|
||||||
|
cfg, err := m.store.GetMQTTForwarderConfig(id)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runner := newMQTTForwardRunner(*cfg)
|
||||||
|
runner.Start()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.runners[id] = runner
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mqttForwardManager) StopForwarder(id uint64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
runner := m.runners[id]
|
||||||
|
delete(m.runners, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if runner != nil {
|
||||||
|
runner.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mqttForwardManager) StopAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
runners := make([]*mqttForwardRunner, 0, len(m.runners))
|
||||||
|
for id, runner := range m.runners {
|
||||||
|
runners = append(runners, runner)
|
||||||
|
delete(m.runners, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, runner := range runners {
|
||||||
|
runner.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus {
|
||||||
|
m.mu.Lock()
|
||||||
|
runners := make([]*mqttForwardRunner, 0, len(m.runners))
|
||||||
|
for _, runner := range m.runners {
|
||||||
|
runners = append(runners, runner)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
items := make([]mqttForwardRuntimeStatus, 0, len(runners))
|
||||||
|
for _, runner := range runners {
|
||||||
|
items = append(items, runner.Status())
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].ForwarderID < items[j].ForwarderID })
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMQTTForwardRunner(config mqttForwarderConfig) *mqttForwardRunner {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &mqttForwardRunner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) Start() {
|
||||||
|
r.source = r.newClient(true)
|
||||||
|
r.target = r.newClient(false)
|
||||||
|
r.connectClient(r.target, "target")
|
||||||
|
r.connectClient(r.source, "source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) Stop() {
|
||||||
|
r.cancel()
|
||||||
|
if r.source != nil && r.source.IsConnected() {
|
||||||
|
r.source.Disconnect(250)
|
||||||
|
}
|
||||||
|
if r.target != nil && r.target.IsConnected() {
|
||||||
|
r.target.Disconnect(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
started := r.startedAt
|
||||||
|
return mqttForwardRuntimeStatus{
|
||||||
|
ForwarderID: r.config.Forwarder.ID,
|
||||||
|
Running: true,
|
||||||
|
SourceConnected: r.sourceConnected,
|
||||||
|
TargetConnected: r.targetConnected,
|
||||||
|
LastError: r.lastError,
|
||||||
|
StartedAt: &started,
|
||||||
|
MessagesForwarded: r.messagesForwarded,
|
||||||
|
MessagesDropped: r.messagesDropped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client {
|
||||||
|
forwarder := r.config.Forwarder
|
||||||
|
host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS
|
||||||
|
role := "source"
|
||||||
|
if !source {
|
||||||
|
host, port, username, password, clientID, useTLS = forwarder.TargetHost, forwarder.TargetPort, forwarder.TargetUsername, forwarder.TargetPassword, forwarder.TargetClientID, forwarder.TargetTLS
|
||||||
|
role = "target"
|
||||||
|
}
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = fmt.Sprintf("mesh-forward-%d-%s", forwarder.ID, role)
|
||||||
|
}
|
||||||
|
scheme := "tcp"
|
||||||
|
if useTLS {
|
||||||
|
scheme = "ssl"
|
||||||
|
}
|
||||||
|
opts := pahomqtt.NewClientOptions().
|
||||||
|
AddBroker(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, fmt.Sprint(port)))).
|
||||||
|
SetClientID(clientID).
|
||||||
|
SetAutoReconnect(true).
|
||||||
|
SetConnectRetry(true).
|
||||||
|
SetKeepAlive(60 * time.Second).
|
||||||
|
SetConnectionLostHandler(func(_ pahomqtt.Client, err error) {
|
||||||
|
r.setConnected(source, false)
|
||||||
|
r.setError(fmt.Sprintf("%s connection lost: %v", role, err))
|
||||||
|
}).
|
||||||
|
SetOnConnectHandler(func(client pahomqtt.Client) {
|
||||||
|
r.setConnected(source, true)
|
||||||
|
r.subscribe(client, source)
|
||||||
|
})
|
||||||
|
if username != "" {
|
||||||
|
opts.SetUsername(username)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
opts.SetPassword(password)
|
||||||
|
}
|
||||||
|
if useTLS {
|
||||||
|
opts.SetTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12})
|
||||||
|
}
|
||||||
|
return pahomqtt.NewClient(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string) {
|
||||||
|
token := client.Connect()
|
||||||
|
if !token.WaitTimeout(2 * time.Second) {
|
||||||
|
r.setError(label + " connect pending")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := token.Error(); err != nil {
|
||||||
|
r.setError(fmt.Sprintf("%s connect failed: %v", label, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) {
|
||||||
|
for _, topic := range r.config.Topics {
|
||||||
|
filter := topic.Topic
|
||||||
|
if !source {
|
||||||
|
if topic.Direction != mqttForwardDirectionBidirectional {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix)
|
||||||
|
}
|
||||||
|
topicRule := topic
|
||||||
|
token := client.Subscribe(filter, byte(topic.QoS), func(_ pahomqtt.Client, msg pahomqtt.Message) {
|
||||||
|
r.forwardMessage(source, topicRule, msg)
|
||||||
|
})
|
||||||
|
if !token.WaitTimeout(2 * time.Second) {
|
||||||
|
r.setError("subscribe pending: " + filter)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := token.Error(); err != nil {
|
||||||
|
r.setError(fmt.Sprintf("subscribe %s failed: %v", filter, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopicRecord, msg pahomqtt.Message) {
|
||||||
|
if r.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fromTopic := msg.Topic()
|
||||||
|
if fromSource {
|
||||||
|
if !mqttTopicFilterMatches(rule.Topic, fromTopic) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !mqttTopicFilterMatches(mapMQTTForwardTopic(rule.Topic, rule.SourcePrefix, rule.TargetPrefix), fromTopic) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toTopic := fromTopic
|
||||||
|
forwardDirection := mqttForwardDirectionSourceToTarget
|
||||||
|
if fromSource {
|
||||||
|
toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix)
|
||||||
|
} else {
|
||||||
|
forwardDirection = mqttForwardDirectionTargetToSource
|
||||||
|
toTopic = mapMQTTForwardTopic(fromTopic, rule.TargetPrefix, rule.SourcePrefix)
|
||||||
|
}
|
||||||
|
if r.isSuppressed(forwardDirection, fromTopic, toTopic, msg.Payload(), rule.QoS, rule.Retain) {
|
||||||
|
r.incDropped()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := r.target
|
||||||
|
reverseDirection := mqttForwardDirectionTargetToSource
|
||||||
|
if !fromSource {
|
||||||
|
target = r.source
|
||||||
|
reverseDirection = mqttForwardDirectionSourceToTarget
|
||||||
|
}
|
||||||
|
r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain)
|
||||||
|
token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload())
|
||||||
|
if !token.WaitTimeout(2 * time.Second) {
|
||||||
|
r.setError("publish pending: " + toTopic)
|
||||||
|
r.incDropped()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := token.Error(); err != nil {
|
||||||
|
r.setError(fmt.Sprintf("publish %s failed: %v", toTopic, err))
|
||||||
|
r.incDropped()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.incForwarded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) setConnected(source bool, connected bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if source {
|
||||||
|
r.sourceConnected = connected
|
||||||
|
} else {
|
||||||
|
r.targetConnected = connected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) setError(message string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.lastError = message
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) incForwarded() {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.messagesForwarded++
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) incDropped() {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.messagesDropped++
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
|
||||||
|
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||||
|
now := time.Now()
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
expires, ok := r.loopCache[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if now.After(expires) {
|
||||||
|
delete(r.loopCache, key)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(r.loopCache, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mqttForwardRunner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
|
||||||
|
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||||
|
now := time.Now()
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.loopCache) >= mqttForwardLoopMaxEntries {
|
||||||
|
for existing, expires := range r.loopCache {
|
||||||
|
if now.After(expires) || len(r.loopCache) >= mqttForwardLoopMaxEntries {
|
||||||
|
delete(r.loopCache, existing)
|
||||||
|
}
|
||||||
|
if len(r.loopCache) < mqttForwardLoopMaxEntries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.loopCache[key] = now.Add(mqttForwardLoopTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwardLoopKey(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) string {
|
||||||
|
sum := sha256.Sum256(payload)
|
||||||
|
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%t\x00%s", direction, fromTopic, toTopic, qos, retain, hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapMQTTForwardTopic(topic, fromPrefix, toPrefix string) string {
|
||||||
|
fromPrefix = strings.Trim(fromPrefix, "/")
|
||||||
|
toPrefix = strings.Trim(toPrefix, "/")
|
||||||
|
if fromPrefix == "" {
|
||||||
|
return topic
|
||||||
|
}
|
||||||
|
if topic == fromPrefix {
|
||||||
|
return toPrefix
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(topic, fromPrefix+"/") {
|
||||||
|
if toPrefix == "" {
|
||||||
|
return strings.TrimPrefix(topic, fromPrefix+"/")
|
||||||
|
}
|
||||||
|
return toPrefix + strings.TrimPrefix(topic, fromPrefix)
|
||||||
|
}
|
||||||
|
return topic
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttTopicFilterMatches(filter, topic string) bool {
|
||||||
|
filterParts := strings.Split(filter, "/")
|
||||||
|
topicParts := strings.Split(topic, "/")
|
||||||
|
for i, filterPart := range filterParts {
|
||||||
|
if filterPart == "#" {
|
||||||
|
return i == len(filterParts)-1
|
||||||
|
}
|
||||||
|
if i >= len(topicParts) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filterPart == "+" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filterPart != topicParts[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(filterParts) == len(topicParts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mqttForwardDirectionSourceToTarget = "source_to_target"
|
||||||
|
mqttForwardDirectionBidirectional = "bidirectional"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists")
|
||||||
|
errMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
type mqttForwarderInput struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
SourceHost string
|
||||||
|
SourcePort int
|
||||||
|
SourceUsername string
|
||||||
|
SourcePassword *string
|
||||||
|
SourceClientID string
|
||||||
|
SourceTLS bool
|
||||||
|
TargetHost string
|
||||||
|
TargetPort int
|
||||||
|
TargetUsername string
|
||||||
|
TargetPassword *string
|
||||||
|
TargetClientID string
|
||||||
|
TargetTLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwardTopicInput struct {
|
||||||
|
Topic string
|
||||||
|
Enabled bool
|
||||||
|
Direction string
|
||||||
|
SourcePrefix string
|
||||||
|
TargetPrefix string
|
||||||
|
QoS int
|
||||||
|
Retain bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqttForwarderConfig struct {
|
||||||
|
Forwarder mqttForwarderRecord
|
||||||
|
Topics []mqttForwardTopicRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []mqttForwarderRecord
|
||||||
|
q := s.db.Model(&mqttForwarderRecord{}).
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountMQTTForwarders(opts listOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
return total, s.db.Model(&mqttForwarderRecord{}).Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetMQTTForwarder(id uint64) (*mqttForwarderRecord, error) {
|
||||||
|
var row mqttForwarderRecord
|
||||||
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRecord, error) {
|
||||||
|
row, err := mqttForwarderFromInput(input, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureMQTTForwarderNameUnique(0, row.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Create(row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttForwarderRecord, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, fmt.Errorf("mqtt forwarder id is required")
|
||||||
|
}
|
||||||
|
existing, err := s.GetMQTTForwarder(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row, err := mqttForwarderFromInput(input, existing)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureMQTTForwarderNameUnique(id, row.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"name": row.Name, "enabled": row.Enabled,
|
||||||
|
"source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername,
|
||||||
|
"source_password": row.SourcePassword, "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS,
|
||||||
|
"target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername,
|
||||||
|
"target_password": row.TargetPassword, "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&mqttForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetMQTTForwarder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteMQTTForwarder(id uint64) error {
|
||||||
|
if id == 0 {
|
||||||
|
return fmt.Errorf("mqtt forwarder id is required")
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("forwarder_id = ?", id).Delete(&mqttForwardTopicRecord{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result := tx.Where("id = ?", id).Delete(&mqttForwarderRecord{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]mqttForwardTopicRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []mqttForwardTopicRecord
|
||||||
|
q := s.db.Model(&mqttForwardTopicRecord{}).
|
||||||
|
Where("forwarder_id = ?", forwarderID).
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
return total, s.db.Model(&mqttForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetMQTTForwardTopic(id uint64) (*mqttForwardTopicRecord, error) {
|
||||||
|
var row mqttForwardTopicRecord
|
||||||
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
||||||
|
if _, err := s.GetMQTTForwarder(forwarderID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row, err := mqttForwardTopicFromInput(forwarderID, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureMQTTForwardTopicUnique(0, forwarderID, row.Topic); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Create(row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, fmt.Errorf("mqtt forward topic id is required")
|
||||||
|
}
|
||||||
|
existing, err := s.GetMQTTForwardTopic(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row, err := mqttForwardTopicFromInput(existing.ForwarderID, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.ensureMQTTForwardTopicUnique(id, existing.ForwarderID, row.Topic); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction,
|
||||||
|
"source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix,
|
||||||
|
"qos": row.QoS, "retain": row.Retain, "updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&mqttForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetMQTTForwardTopic(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteMQTTForwardTopic(id uint64) error {
|
||||||
|
result := s.db.Where("id = ?", id).Delete(&mqttForwardTopicRecord{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetMQTTForwarderConfig(id uint64) (*mqttForwarderConfig, error) {
|
||||||
|
forwarder, err := s.GetMQTTForwarder(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var topics []mqttForwardTopicRecord
|
||||||
|
if err := s.db.Where("forwarder_id = ? AND enabled = ?", id, true).Order("id ASC").Find(&topics).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mqttForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListEnabledMQTTForwarderConfigs() ([]mqttForwarderConfig, error) {
|
||||||
|
var forwarders []mqttForwarderRecord
|
||||||
|
if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&forwarders).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
configs := make([]mqttForwarderConfig, 0, len(forwarders))
|
||||||
|
for _, forwarder := range forwarders {
|
||||||
|
var topics []mqttForwardTopicRecord
|
||||||
|
if err := s.db.Where("forwarder_id = ? AND enabled = ?", forwarder.ID, true).Order("id ASC").Find(&topics).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(topics) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configs = append(configs, mqttForwarderConfig{Forwarder: forwarder, Topics: topics})
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
||||||
|
var existing mqttForwarderRecord
|
||||||
|
q := s.db.Where("name = ?", name)
|
||||||
|
if id != 0 {
|
||||||
|
q = q.Where("id <> ?", id)
|
||||||
|
}
|
||||||
|
err := q.Take(&existing).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errMQTTForwarderAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error {
|
||||||
|
var existing mqttForwardTopicRecord
|
||||||
|
q := s.db.Where("forwarder_id = ? AND topic = ?", forwarderID, topic)
|
||||||
|
if id != 0 {
|
||||||
|
q = q.Where("id <> ?", id)
|
||||||
|
}
|
||||||
|
err := q.Take(&existing).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errMQTTForwardTopicAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRecord) (*mqttForwarderRecord, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("mqtt forwarder name is required")
|
||||||
|
}
|
||||||
|
sourceHost := strings.TrimSpace(input.SourceHost)
|
||||||
|
if sourceHost == "" {
|
||||||
|
return nil, fmt.Errorf("source host is required")
|
||||||
|
}
|
||||||
|
if err := validateMQTTForwardPort(input.SourcePort, "source port"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetHost := strings.TrimSpace(input.TargetHost)
|
||||||
|
if targetHost == "" {
|
||||||
|
return nil, fmt.Errorf("target host is required")
|
||||||
|
}
|
||||||
|
if err := validateMQTTForwardPort(input.TargetPort, "target port"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row := &mqttForwarderRecord{
|
||||||
|
Name: name, Enabled: input.Enabled,
|
||||||
|
SourceHost: sourceHost, SourcePort: input.SourcePort, SourceUsername: strings.TrimSpace(input.SourceUsername), SourceClientID: strings.TrimSpace(input.SourceClientID), SourceTLS: input.SourceTLS,
|
||||||
|
TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS,
|
||||||
|
}
|
||||||
|
if input.SourcePassword != nil {
|
||||||
|
row.SourcePassword = *input.SourcePassword
|
||||||
|
} else if existing != nil {
|
||||||
|
row.SourcePassword = existing.SourcePassword
|
||||||
|
}
|
||||||
|
if input.TargetPassword != nil {
|
||||||
|
row.TargetPassword = *input.TargetPassword
|
||||||
|
} else if existing != nil {
|
||||||
|
row.TargetPassword = existing.TargetPassword
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
||||||
|
if forwarderID == 0 {
|
||||||
|
return nil, fmt.Errorf("mqtt forwarder id is required")
|
||||||
|
}
|
||||||
|
topic := strings.TrimSpace(input.Topic)
|
||||||
|
if err := validateMQTTTopicFilter(topic); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
direction, err := normalizeMQTTForwardDirection(input.Direction)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.QoS < 0 || input.QoS > 2 {
|
||||||
|
return nil, fmt.Errorf("qos must be 0, 1, or 2")
|
||||||
|
}
|
||||||
|
return &mqttForwardTopicRecord{
|
||||||
|
ForwarderID: forwarderID, Topic: topic, Enabled: input.Enabled, Direction: direction,
|
||||||
|
SourcePrefix: strings.Trim(strings.TrimSpace(input.SourcePrefix), "/"),
|
||||||
|
TargetPrefix: strings.Trim(strings.TrimSpace(input.TargetPrefix), "/"),
|
||||||
|
QoS: input.QoS, Retain: input.Retain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMQTTForwardPort(port int, label string) error {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
return fmt.Errorf("%s must be between 1 and 65535", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMQTTForwardDirection(direction string) (string, error) {
|
||||||
|
direction = strings.TrimSpace(direction)
|
||||||
|
if direction == "" {
|
||||||
|
direction = mqttForwardDirectionSourceToTarget
|
||||||
|
}
|
||||||
|
switch direction {
|
||||||
|
case mqttForwardDirectionSourceToTarget, mqttForwardDirectionBidirectional:
|
||||||
|
return direction, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid mqtt forward direction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMQTTTopicFilter(topic string) error {
|
||||||
|
if topic == "" {
|
||||||
|
return fmt.Errorf("topic is required")
|
||||||
|
}
|
||||||
|
parts := strings.Split(topic, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if strings.Contains(part, "#") {
|
||||||
|
if part != "#" || i != len(parts)-1 {
|
||||||
|
return fmt.Errorf("invalid topic filter: # must be the last level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(part, "+") && part != "+" {
|
||||||
|
return fmt.Errorf("invalid topic filter: + must occupy an entire level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache) *http.Server {
|
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader) *http.Server {
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking),
|
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +47,12 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
|||||||
return server.Serve(listener)
|
return server.Serve(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache) *gin.Engine {
|
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
registerAPIRoutes(api, store)
|
registerAPIRoutes(api, store)
|
||||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking)
|
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder)
|
||||||
registerStaticRoutes(r, cfg.StaticDir)
|
registerStaticRoutes(r, cfg.StaticDir)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache) {
|
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader) {
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -183,6 +183,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
protected := r.Group("")
|
protected := r.Group("")
|
||||||
protected.Use(requireAdmin(sessions))
|
protected.Use(requireAdmin(sessions))
|
||||||
registerAdminBlockingRoutes(protected, store, blocking)
|
registerAdminBlockingRoutes(protected, store, blocking)
|
||||||
|
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||||
protected.GET("/me", func(c *gin.Context) {
|
protected.GET("/me", func(c *gin.Context) {
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||||
|
|||||||
Reference in New Issue
Block a user