mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
updated ConfigPayload and heartbeat metadata to camelCase, removed heartbeat nodes and sensors metadata, standardized them in top level columns and fields.
updated docs to reflect changes
This commit is contained in:
+5
-8
@@ -158,7 +158,7 @@ Creates a new node entry and returns the generated node credentials.
|
||||
|
||||
#### GET /api/v1/nodes
|
||||
|
||||
Returns all registered nodes and their installed sensors. Each node includes current status, heartbeat metadata, and pending config state.
|
||||
Returns all registered nodes and their installed sensors. Each node includes current status and pending config state.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
@@ -546,18 +546,15 @@ These endpoints are used by sensor agents and require the node API key bearer to
|
||||
|
||||
### POST /api/v1/heartbeat
|
||||
|
||||
Reports a sensor heartbeat and updates runtime metadata.
|
||||
Reports a sensor heartbeat and updates its versioning state.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"sensorId": "alpha-node-01",
|
||||
"metadata": {
|
||||
"agent_version": "1.0.0",
|
||||
"contract_version": "1.0",
|
||||
"sensor_type": "tarpit",
|
||||
"HW_CONFIG_REV": "rev_abc123"
|
||||
}
|
||||
"agentVersion": "1.0.0",
|
||||
"contractVersion": "1.0",
|
||||
"configRev": "rev_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -51,20 +51,17 @@ This payload is emitted continuously (typically every 30 seconds) by sensors to
|
||||
```json
|
||||
{
|
||||
"sensorId": "alpha-node-01",
|
||||
"metadata": {
|
||||
"agent_version": "1.0.0",
|
||||
"contract_version": "1.0",
|
||||
"HW_CONFIG_REV": "rev_abc123"
|
||||
}
|
||||
"agentVersion": "1.0.0",
|
||||
"contractVersion": "1.0",
|
||||
"configRev": "rev_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### Field Definitions
|
||||
- `sensorId` (string, required): The unique identifier of the sensor.
|
||||
- `metadata` (object, required):
|
||||
- `agent_version` (string): The version of the language SDK or agent.
|
||||
- `contract_version` (string): The API contract version in use.
|
||||
- `HW_CONFIG_REV` (string): The current configuration revision the sensor is running. Used by the Hub to determine if the node has synchronized its desired state.
|
||||
- `agentVersion` (string, required): The version of the language SDK or agent.
|
||||
- `contractVersion` (string, required): The API contract version in use.
|
||||
- `configRev` (string, required): The current configuration revision the sensor is running. Used by the Hub to determine if the node has synchronized its desired state.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ The data access layer.
|
||||
- Operates in Write-Ahead Log (WAL) mode with connection pooling.
|
||||
- Contains **no** business logic.
|
||||
|
||||
#### Schema Migrations & Constraints
|
||||
- The Hub uses an embedded, automated migration system.
|
||||
- **CRITICAL:** Do NOT use the legacy `CREATE new_table -> DROP old_table -> RENAME` workaround for altering schemas if the table is referenced by foreign keys with `ON DELETE CASCADE`. Doing so while foreign keys are enforced will instantly trigger the cascade and wipe out dependent data (e.g., dropping `node_sensors` deletes all `events`).
|
||||
- **Solution:** HoneyWire uses a modern SQLite driver (3.35.0+) that natively supports `ALTER TABLE ... DROP COLUMN`. Always use native `ALTER TABLE` operations to guarantee schema mutations are non-destructive and isolated.
|
||||
|
||||
### 5. Read/Analytics Layer (`internal/projections`)
|
||||
A specialized CQRS (Command Query Responsibility Segregation) pattern used for heavy dashboard analytics.
|
||||
- Used for high-volume aggregations like Threat Velocity and Threat Severity Distributions.
|
||||
|
||||
@@ -58,18 +58,7 @@ func (h *ConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"hubEndpoint": cfg.HubEndpoint,
|
||||
"registryUrl": cfg.RegistryURL,
|
||||
"autoArchiveDays": cfg.AutoArchiveDays,
|
||||
"autoPurgeDays": cfg.AutoPurgeDays,
|
||||
"webhookType": cfg.WebhookType,
|
||||
"webhookUrl": cfg.WebhookURL,
|
||||
"webhookEvents": cfg.WebhookEvents,
|
||||
"siemAddress": cfg.SiemAddress,
|
||||
"siemProtocol": cfg.SiemProtocol,
|
||||
"whitelistedSources": cfg.WhitelistedSources,
|
||||
})
|
||||
SendJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -39,7 +39,7 @@ func (h *SensorHandler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Hand off to the Service layer
|
||||
if err := h.service.ProcessHeartbeat(nodeID, hb.SensorID, hb.Metadata); err != nil {
|
||||
if err := h.service.ProcessHeartbeat(nodeID, hb.SensorID, hb.AgentVersion, hb.ContractVersion, hb.ConfigRev); err != nil {
|
||||
if errors.Is(err, sensor.ErrSensorNotRegistered) {
|
||||
RespondError(w, "Sensor not registered on this node", http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -12,16 +12,16 @@ type SetupPayload struct {
|
||||
|
||||
// ConfigPayload represents the runtime configuration of the Hub
|
||||
type ConfigPayload struct {
|
||||
HubEndpoint string `json:"hubEndpoint"`
|
||||
RegistryURL string `json:"registryUrl"`
|
||||
AutoArchiveDays int `json:"autoArchiveDays"`
|
||||
AutoPurgeDays int `json:"autoPurgeDays"`
|
||||
WebhookURL string `json:"webhookUrl"`
|
||||
WebhookType string `json:"webhookType"`
|
||||
WebhookEvents []string `json:"webhookEvents"`
|
||||
SiemAddress string `json:"siemAddress"`
|
||||
SiemProtocol string `json:"siemProtocol"`
|
||||
WhitelistedSources string `json:"whitelistedSources"`
|
||||
HubEndpoint string `json:"hubEndpoint"`
|
||||
RegistryURL string `json:"registryUrl"`
|
||||
AutoArchiveDays int `json:"autoArchiveDays"`
|
||||
AutoPurgeDays int `json:"autoPurgeDays"`
|
||||
WebhookURL string `json:"webhookUrl"`
|
||||
WebhookType string `json:"webhookType"`
|
||||
WebhookEvents []string `json:"webhookEvents"`
|
||||
SiemAddress string `json:"siemAddress"`
|
||||
SiemProtocol string `json:"siemProtocol"`
|
||||
WhitelistedSources string `json:"whitelistedSources"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
@@ -42,8 +42,10 @@ type Event struct {
|
||||
|
||||
// Heartbeat represents a routine ping from a sensor
|
||||
type Heartbeat struct {
|
||||
SensorID string `json:"sensorId"`
|
||||
Metadata map[string]interface{} `json:"metadata"` // Contains HW_CONFIG_REV
|
||||
SensorID string `json:"sensorId"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
ConfigRev string `json:"configRev"`
|
||||
}
|
||||
|
||||
// Node represents a physical server managing sensors
|
||||
@@ -74,7 +76,6 @@ type NodeSensor struct {
|
||||
IsSilenced bool `json:"isSilenced"`
|
||||
Events24h int `json:"events24h"`
|
||||
EnvVars map[string]interface{} `json:"envVars"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
DeployedVersion string `json:"deployedVersion"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package sensor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
|
||||
// Store defines exactly what the Sensor service needs from internal/store
|
||||
type Store interface {
|
||||
ProcessHeartbeat(nodeID, sensorID, agentRevision, nowStr, metadataJSON string) (bool, error)
|
||||
ProcessHeartbeat(nodeID, sensorID, agentVersion, contractVersion, configRev, nowStr string) (bool, error)
|
||||
InsertHeartbeat(nodeID, sensorID, minuteBucket string) error
|
||||
MarkSensorOffline(nodeID, sensorID, offlineTime string) error
|
||||
UpdateSensorSilence(nodeID, sensorID string, silenceVal int) error
|
||||
@@ -38,24 +37,16 @@ func NewService(store Store, broadcaster Broadcaster) *Service {
|
||||
}
|
||||
|
||||
// ProcessHeartbeat handles the core logic of a sensor checking in
|
||||
func (s *Service) ProcessHeartbeat(nodeID, sensorID string, metadata map[string]interface{}) error {
|
||||
now := time.Now().UTC()
|
||||
nowStr := now.Format(time.RFC3339)
|
||||
minuteBucket := now.Truncate(time.Minute).Format(time.RFC3339)
|
||||
func (s *Service) ProcessHeartbeat(nodeID, sensorID, agentVersion, contractVersion, configRev string) error {
|
||||
nowStr := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
agentRevision := ""
|
||||
if rev, ok := metadata["HW_CONFIG_REV"].(string); ok {
|
||||
agentRevision = rev
|
||||
}
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
|
||||
justSynced, err := s.store.ProcessHeartbeat(nodeID, sensorID, agentRevision, nowStr, string(metadataJSON))
|
||||
justSynced, err := s.store.ProcessHeartbeat(nodeID, sensorID, agentVersion, contractVersion, configRev, nowStr)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Heartbeat DB update failed for node %s: %v", nodeID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
minuteBucket := time.Now().UTC().Truncate(time.Minute).Format(time.RFC3339)
|
||||
if err := s.store.InsertHeartbeat(nodeID, sensorID, minuteBucket); err != nil {
|
||||
if strings.Contains(err.Error(), "FOREIGN KEY") {
|
||||
return ErrSensorNotRegistered
|
||||
|
||||
@@ -36,7 +36,9 @@ CREATE TABLE IF NOT EXISTS node_sensors (
|
||||
sensor_id TEXT NOT NULL,
|
||||
custom_name TEXT NOT NULL,
|
||||
config_values TEXT NOT NULL DEFAULT '{}',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
agent_version TEXT NOT NULL DEFAULT '',
|
||||
contract_version TEXT NOT NULL DEFAULT '',
|
||||
config_rev TEXT NOT NULL DEFAULT '',
|
||||
deployed_version TEXT NOT NULL DEFAULT '',
|
||||
last_heartbeat TEXT,
|
||||
is_silenced INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -87,6 +89,18 @@ CREATE INDEX IF NOT EXISTS idx_sensors_node ON node_sensors(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeats_time ON sensor_heartbeats(time_bucket);
|
||||
`
|
||||
|
||||
// WARNING: Schema Migrations & Foreign Keys
|
||||
//
|
||||
// HoneyWire uses ON DELETE CASCADE for critical tables (e.g., deleting a node_sensor
|
||||
// automatically deletes all its events).
|
||||
//
|
||||
// When altering schemas, NEVER use the legacy SQLite workaround of creating a new table,
|
||||
// copying data, and dropping the old table. If foreign keys are enabled, dropping the
|
||||
// old table will instantly trigger the cascade and permanently delete all dependent rows.
|
||||
//
|
||||
// ALWAYS use native SQLite 3.35.0+ ALTER TABLE statements (e.g., ADD COLUMN or DROP COLUMN)
|
||||
// to mutate schemas safely without destroying dependent data.
|
||||
|
||||
var migrations = []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
|
||||
@@ -128,7 +128,7 @@ func (s *SQLiteStore) GetNodes() ([]models.Node, error) {
|
||||
// Fetch sensors
|
||||
for i := range nodes {
|
||||
rows, err := s.DB.Query(`
|
||||
SELECT sensor_id, custom_name, config_values, metadata, deployed_version, is_silenced, last_heartbeat
|
||||
SELECT sensor_id, custom_name, config_values, deployed_version, is_silenced, last_heartbeat
|
||||
FROM node_sensors WHERE node_id = ?`, nodes[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -137,11 +137,10 @@ func (s *SQLiteStore) GetNodes() ([]models.Node, error) {
|
||||
for rows.Next() {
|
||||
var ns models.NodeSensor
|
||||
var configStr string
|
||||
var metaStr string
|
||||
var silencedInt int
|
||||
var lastHb sql.NullString
|
||||
|
||||
if err := rows.Scan(&ns.Name, &ns.Display, &configStr, &metaStr, &ns.DeployedVersion, &silencedInt, &lastHb); err != nil {
|
||||
if err := rows.Scan(&ns.Name, &ns.Display, &configStr, &ns.DeployedVersion, &silencedInt, &lastHb); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,7 +196,7 @@ func (s *SQLiteStore) GetNodeDetails(nodeID string) (*models.Node, error) {
|
||||
|
||||
// Fetch Installed Sensors (Added metadata and last_heartbeat)
|
||||
rows, err := s.DB.Query(`
|
||||
SELECT sensor_id, custom_name, config_values, metadata, deployed_version, is_silenced, last_heartbeat
|
||||
SELECT sensor_id, custom_name, config_values, deployed_version, is_silenced, last_heartbeat
|
||||
FROM node_sensors WHERE node_id = ?`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -207,11 +206,10 @@ func (s *SQLiteStore) GetNodeDetails(nodeID string) (*models.Node, error) {
|
||||
for rows.Next() {
|
||||
var ns models.NodeSensor
|
||||
var configStr string
|
||||
var metaStr string
|
||||
var silencedInt int
|
||||
var lastHb sql.NullString
|
||||
|
||||
if err := rows.Scan(&ns.Name, &ns.Display, &configStr, &metaStr, &ns.DeployedVersion, &silencedInt, &lastHb); err != nil {
|
||||
if err := rows.Scan(&ns.Name, &ns.Display, &configStr, &ns.DeployedVersion, &silencedInt, &lastHb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -227,7 +225,6 @@ func (s *SQLiteStore) GetNodeDetails(nodeID string) (*models.Node, error) {
|
||||
ns.Status = deriveStatus(ns.LastHeartbeat)
|
||||
|
||||
json.Unmarshal([]byte(configStr), &ns.EnvVars)
|
||||
json.Unmarshal([]byte(metaStr), &ns.Metadata)
|
||||
|
||||
// Count events in the last 24h for this specific sensor
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
@@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,7 +19,7 @@ type HeartbeatData struct {
|
||||
}
|
||||
|
||||
// ProcessHeartbeat safely handles node updates and config reconciliation
|
||||
func (s *SQLiteStore) ProcessHeartbeat(nodeID, sensorID, agentRevision, nowStr, metadataStr string) (bool, error) {
|
||||
func (s *SQLiteStore) ProcessHeartbeat(nodeID, sensorID, agentVersion, contractVersion, configRev, nowStr string) (bool, error) {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -35,9 +34,9 @@ func (s *SQLiteStore) ProcessHeartbeat(nodeID, sensorID, agentRevision, nowStr,
|
||||
// Update the specific Sensor's heartbeat AND metadata
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE node_sensors
|
||||
SET metadata = ?, last_heartbeat = ?, updated_at = ?
|
||||
SET agent_version = ?, contract_version = ?, config_rev = ?, last_heartbeat = ?, updated_at = ?
|
||||
WHERE node_id = ? AND sensor_id = ?`,
|
||||
metadataStr, nowStr, nowStr, nodeID, sensorID); err != nil {
|
||||
agentVersion, contractVersion, configRev, nowStr, nowStr, nodeID, sensorID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -54,22 +53,17 @@ func (s *SQLiteStore) ProcessHeartbeat(nodeID, sensorID, agentRevision, nowStr,
|
||||
targetRevision = desiredRevision.String
|
||||
}
|
||||
|
||||
if targetRevision != "" && targetRevision == agentRevision {
|
||||
rows, err := tx.Query("SELECT metadata FROM node_sensors WHERE node_id = ?", nodeID)
|
||||
if targetRevision != "" && targetRevision == configRev {
|
||||
rows, err := tx.Query("SELECT config_rev FROM node_sensors WHERE node_id = ?", nodeID)
|
||||
if err == nil {
|
||||
allMatched := true
|
||||
for rows.Next() {
|
||||
var metaStr string
|
||||
if err := rows.Scan(&metaStr); err != nil {
|
||||
var rev string
|
||||
if err := rows.Scan(&rev); err != nil {
|
||||
allMatched = false
|
||||
break
|
||||
}
|
||||
var meta map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(metaStr), &meta); err != nil {
|
||||
allMatched = false
|
||||
break
|
||||
}
|
||||
if rev, ok := meta["HW_CONFIG_REV"].(string); !ok || rev != targetRevision {
|
||||
if rev != targetRevision {
|
||||
allMatched = false
|
||||
break
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface RawSensorPayload {
|
||||
status?: string
|
||||
isSilenced?: boolean
|
||||
envVars?: Record<string, any>
|
||||
metadata?: Record<string, any>
|
||||
deployedVersion?: string
|
||||
updateAvailable?: boolean
|
||||
lastHeartbeat?: string | null
|
||||
@@ -61,7 +60,6 @@ export interface InstalledSensor {
|
||||
status: string
|
||||
isSilenced: boolean
|
||||
envVars: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
deployedVersion?: string
|
||||
updateAvailable?: boolean
|
||||
lastHeartbeat: string | null
|
||||
@@ -206,8 +204,8 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
return {
|
||||
...sensor,
|
||||
display: manifest?.name || sensor.display || sensor.name || '',
|
||||
icon: manifest?.icon_svg || sensor.metadata?.icon || DEFAULT_SENSOR_ICON,
|
||||
osi: manifest?.osi_layer || sensor.metadata?.osi || 'Other',
|
||||
icon: manifest?.icon_svg || DEFAULT_SENSOR_ICON,
|
||||
osi: manifest?.osi_layer || 'Other',
|
||||
status: (node.status === 'down' && sensor.status === 'pending') ? 'down' : sensor.status
|
||||
}
|
||||
})
|
||||
@@ -373,7 +371,6 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
deployedVersion: raw.deployedVersion || '',
|
||||
updateAvailable: raw.updateAvailable || false,
|
||||
envVars: raw.envVars || {},
|
||||
metadata: raw.metadata || {},
|
||||
lastHeartbeat: raw.lastHeartbeat || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,12 +390,10 @@ func (s *Sensor) heartbeatLoop() {
|
||||
|
||||
func (s *Sensor) sendHeartbeat() (*http.Response, error) {
|
||||
payload := map[string]any{
|
||||
"sensorId": s.SensorID,
|
||||
"metadata": map[string]string{
|
||||
"agent_version": s.AgentVersion,
|
||||
"contract_version": s.hubContractVersion,
|
||||
"HW_CONFIG_REV": s.ConfigRev,
|
||||
},
|
||||
"sensorId": s.SensorID,
|
||||
"agentVersion": s.AgentVersion,
|
||||
"contractVersion": s.hubContractVersion,
|
||||
"configRev": s.ConfigRev,
|
||||
}
|
||||
return s.postToHub("/api/v1/heartbeat", payload)
|
||||
}
|
||||
|
||||
@@ -317,11 +317,9 @@ class HoneyWireSensor(ABC):
|
||||
|
||||
payload = {
|
||||
"sensorId": self.sensor_id,
|
||||
"metadata": {
|
||||
"agent_version": self.agent_version,
|
||||
"contract_version": self._hub_contract_version,
|
||||
"HW_CONFIG_REV": self.config_rev
|
||||
}
|
||||
"agentVersion": self.agent_version,
|
||||
"contractVersion": self._hub_contract_version,
|
||||
"configRev": self.config_rev
|
||||
}
|
||||
|
||||
resp, exc = None, None
|
||||
|
||||
Reference in New Issue
Block a user