diff --git a/admin_bot_routes.go b/admin_bot_routes.go index c08c8b2..0f3cc0f 100644 --- a/admin_bot_routes.go +++ b/admin_bot_routes.go @@ -10,12 +10,15 @@ import ( ) type botNodeRequest struct { - NodeNum *int64 `json:"node_num"` - LongName string `json:"long_name"` - ShortName string `json:"short_name"` - Enabled bool `json:"enabled"` - DefaultChannelID string `json:"default_channel_id"` - TopicPrefix string `json:"topic_prefix"` + NodeNum *int64 `json:"node_num"` + LongName string `json:"long_name"` + ShortName string `json:"short_name"` + Enabled bool `json:"enabled"` + DefaultChannelID string `json:"default_channel_id"` + TopicPrefix string `json:"topic_prefix"` + PSK string `json:"psk"` + NodeInfoBroadcastEnabled bool `json:"nodeinfo_broadcast_enabled"` + NodeInfoBroadcastIntervalSeconds int64 `json:"nodeinfo_broadcast_interval_seconds"` } type botSendMessageRequest struct { @@ -77,6 +80,42 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { } c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + r.POST("/bot/nodes/:id/keys/regenerate", func(c *gin.Context) { + id, ok := parseBotID(c, "invalid bot node id") + if !ok { + return + } + row, err := store.RegenerateBotNodeKeys(id) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"item": botNodeDTO(*row)}) + }) + r.POST("/bot/nodes/:id/nodeinfo", func(c *gin.Context) { + if sender == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"}) + return + } + id, ok := parseBotID(c, "invalid bot node id") + if !ok { + return + } + row, err := sender.PublishNodeInfoByID(c.Request.Context(), id) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"item": botNodeDTO(*row)}) + }) r.GET("/bot/messages", func(c *gin.Context) { opts, ok := parseBotMessageListOptions(c) if !ok { @@ -120,7 +159,7 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { } func botNodeInputFromRequest(req botNodeRequest) botNodeInput { - return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix} + return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds} } func parseBotID(c *gin.Context, message string) (uint64, bool) { @@ -166,7 +205,7 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord } func botNodeDTO(row botNodeRecord) gin.H { - return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} + return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } func botMessageDTO(row botMessageRecord) gin.H { diff --git a/bot_service.go b/bot_service.go index 1f060fa..7b340c4 100644 --- a/bot_service.go +++ b/bot_service.go @@ -28,6 +28,7 @@ type botSendTextRequest struct { type botTextSender interface { SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error) + PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) } type botService struct { @@ -40,6 +41,13 @@ func newBotService(store *store, server *mqtt.Server, key []byte) *botService { return &botService{store: store, server: server, key: key} } +func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) { + if s == nil || s.store == nil || s.server == nil { + return + } + go s.runNodeInfoBroadcaster(ctx) +} + func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) { if s == nil || s.store == nil { return nil, fmt.Errorf("bot service is not configured") @@ -80,22 +88,32 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe if err != nil { return nil, err } + psk := strings.TrimSpace(bot.PSK) + if psk == "" { + psk = botDefaultPSK + } + key, err := mqtpp.ExpandPSK(psk) + if err != nil { + return nil, err + } fromNodeNum := uint32(bot.NodeNum) raw, err := mqtpp.BuildTextMessageServiceEnvelope(mqtpp.TextMessageBuildOptions{ - FromNodeNum: fromNodeNum, - ToNodeNum: uint32(toNodeNum), - PacketID: packetID, - ChannelID: channelID, - GatewayID: bot.NodeID, - Text: text, - PSK: s.key, - Encrypt: true, - ViaMQTT: true, + PacketBuildOptions: mqtpp.PacketBuildOptions{ + FromNodeNum: fromNodeNum, + ToNodeNum: uint32(toNodeNum), + PacketID: packetID, + ChannelID: channelID, + GatewayID: bot.NodeID, + PSK: key, + Encrypt: true, + ViaMQTT: true, + }, + Text: text, }) if err != nil { return nil, err } - topic := strings.Trim(bot.TopicPrefix, "/") + "/" + channelID + "/" + bot.NodeID + topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID) row := &botMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, @@ -137,6 +155,124 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe return row, nil } +func (s *botService) runNodeInfoBroadcaster(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + s.broadcastDueNodeInfo(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.broadcastDueNodeInfo(ctx) + } + } +} + +func (s *botService) broadcastDueNodeInfo(ctx context.Context) { + rows, err := s.store.ListBotNodes(listOptions{Limit: 500}) + if err != nil { + printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()}) + return + } + now := time.Now() + for _, bot := range rows { + if ctx.Err() != nil { + return + } + if !bot.Enabled || !bot.NodeInfoBroadcastEnabled { + continue + } + interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second + if interval <= 0 { + interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second + } + if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval { + continue + } + if err := s.PublishNodeInfo(ctx, bot); err != nil { + printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "bot_id": bot.ID, "node_id": bot.NodeID, "error": err.Error()}) + } + } +} + +func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) { + if s == nil || s.store == nil { + return nil, fmt.Errorf("bot service is not configured") + } + bot, err := s.store.GetBotNode(id) + if err != nil { + return nil, err + } + if !bot.Enabled { + return nil, fmt.Errorf("bot node is disabled") + } + if err := s.PublishNodeInfo(ctx, *bot); err != nil { + return nil, err + } + updated, err := s.store.GetBotNode(id) + if err != nil { + return bot, nil + } + return updated, nil +} + +func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error { + if s == nil || s.server == nil { + return fmt.Errorf("mqtt server is not configured") + } + if strings.TrimSpace(bot.PublicKey) == "" && s.store != nil { + updated, err := s.store.RegenerateBotNodeKeys(bot.ID) + if err != nil { + return err + } + bot = *updated + } + psk := strings.TrimSpace(bot.PSK) + if psk == "" { + psk = botDefaultPSK + } + key, err := mqtpp.ExpandPSK(psk) + if err != nil { + return err + } + packetID, err := randomPacketID() + if err != nil { + return err + } + publicKey, err := decodeBotPublicKey(bot) + if err != nil { + return err + } + raw, err := mqtpp.BuildNodeInfoServiceEnvelope(mqtpp.NodeInfoBuildOptions{ + PacketBuildOptions: mqtpp.PacketBuildOptions{ + FromNodeNum: uint32(bot.NodeNum), + ToNodeNum: mqtpp.NodeNumBroadcast, + PacketID: packetID, + ChannelID: bot.DefaultChannelID, + GatewayID: bot.NodeID, + PSK: key, + Encrypt: true, + ViaMQTT: true, + }, + NodeID: bot.NodeID, + LongName: bot.LongName, + ShortName: bot.ShortName, + HWModel: 255, + Role: 0, + IsLicensed: false, + PublicKey: publicKey, + }) + if err != nil { + return err + } + topic := botMQTTTopic(bot.TopicPrefix, bot.DefaultChannelID, bot.NodeID) + if err := s.server.Publish(topic, raw, false, 0); err != nil { + return err + } + return s.store.UpdateBotNodeInfoBroadcastAt(bot.ID, time.Now()) +} + func normalizeBotMessageType(value string) (string, error) { switch strings.TrimSpace(value) { case "", botMessageTypeChannel: @@ -174,6 +310,17 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin return int64(nodeNum), &normalized, nil } +func botMQTTTopic(topicPrefix, channelID, nodeID string) string { + prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/") + if prefix == "" { + prefix = botDefaultTopicPrefix + } + if strings.HasSuffix(prefix, "/2/e") { + return prefix + "/" + channelID + "/" + nodeID + } + return prefix + "/2/e/" + channelID + "/" + nodeID +} + func randomPacketID() (uint32, error) { for i := 0; i < 8; i++ { var buf [4]byte diff --git a/bot_store.go b/bot_store.go index 67070d8..6e60421 100644 --- a/bot_store.go +++ b/bot_store.go @@ -1,7 +1,9 @@ package main import ( + "crypto/ecdh" "crypto/rand" + "encoding/base64" "encoding/binary" "errors" "fmt" @@ -15,23 +17,28 @@ import ( ) const ( - botDefaultTopicPrefix = "msh/2/e" - botMessageTypeChannel = "channel" - botMessageTypeDirect = "direct" - botMessageStatusPending = "pending" - botMessageStatusPublished = "published" - botMessageStatusFailed = "failed" + botDefaultTopicPrefix = "msh/CN" + botDefaultPSK = "AQ==" + botDefaultNodeInfoBroadcastSeconds = int64(3600) + botMessageTypeChannel = "channel" + botMessageTypeDirect = "direct" + botMessageStatusPending = "pending" + botMessageStatusPublished = "published" + botMessageStatusFailed = "failed" ) var errBotNodeAlreadyExists = errors.New("bot node already exists") type botNodeInput struct { - NodeNum *int64 - LongName string - ShortName string - Enabled bool - DefaultChannelID string - TopicPrefix string + NodeNum *int64 + LongName string + ShortName string + Enabled bool + DefaultChannelID string + TopicPrefix string + PSK string + NodeInfoBroadcastEnabled bool + NodeInfoBroadcastIntervalSeconds int64 } type botMessageListOptions struct { @@ -76,6 +83,9 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) { if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil { return nil, err } + if err := populateBotNodeKeys(row); err != nil { + return nil, err + } if err := s.db.Create(row).Error; err != nil { return nil, err } @@ -100,14 +110,17 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er return nil, err } updates := map[string]any{ - "node_id": row.NodeID, - "node_num": row.NodeNum, - "long_name": row.LongName, - "short_name": row.ShortName, - "enabled": row.Enabled, - "default_channel_id": row.DefaultChannelID, - "topic_prefix": row.TopicPrefix, - "updated_at": time.Now(), + "node_id": row.NodeID, + "node_num": row.NodeNum, + "long_name": row.LongName, + "short_name": row.ShortName, + "enabled": row.Enabled, + "default_channel_id": row.DefaultChannelID, + "topic_prefix": row.TopicPrefix, + "psk": row.PSK, + "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, + "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, + "updated_at": time.Now(), } if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err @@ -142,6 +155,35 @@ func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publis return nil } +func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error { + result := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) { + if id == 0 { + return nil, fmt.Errorf("bot node id is required") + } + row, err := s.GetBotNode(id) + if err != nil { + return nil, err + } + if err := populateBotNodeKeys(row); err != nil { + return nil, err + } + updates := map[string]any{"public_key": row.PublicKey, "private_key": row.PrivateKey, "updated_at": time.Now()} + if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + return s.GetBotNode(id) +} + func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, error) { opts.listOptions = normalizeListOptions(opts.listOptions) var rows []botMessageRecord @@ -182,6 +224,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err longName := strings.TrimSpace(input.LongName) shortName := strings.TrimSpace(input.ShortName) channelID := strings.TrimSpace(input.DefaultChannelID) + psk := strings.TrimSpace(input.PSK) + if psk == "" { + psk = botDefaultPSK + } + if _, err := mqtpp.ExpandPSK(psk); err != nil { + return nil, err + } topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/") if topicPrefix == "" { topicPrefix = botDefaultTopicPrefix @@ -201,6 +250,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err if channelID == "" { return nil, fmt.Errorf("default channel id is required") } + interval := input.NodeInfoBroadcastIntervalSeconds + if interval <= 0 { + interval = botDefaultNodeInfoBroadcastSeconds + } + if interval < 60 { + return nil, fmt.Errorf("nodeinfo broadcast interval must be at least 60 seconds") + } var nodeNum int64 if input.NodeNum == nil || *input.NodeNum == 0 { generated, err := s.generateBotNodeNum() @@ -214,7 +270,28 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err if err := validateBotNodeNum(nodeNum); err != nil { return nil, err } - return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix}, nil + return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval}, nil +} + +func populateBotNodeKeys(row *botNodeRecord) error { + privateKey, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return err + } + row.PrivateKey = base64.StdEncoding.EncodeToString(privateKey.Bytes()) + row.PublicKey = base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes()) + return nil +} + +func decodeBotPublicKey(row botNodeRecord) ([]byte, error) { + if strings.TrimSpace(row.PublicKey) == "" { + return nil, nil + } + key, err := base64.StdEncoding.DecodeString(row.PublicKey) + if err != nil { + return nil, fmt.Errorf("invalid bot public key: %w", err) + } + return key, nil } func validateBotNodeNum(nodeNum int64) error { diff --git a/db.go b/db.go index 6d63647..a6582bd 100644 --- a/db.go +++ b/db.go @@ -238,17 +238,23 @@ func (mqttForwardTopicRecord) TableName() string { } type botNodeRecord struct { - ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` - NodeID string `gorm:"column:node_id;not null;uniqueIndex"` - NodeNum int64 `gorm:"column:node_num;not null;uniqueIndex"` - LongName string `gorm:"column:long_name;not null"` - ShortName string `gorm:"column:short_name;not null"` - Enabled bool `gorm:"column:enabled;not null;index"` - DefaultChannelID string `gorm:"column:default_channel_id;not null;index"` - TopicPrefix string `gorm:"column:topic_prefix;not null"` - LastPacketID int64 `gorm:"column:last_packet_id;not null"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + NodeID string `gorm:"column:node_id;not null;uniqueIndex"` + NodeNum int64 `gorm:"column:node_num;not null;uniqueIndex"` + LongName string `gorm:"column:long_name;not null"` + ShortName string `gorm:"column:short_name;not null"` + Enabled bool `gorm:"column:enabled;not null;index"` + DefaultChannelID string `gorm:"column:default_channel_id;not null;index"` + TopicPrefix string `gorm:"column:topic_prefix;not null"` + PSK string `gorm:"column:psk;not null;size:64"` + PublicKey string `gorm:"column:public_key;type:text"` + PrivateKey string `gorm:"column:private_key;type:text"` + NodeInfoBroadcastEnabled bool `gorm:"column:nodeinfo_broadcast_enabled;not null;index"` + NodeInfoBroadcastIntervalSeconds int64 `gorm:"column:nodeinfo_broadcast_interval_seconds;not null"` + LastNodeInfoBroadcastAt *time.Time `gorm:"column:last_nodeinfo_broadcast_at;index"` + LastPacketID int64 `gorm:"column:last_packet_id;not null"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } func (botNodeRecord) TableName() string { @@ -510,6 +516,9 @@ func (s *store) migrate() error { return err } } + if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil { + return err + } if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil { return err } @@ -517,6 +526,47 @@ func (s *store) migrate() error { }) } +func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error { + if !migrator.HasTable(&botNodeRecord{}) { + return nil + } + if !migrator.HasColumn(&botNodeRecord{}, "PSK") { + if driver == databaseDriverSQLite { + if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN psk TEXT NOT NULL DEFAULT 'AQ=='").Error; err != nil { + return fmt.Errorf("migrate bot_nodes psk column: %w", err) + } + } else if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN psk VARCHAR(64) NOT NULL DEFAULT 'AQ=='").Error; err != nil { + return fmt.Errorf("migrate bot_nodes psk column: %w", err) + } + } + if !migrator.HasColumn(&botNodeRecord{}, "PublicKey") { + if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN public_key text").Error; err != nil { + return fmt.Errorf("migrate bot_nodes public_key column: %w", err) + } + } + if !migrator.HasColumn(&botNodeRecord{}, "PrivateKey") { + if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN private_key text").Error; err != nil { + return fmt.Errorf("migrate bot_nodes private_key column: %w", err) + } + } + if !migrator.HasColumn(&botNodeRecord{}, "NodeInfoBroadcastEnabled") { + if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN nodeinfo_broadcast_enabled numeric NOT NULL DEFAULT true").Error; err != nil { + return fmt.Errorf("migrate bot_nodes nodeinfo_broadcast_enabled column: %w", err) + } + } + if !migrator.HasColumn(&botNodeRecord{}, "NodeInfoBroadcastIntervalSeconds") { + if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN nodeinfo_broadcast_interval_seconds bigint NOT NULL DEFAULT 3600").Error; err != nil { + return fmt.Errorf("migrate bot_nodes nodeinfo_broadcast_interval_seconds column: %w", err) + } + } + if !migrator.HasColumn(&botNodeRecord{}, "LastNodeInfoBroadcastAt") { + if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN last_nodeinfo_broadcast_at datetime NULL").Error; err != nil { + return fmt.Errorf("migrate bot_nodes last_nodeinfo_broadcast_at column: %w", err) + } + } + return nil +} + func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error { if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") { if driver == databaseDriverSQLite { diff --git a/main.go b/main.go index 47be655..7ec5a40 100644 --- a/main.go +++ b/main.go @@ -226,6 +226,9 @@ func run(cfg *config) error { return err } botSender := newBotService(store, server, cfg.key) + botCtx, stopBotBroadcaster := context.WithCancel(context.Background()) + defer stopBotBroadcaster() + botSender.StartNodeInfoBroadcaster(botCtx) forwardManager := newMQTTForwardManager(store) if err := forwardManager.StartFromStore(); err != nil { server.Close() diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index a7d816f..d7cd979 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -357,6 +357,14 @@ export function deleteBotNode(id: number): Promise<{ status: string }> { return deleteJSON<{ status: string }>(`/api/admin/bot/nodes/${id}`) } +export function broadcastBotNodeInfo(id: number): Promise { + return postJSON(`/api/admin/bot/nodes/${id}/nodeinfo`) +} + +export function regenerateBotNodeKeys(id: number): Promise { + return postJSON(`/api/admin/bot/nodes/${id}/keys/regenerate`) +} + export function getBotMessages(botId = 0, limit = 100, offset = 0): Promise> { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) if (botId > 0) { diff --git a/meshmap_frontend/src/components/AdminBot.vue b/meshmap_frontend/src/components/AdminBot.vue index 60a7638..84c3d61 100644 --- a/meshmap_frontend/src/components/AdminBot.vue +++ b/meshmap_frontend/src/components/AdminBot.vue @@ -1,6 +1,6 @@