diff --git a/.gitignore b/.gitignore index bbe5918..4383534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +hub + # ========================= # OS & IDE Clutter # ========================= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdbdd31..f860a18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ To keep the ecosystem stable, all community-submitted sensors must adhere to a s ### The Golden Rules of Sensors 1. **Strict Sandboxing (Docker Only):** Every sensor must include a `Dockerfile`. We strongly enforce the use of minimal, hardened base images (like Distroless) running as non-root users (`UID 65532`) with all Linux kernel capabilities dropped (`cap_drop: ALL`). 2. **Zero Blast Radius:** Your sensor must not crash or overwhelm the main Hub. All communication must happen asynchronously via HTTP POST requests containing JSON. -3. **No Hardcoding:** All configurations (Ports, API keys, file paths, thresholds) must be handled dynamically via environment variables. +3. **No Hardcoding:** All configurations (Ports, Node keys, file paths, thresholds) must be handled dynamically via environment variables. ### How to Submit 1. **Use the Official Template:** Copy the [`Sensors/templates/go-sensor-template/`](./Sensors/templates/go-sensor-template/) folder and rename it to your sensor's name inside the [`Sensors/community/`](./Sensors/community/) directory. diff --git a/Docs/API.md b/Docs/API.md index 78942aa..3239b27 100644 --- a/Docs/API.md +++ b/Docs/API.md @@ -16,11 +16,10 @@ Protected by an HTTP-only session cookie named `hw_auth`. The cookie is issued b ### Agent (sensor) routes -Protected by a shared secret configured via the UI and stored in the Hub's SQLite database. Pass the key using either of these headers: +Protected by a unique node key generated during provisioning. Pass the key using the Authorization header: ```text -X-Api-Key: -Authorization: Bearer +Authorization: Bearer ``` --- @@ -42,9 +41,9 @@ All messages share the same envelope: | Type | Trigger | Payload | |---|---|---| | `NEW_EVENT` | A sensor reports an event | Full event object (see event schema below) | -| `NEW_SENSOR` | A sensor sends its first heartbeat | `{ "sensor_id": "..." }` | -| `DELETE_SENSOR` | A sensor is forgotten via the dashboard | `{ "sensor_id": "..." }` | -| `SILENCE_SENSOR` | A sensor's silence state is toggled | `{ "sensor_id": "...", "is_silenced": true }` | +| `NEW_SENSOR` | A sensor sends its first heartbeat | `{ "node_id": "...", "sensor_id": "...", "timestamp": "..." }` | +| `DELETE_SENSOR` | A sensor is forgotten via the dashboard | `{ "node_id": "...", "sensor_id": "..." }` | +| `SILENCE_SENSOR` | A sensor\'s silence state is toggled | `{ "node_id": "...", "sensor_id": "...", "is_silenced": true }` | **`NEW_EVENT` payload example:** @@ -55,6 +54,7 @@ All messages share the same envelope: "id": 42, "timestamp": "2026-04-12 18:30:05", "contract_version": "1.0", + "node_id": "node-12345678", "sensor_id": "core-dpi-engine", "event_trigger": "malformed_jwt_detected", "severity": "critical", @@ -207,6 +207,66 @@ Performs a full factory reset. Wipes all events, sensors, heartbeats, and config * 401 Unauthorized if the password does not match. --- + +## Node Provisioning + +### POST /api/v1/tokens/generate +Generates a one-time pairing token for the node deployment wizard. +**Requires Authentication:** Yes (UI Cookie) + +**Response** +```json +{ + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "expires_in": 900 +} +``` + +### POST /api/v1/wizard/link +Public endpoint called by the wizard script to exchange a token for node credentials. The token is deleted immediately after use. + +**Request** +```json +{ + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "alias": "production-db-node" +} +``` + +**Response** +```json +{ + "node_id": "b3f5c9e2", + "node_key": "long_hex_string_...", + "alias": "production-db-node", + "timestamp": "2026-04-12T18:30:05Z" +} +``` + +## Compose Generation + +### POST /api/v1/compose/generate +Generates a raw `docker-compose.yml` string for the target node based on selected sensors. +**Requires Authentication:** Yes (UI Cookie OR Node Key) + +**Request** +```json +{ + "node_id": "node-12345678", + "hub_endpoint": "https://hub.honeywire.local", + "hub_key": "hub_key_abc", + "sensors": [ + { + "sensor_id": "alpha-node-01", + "env_values": {}, + "manifest": { ... } + } + ] +} +``` + +**Response** — `application/x-yaml` payload containing the composed Docker instructions. + ## Sensor Fleet ### GET /api/v1/sensors @@ -217,6 +277,7 @@ Returns all registered sensors with live status and metadata. A sensor is consid ```json [ { + "node_id": "node-12345678", "sensor_id": "tarpit-01", "last_seen": "2026-04-07 15:25:11", "metadata": { @@ -238,12 +299,12 @@ Silences or un-silences a sensor. Silenced sensors continue logging events to th **Request** ```json -{ "is_silenced": true } +{ "node_id": "node-12345678", "is_silenced": true } ``` **Response** ```json -{ "status": "success", "sensor_id": "tarpit-01", "is_silenced": true } +{ "status": "success", "node_id": "node-12345678", "sensor_id": "tarpit-01", "is_silenced": true } ``` --- @@ -252,6 +313,12 @@ Silences or un-silences a sensor. Silenced sensors continue logging events to th Removes a sensor from active monitoring. Deletes the sensor record and its full heartbeat history. Events previously generated by this sensor are retained for auditing. +**Query parameters** + +| Parameter | Values | Default | Description | +|---|---|---|---| +| `node_id` | any node ID | — | The node owning the sensor | + **Response** ```json { "status": "success", "message": "Sensor forgotten successfully" } @@ -283,6 +350,7 @@ Returns heatmap data used by the Fleet Health dashboard. The response is an arra [ { "id": "tarpit-01", + "node_id": "node-12345678", "name": "tarpit-01", "isOnline": true, "blocks": [ @@ -319,6 +387,7 @@ Returns a list of events, newest first. |---|---|---|---| | `archived` | `true`, `false` | `false` | Whether to return archived or active events | | `sensor_id` | any sensor ID | — | Filter to a specific sensor | +| `node_id` | any node ID | — | Filter to a specific node | **Response** — array of event objects. See the event schema in the [WebSocket](#websocket) section for field reference. @@ -408,6 +477,7 @@ Called by sensors every 30 seconds to signal they are alive and update their met **Request** ```json { + "node_id": "node-12345678", "sensor_id": "alpha-node-01", "metadata": { "agent_version": "1.0.0", @@ -434,6 +504,7 @@ If the Hub is armed and the reporting sensor is not silenced, a push notificatio ```json { "contract_version": "1.0", + "node_id": "node-12345678", "sensor_id": "core-dpi-engine", "event_trigger": "malformed_jwt_detected", "severity": "critical", diff --git a/Hub/internal/api/auth.go b/Hub/internal/api/auth.go index 3f2fc95..213ccb6 100644 --- a/Hub/internal/api/auth.go +++ b/Hub/internal/api/auth.go @@ -6,8 +6,8 @@ import ( "log" "net/http" "strings" - "sync" "time" + "github.com/honeywire/hub/internal/auth" "golang.org/x/crypto/bcrypt" ) @@ -18,23 +18,18 @@ type loginState struct { lockedUntil time.Time } -var ( - authTracker = make(map[string]*loginState) - authMutex sync.Mutex -) - // Background routine to prevent memory leaks from abandoned IPs func (h *Handler) cleanupAuthTracker() { for { time.Sleep(5 * time.Minute) - authMutex.Lock() + h.authMutex.Lock() now := time.Now() - for ip, state := range authTracker { + for ip, state := range h.authTracker { if now.After(state.lockedUntil) { - delete(authTracker, ip) + delete(h.authTracker, ip) } } - authMutex.Unlock() + h.authMutex.Unlock() } } @@ -42,26 +37,26 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { ip := h.getRealIP(r) // Rate Limiter Pre-Check - authMutex.Lock() - if state, exists := authTracker[ip]; exists { + h.authMutex.Lock() + if state, exists := h.authTracker[ip]; exists { if state.attempts >= 10 { if time.Now().Before(state.lockedUntil) { - authMutex.Unlock() - http.Error(w, "Too many failed attempts. Try again later.", http.StatusTooManyRequests) + h.authMutex.Unlock() + RespondError(w, "Too many failed attempts. Try again later.", http.StatusTooManyRequests) return } // Lockout expired, wipe the slate clean - delete(authTracker, ip) + delete(h.authTracker, ip) } } - authMutex.Unlock() + h.authMutex.Unlock() // Parse Request var req struct { Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) + RespondError(w, "Invalid request", http.StatusBadRequest) return } @@ -73,8 +68,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { isAuthorized = subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.Cfg.DashboardPassword)) == 1 } else { // Layer B: Runtime Database Hash (Setup UI) - var dbHash string - err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='admin_hash'").Scan(&dbHash) + dbHash, err := h.Store.GetConfigValue("admin_hash") if err == nil { err = bcrypt.CompareHashAndPassword([]byte(dbHash), []byte(req.Password)) isAuthorized = (err == nil) @@ -83,13 +77,13 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { if isAuthorized { // Clear failed attempts for this IP on successful login - authMutex.Lock() - delete(authTracker, ip) - authMutex.Unlock() + h.authMutex.Lock() + delete(h.authTracker, ip) + h.authMutex.Unlock() token, err := h.SessionStore.Create() if err != nil { - http.Error(w, "Session creation failed", http.StatusInternalServerError) + RespondError(w, "Session creation failed", http.StatusInternalServerError) return } @@ -109,19 +103,19 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { } // Handle Failure & Increment Rate Limiter - authMutex.Lock() - if _, exists := authTracker[ip]; !exists { - authTracker[ip] = &loginState{} + h.authMutex.Lock() + if _, exists := h.authTracker[ip]; !exists { + h.authTracker[ip] = &loginState{} } - authTracker[ip].attempts++ - - if authTracker[ip].attempts >= 10 { - authTracker[ip].lockedUntil = time.Now().Add(15 * time.Minute) + h.authTracker[ip].attempts++ + + if h.authTracker[ip].attempts >= 10 { + h.authTracker[ip].lockedUntil = time.Now().Add(15 * time.Minute) log.Printf("[!] AUDIT: IP %s locked out of dashboard for 15 minutes due to brute-force", ip) } - authMutex.Unlock() + h.authMutex.Unlock() - http.Error(w, "Invalid Password", http.StatusUnauthorized) + RespondError(w, "Invalid Password", http.StatusUnauthorized) } func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { @@ -143,12 +137,11 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { // --- Per-Node Authentication --- // nodeAuthCache stores per-node keys in memory for fast validation // Key: node_id, Value: node_key (64-char hex string) -var nodeAuthCache sync.Map // validateNodeAuth checks if a request is authenticated for a given node // Extracts Bearer token from Authorization header and compares against node_key func (h *Handler) validateNodeAuth(r *http.Request, nodeID string) bool { - authHeader := r.Header.Get("Authorization") + authHeader := r.Header.Get("Authorization") if authHeader == "" { return false } @@ -161,19 +154,18 @@ func (h *Handler) validateNodeAuth(r *http.Request, nodeID string) bool { token := parts[1] // Check cache first - if cachedKey, ok := nodeAuthCache.Load(nodeID); ok { + if cachedKey, ok := h.nodeAuthCache.Load(nodeID); ok { return subtle.ConstantTimeCompare([]byte(token), []byte(cachedKey.(string))) == 1 } // Cache miss - query database - var nodeKey string - err := h.Store.DB.QueryRow("SELECT node_key FROM nodes WHERE node_id = ?", nodeID).Scan(&nodeKey) + nodeKey, err := h.Store.GetNodeKey(nodeID) if err != nil { return false } // Cache the key for future requests - nodeAuthCache.Store(nodeID, nodeKey) + h.nodeAuthCache.Store(nodeID, nodeKey) // Validate token return subtle.ConstantTimeCompare([]byte(token), []byte(nodeKey)) == 1 @@ -181,6 +173,6 @@ func (h *Handler) validateNodeAuth(r *http.Request, nodeID string) bool { // invalidateNodeCache removes a node from the auth cache // Called after node deletion or key rotation -func invalidateNodeCache(nodeID string) { - nodeAuthCache.Delete(nodeID) -} \ No newline at end of file +func (h *Handler) invalidateNodeCache(nodeID string) { + h.nodeAuthCache.Delete(nodeID) +} diff --git a/Hub/internal/api/config.go b/Hub/internal/api/config.go index 22c6ea2..8c0f010 100644 --- a/Hub/internal/api/config.go +++ b/Hub/internal/api/config.go @@ -12,7 +12,6 @@ import ( "github.com/honeywire/hub/internal/models" "github.com/honeywire/hub/internal/notify" "github.com/honeywire/hub/internal/siem" - "github.com/honeywire/hub/internal/store" "golang.org/x/crypto/bcrypt" ) @@ -21,65 +20,53 @@ func (h *Handler) GetSetupStatus(w http.ResponseWriter, r *http.Request) { SendJSON(w, http.StatusOK, map[string]bool{"requires_setup": false}) return } - var isSetup string - err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_setup'").Scan(&isSetup) + isSetup, err := h.Store.GetConfigValue("is_setup") SendJSON(w, http.StatusOK, map[string]bool{"requires_setup": err != nil || isSetup != "true"}) } func (h *Handler) CompleteSetup(w http.ResponseWriter, r *http.Request) { if h.Cfg.DashboardPassword != "" { - http.Error(w, "Setup is locked by environment configuration.", http.StatusForbidden) + RespondError(w, "Setup is locked by environment configuration.", http.StatusForbidden) return } - var isSetup string - err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_setup'").Scan(&isSetup) + isSetup, err := h.Store.GetConfigValue("is_setup") if err == nil && isSetup == "true" { - http.Error(w, "Setup has already been completed. Unauthorized.", http.StatusForbidden) + RespondError(w, "Setup has already been completed. Unauthorized.", http.StatusForbidden) return } var req models.SetupPayload if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid payload", http.StatusBadRequest) + RespondError(w, "Invalid payload", http.StatusBadRequest) return } if req.HubEndpoint == "" || req.HubKey == "" || req.Password == "" { - http.Error(w, "Invalid setup parameters. Missing required fields.", http.StatusBadRequest) + RespondError(w, "Invalid setup parameters. Missing required fields.", http.StatusBadRequest) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to secure password", http.StatusInternalServerError) + RespondError(w, "Failed to secure password", http.StatusInternalServerError) return } - tx, _ := h.Store.DB.Begin() - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('admin_hash', ?)", string(hash)) - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('hub_endpoint', ?)", req.HubEndpoint) - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('hub_key', ?)", req.HubKey) - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('is_setup', 'true')") - tx.Commit() + if err := h.Store.CompleteSetup(string(hash), req.HubEndpoint, req.HubKey); err != nil { + RespondError(w, "Failed to complete setup", http.StatusInternalServerError) + return + } SendJSON(w, http.StatusOK, map[string]string{"status": "success"}) } func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { - rows, err := h.Store.DB.Query("SELECT key, value FROM config") + kv, err := h.Store.GetAllConfig() if err != nil { - http.Error(w, "Failed to fetch config", http.StatusInternalServerError) + RespondError(w, "Failed to fetch config", http.StatusInternalServerError) return } - defer rows.Close() - - kv := make(map[string]string) - for rows.Next() { - var k, v string - rows.Scan(&k, &v) - kv[k] = v - } archiveDays, _ := strconv.Atoi(kv["auto_archive_days"]) purgeDays, _ := strconv.Atoi(kv["auto_purge_days"]) @@ -107,72 +94,33 @@ func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) { var req map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON payload", http.StatusBadRequest) + RespondError(w, "Invalid JSON payload", http.StatusBadRequest) return } - tx, err := h.Store.DB.Begin() - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) + if err := h.Store.UpdateConfigBatch(req); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) return } - defer tx.Rollback() - - validWebhooks := map[string]bool{"ntfy": true, "gotify": true, "discord": true, "slack": true} - validProtocols := map[string]bool{"tcp": true, "udp": true} - - for key, val := range req { - switch key { - case "hub_endpoint", "hub_key", "webhook_url", "siem_address": - if strVal, ok := val.(string); ok { - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strVal) - } - case "webhook_type": - if strVal, ok := val.(string); ok && validWebhooks[strings.ToLower(strVal)] { - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal)) - } - case "siem_protocol": - if strVal, ok := val.(string); ok && validProtocols[strings.ToLower(strVal)] { - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal)) - } - case "auto_archive_days", "auto_purge_days": - if numVal, ok := val.(float64); ok { - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strconv.Itoa(int(numVal))) - } - case "webhook_events": - if arrVal, ok := val.([]interface{}); ok { - var events []string - for _, v := range arrVal { - if str, ok := v.(string); ok { - events = append(events, str) - } - } - tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.Join(events, ",")) - } - } - } - - tx.Commit() // Hot reload if related settings were changed var siemAddress, siemProtocol string if val, ok := req["siem_address"].(string); ok { siemAddress = val } else { - h.Store.DB.QueryRow("SELECT value FROM config WHERE key='siem_address'").Scan(&siemAddress) + siemAddress, _ = h.Store.GetConfigValue("siem_address") } if val, ok := req["siem_protocol"].(string); ok { siemProtocol = val } else { - h.Store.DB.QueryRow("SELECT value FROM config WHERE key='siem_protocol'").Scan(&siemProtocol) + siemProtocol, _ = h.Store.GetConfigValue("siem_protocol") } siem.UpdateConfig(siemAddress, siemProtocol) - var isArmed, webhookType, webhookURL, webhookEvents string - h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_armed'").Scan(&isArmed) - h.Store.DB.QueryRow("SELECT value FROM config WHERE key='webhook_type'").Scan(&webhookType) - h.Store.DB.QueryRow("SELECT value FROM config WHERE key='webhook_url'").Scan(&webhookURL) - h.Store.DB.QueryRow("SELECT value FROM config WHERE key='webhook_events'").Scan(&webhookEvents) + isArmed, _ := h.Store.GetConfigValue("is_armed") + webhookType, _ := h.Store.GetConfigValue("webhook_type") + webhookURL, _ := h.Store.GetConfigValue("webhook_url") + webhookEvents, _ := h.Store.GetConfigValue("webhook_events") notify.UpdateConfig(isArmed == "true", webhookType, webhookURL, webhookEvents) SendJSON(w, http.StatusOK, map[string]string{"status": "success"}) @@ -180,7 +128,7 @@ func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { if h.Cfg.DashboardPassword != "" { - http.Error(w, "Password is locked by environment configuration.", http.StatusForbidden) + RespondError(w, "Password is locked by environment configuration.", http.StatusForbidden) return } @@ -189,29 +137,31 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { NewPassword string `json:"new_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid payload", http.StatusBadRequest) + RespondError(w, "Invalid payload", http.StatusBadRequest) return } - var dbHash string - err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='admin_hash'").Scan(&dbHash) + dbHash, err := h.Store.GetConfigValue("admin_hash") if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) + RespondError(w, "Database error", http.StatusInternalServerError) return } if err := bcrypt.CompareHashAndPassword([]byte(dbHash), []byte(req.CurrentPassword)); err != nil { - http.Error(w, "Incorrect current password", http.StatusUnauthorized) + RespondError(w, "Incorrect current password", http.StatusUnauthorized) return } newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to hash new password", http.StatusInternalServerError) + RespondError(w, "Failed to hash new password", http.StatusInternalServerError) return } - h.Store.DB.Exec("UPDATE config SET value = ? WHERE key = 'admin_hash'", string(newHash)) + if err := h.Store.UpdateConfigValue("admin_hash", string(newHash)); err != nil { + RespondError(w, "Failed to update password", http.StatusInternalServerError) + return + } h.SessionStore.ClearAllSessions() http.SetCookie(w, &http.Cookie{ @@ -231,21 +181,20 @@ func (h *Handler) FactoryReset(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid payload", http.StatusBadRequest) + RespondError(w, "Invalid payload", http.StatusBadRequest) return } // Retrieve the master password hash from the database - var dbHash string - err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='admin_hash'").Scan(&dbHash) + dbHash, err := h.Store.GetConfigValue("admin_hash") if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) + RespondError(w, "Database error", http.StatusInternalServerError) return } // Verify the password if err := bcrypt.CompareHashAndPassword([]byte(dbHash), []byte(req.Password)); err != nil { - http.Error(w, "Incorrect password", http.StatusUnauthorized) + RespondError(w, "Incorrect password", http.StatusUnauthorized) return } @@ -253,14 +202,10 @@ func (h *Handler) FactoryReset(w http.ResponseWriter, r *http.Request) { ip := h.getRealIP(r) log.Printf("[!] AUDIT: IP %s initiated a full Factory Reset. Wiping database.", ip) - tx, _ := h.Store.DB.Begin() - tx.Exec("DELETE FROM events") - tx.Exec("DELETE FROM sensors") - tx.Exec("DELETE FROM sensor_heartbeats") - tx.Exec("DELETE FROM config") - tx.Commit() - - store.InitializeDefaultConfig(h.Store.DB) + if err := h.Store.FactoryReset(); err != nil { + RespondError(w, "Failed to factory reset", http.StatusInternalServerError) + return + } // Terminate all sessions and clear the UI cookie h.SessionStore.ClearAllSessions() @@ -276,13 +221,11 @@ func (h *Handler) FactoryReset(w http.ResponseWriter, r *http.Request) { } func (h *Handler) GetSystemState(w http.ResponseWriter, r *http.Request) { - var isArmedStr string - // Read from the new config table - err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key = 'is_armed'").Scan(&isArmedStr) - if err != nil { - isArmedStr = "true" // Default fallback - } - SendJSON(w, http.StatusOK, map[string]bool{"is_armed": isArmedStr == "true"}) + isArmedStr, err := h.Store.GetConfigValue("is_armed") + if err != nil { + isArmedStr = "true" // Default fallback + } + SendJSON(w, http.StatusOK, map[string]bool{"is_armed": isArmedStr == "true"}) } func (h *Handler) SetSystemState(w http.ResponseWriter, r *http.Request) { @@ -290,7 +233,7 @@ func (h *Handler) SetSystemState(w http.ResponseWriter, r *http.Request) { IsArmed bool `json:"is_armed"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + RespondError(w, err.Error(), http.StatusBadRequest) return } @@ -298,10 +241,12 @@ func (h *Handler) SetSystemState(w http.ResponseWriter, r *http.Request) { if req.IsArmed { val = "true" } - h.Store.DB.Exec(` - INSERT INTO config (key, value) VALUES ('is_armed', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - val) + + if err := h.Store.UpdateConfigValue("is_armed", val); err != nil { + RespondError(w, "Failed to update state", http.StatusInternalServerError) + return + } + notify.UpdateIsArmed(req.IsArmed) SendJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "is_armed": req.IsArmed}) } diff --git a/Hub/internal/api/events.go b/Hub/internal/api/events.go index ba9d4c6..391912c 100644 --- a/Hub/internal/api/events.go +++ b/Hub/internal/api/events.go @@ -1,7 +1,6 @@ package api import ( - "database/sql" "encoding/json" "fmt" "log" @@ -18,19 +17,19 @@ import ( func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) { var e models.Event if err := json.NewDecoder(r.Body).Decode(&e); err != nil { - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + RespondError(w, "Invalid JSON body", http.StatusBadRequest) return } // Validate required fields if e.NodeID == "" || e.SensorID == "" { - http.Error(w, "node_id and sensor_id are required", http.StatusBadRequest) + RespondError(w, "node_id and sensor_id are required", http.StatusBadRequest) return } // Per-node authentication if !h.validateNodeAuth(r, e.NodeID) { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + RespondError(w, "Unauthorized", http.StatusUnauthorized) return } @@ -38,7 +37,7 @@ func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) { hubMajor := strings.Split(h.Cfg.Version, ".")[0] agentMajor := strings.Split(e.ContractVersion, ".")[0] if agentMajor == "" || hubMajor != agentMajor { - http.Error(w, "Upgrade Required", http.StatusUpgradeRequired) + RespondError(w, "Upgrade Required", http.StatusUpgradeRequired) return } @@ -46,41 +45,28 @@ func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) { detailsJSON, _ := json.Marshal(e.Details) // Insert event with composite key reference (node_id, sensor_id) - result, err := h.Store.DB.Exec(` - INSERT INTO events (node_id, sensor_id, timestamp, contract_version, event_trigger, severity, source, target, details, is_read, is_archived) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`, - e.NodeID, e.SensorID, nowStr, e.ContractVersion, e.EventTrigger, e.Severity, e.Source, e.Target, string(detailsJSON), - ) + lastInsertID, err := h.Store.InsertEvent(&e, nowStr, string(detailsJSON)) if err != nil { log.Printf("[ERROR] Failed to insert event for node %s/sensor %s: %v", e.NodeID, e.SensorID, err) - http.Error(w, "Database error", http.StatusInternalServerError) + RespondError(w, "Database error", http.StatusInternalServerError) return } - lastInsertID, _ := result.LastInsertId() - e.ID = int(lastInsertID) + e.ID = lastInsertID e.Timestamp = nowStr // Update node last_seen - _, err = h.Store.DB.Exec(` - UPDATE nodes SET last_seen = ? WHERE node_id = ?`, - nowStr, e.NodeID, - ) - if err != nil { + if err := h.Store.UpdateNodeLastSeen(e.NodeID, nowStr); err != nil { log.Printf("[WARNING] Failed to update node %s last_seen: %v", e.NodeID, err) } // Check if sensor is silenced - var isSilencedInt int - err = h.Store.DB.QueryRow( - "SELECT is_silenced FROM sensors WHERE node_id = ? AND sensor_id = ?", - e.NodeID, e.SensorID, - ).Scan(&isSilencedInt) - if err != nil && err != sql.ErrNoRows { + isSilenced, err := h.Store.IsSensorSilenced(e.NodeID, e.SensorID) + if err != nil { log.Printf("[WARNING] Failed to check silence status for node %s/sensor %s: %v", e.NodeID, e.SensorID, err) } - if isSilencedInt == 0 { + if !isSilenced { title := fmt.Sprintf("Intrusion Alert: %s", e.SensorID) message := fmt.Sprintf("Trigger: %s\nSource: %s\nTarget: %s", e.EventTrigger, e.Source, e.Target) notify.Dispatch(title, message, e.Severity) @@ -104,65 +90,20 @@ func (h *Handler) GetEvents(w http.ResponseWriter, r *http.Request) { isArchived = 1 } - query := "SELECT id, timestamp, contract_version, sensor_id, node_id, event_trigger, severity, source, target, details, is_read, is_archived FROM events WHERE is_archived = ?" - args := []interface{}{isArchived} + nodeID := r.URL.Query().Get("node_id") + sensorID := r.URL.Query().Get("sensor_id") - // Apply Node Filter if present - if nodeID := r.URL.Query().Get("node_id"); nodeID != "" { - query += " AND node_id = ?" - args = append(args, nodeID) - } - - // Apply Sensor Filter if present (Works WITH node_id now!) - if sensorID := r.URL.Query().Get("sensor_id"); sensorID != "" { - query += " AND sensor_id = ?" - args = append(args, sensorID) - } - - query += " ORDER BY id DESC" - - rows, err := h.Store.DB.Query(query, args...) + events, err := h.Store.GetEvents(isArchived, nodeID, sensorID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + RespondError(w, err.Error(), http.StatusInternalServerError) return } - defer rows.Close() - - var events []models.Event - for rows.Next() { - var e models.Event - var detailsStr string - var isReadInt, isArchivedInt int - var dbNodeID *string // Use pointer to handle SQL NULL safely - - if err := rows.Scan( - &e.ID, &e.Timestamp, &e.ContractVersion, &e.SensorID, &dbNodeID, - &e.EventTrigger, &e.Severity, &e.Source, &e.Target, - &detailsStr, &isReadInt, &isArchivedInt, - ); err != nil { - continue - } - - if dbNodeID != nil { - e.NodeID = *dbNodeID - } - - e.IsRead = isReadInt == 1 - e.IsArchived = isArchivedInt == 1 - json.Unmarshal([]byte(detailsStr), &e.Details) - events = append(events, e) - } - - if events == nil { - events = []models.Event{} - } SendJSON(w, http.StatusOK, events) } func (h *Handler) GetUnreadCount(w http.ResponseWriter, r *http.Request) { - var count int - err := h.Store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE is_read = 0 AND is_archived = 0").Scan(&count) + count, err := h.Store.GetUnreadEventCount() if err != nil { count = 0 } @@ -171,23 +112,35 @@ func (h *Handler) GetUnreadCount(w http.ResponseWriter, r *http.Request) { func (h *Handler) MarkSingleEventRead(w http.ResponseWriter, r *http.Request) { eventID := chi.URLParam(r, "event_id") - h.Store.DB.Exec("UPDATE events SET is_read = 1 WHERE id = ?", eventID) + if err := h.Store.MarkEventRead(eventID); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) + return + } SendJSON(w, http.StatusOK, map[string]string{"status": "success"}) } func (h *Handler) MarkEventsRead(w http.ResponseWriter, r *http.Request) { - h.Store.DB.Exec("UPDATE events SET is_read = 1 WHERE is_read = 0") + if err := h.Store.MarkAllEventsRead(); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) + return + } SendJSON(w, http.StatusOK, map[string]string{"status": "success"}) } func (h *Handler) ArchiveEvent(w http.ResponseWriter, r *http.Request) { eventID := chi.URLParam(r, "event_id") - h.Store.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE id = ?", eventID) + if err := h.Store.ArchiveEvent(eventID); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) + return + } SendJSON(w, http.StatusOK, map[string]string{"status": "success"}) } func (h *Handler) ArchiveAll(w http.ResponseWriter, r *http.Request) { - h.Store.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE is_archived = 0") + if err := h.Store.ArchiveAllEvents(); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) + return + } SendJSON(w, http.StatusOK, map[string]string{"status": "success"}) } @@ -195,8 +148,7 @@ func (h *Handler) ClearEvents(w http.ResponseWriter, r *http.Request) { dryrun := r.URL.Query().Get("dryrun") == "true" if dryrun { - var count int - h.Store.DB.QueryRow("SELECT COUNT(*) FROM events").Scan(&count) + count, _ := h.Store.GetEventCount() SendJSON(w, http.StatusOK, map[string]interface{}{ "status": "success", "dryrun": true, @@ -208,7 +160,11 @@ func (h *Handler) ClearEvents(w http.ResponseWriter, r *http.Request) { ip := h.getRealIP(r) log.Printf("[!] AUDIT: Database purged by IP %s", ip) - h.Store.DB.Exec("DELETE FROM events") + if err := h.Store.ClearAllEvents(); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) + return + } + SendJSON(w, http.StatusOK, map[string]interface{}{ "status": "success", "dryrun": false, diff --git a/Hub/internal/api/handlers.go b/Hub/internal/api/handlers.go index 8e28435..beb9ba6 100644 --- a/Hub/internal/api/handlers.go +++ b/Hub/internal/api/handlers.go @@ -21,20 +21,24 @@ var upgrader = websocket.Upgrader{ } type Handler struct { - Store *store.Store + Store *store.SQLiteStore Cfg *config.Config SessionStore *auth.SessionStore - clients map[*websocket.Conn]bool - clientsMu sync.Mutex + clients map[*websocket.Conn]bool + clientsMu sync.Mutex + authTracker map[string]*loginState + authMutex sync.Mutex + nodeAuthCache sync.Map } -func NewHandler(s *store.Store, cfg *config.Config, sess *auth.SessionStore) *Handler { +func NewHandler(s *store.SQLiteStore, cfg *config.Config, sess *auth.SessionStore) *Handler { h := &Handler{ Store: s, Cfg: cfg, SessionStore: sess, clients: make(map[*websocket.Conn]bool), + authTracker: make(map[string]*loginState), } go h.cleanupAuthTracker() return h @@ -42,6 +46,10 @@ func NewHandler(s *store.Store, cfg *config.Config, sess *auth.SessionStore) *Ha // --- Helpers --- +func RespondError(w http.ResponseWriter, message string, code int) { + SendJSON(w, code, map[string]string{"error": message}) +} + func SendJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/Hub/internal/api/middleware.go b/Hub/internal/api/middleware.go index b04873a..ef9b7b2 100644 --- a/Hub/internal/api/middleware.go +++ b/Hub/internal/api/middleware.go @@ -25,11 +25,10 @@ func UIAuthMiddleware(sessionStore *auth.SessionStore) func(http.Handler) http.H } // AgentAuthMiddleware securely validates sensor heartbeats/events against the DB Config -func AgentAuthMiddleware(s *store.Store) func(http.Handler) http.Handler { +func AgentAuthMiddleware(s *store.SQLiteStore) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var configuredKey string - err := s.DB.QueryRow("SELECT value FROM config WHERE key='hub_key'").Scan(&configuredKey) + configuredKey, err := s.GetConfigValue("hub_key") if err != nil || configuredKey == "" { http.Error(w, "Hub is not fully configured.", http.StatusServiceUnavailable) return diff --git a/Hub/internal/api/provisioning.go b/Hub/internal/api/provisioning.go index 56d71c8..0ca09aa 100644 --- a/Hub/internal/api/provisioning.go +++ b/Hub/internal/api/provisioning.go @@ -17,21 +17,17 @@ func (h *Handler) GenerateToken(w http.ResponseWriter, r *http.Request) { // Generate a random 32-character hex token tokenBytes := make([]byte, 16) if _, err := rand.Read(tokenBytes); err != nil { - http.Error(w, "Token generation failed", http.StatusInternalServerError) + RespondError(w, "Token generation failed", http.StatusInternalServerError) return } token := hex.EncodeToString(tokenBytes) // Token expires in 15 minutes - expiresAt := time.Now().Add(15 * time.Minute) + now := time.Now() + expiresAt := now.Add(15 * time.Minute) - // Insert into pairing_tokens table - _, err := h.Store.DB.Exec( - "INSERT INTO pairing_tokens (token, expires_at, created_at) VALUES (?, ?, ?)", - token, expiresAt.Format(time.RFC3339), time.Now().Format(time.RFC3339), - ) - if err != nil { - http.Error(w, "Failed to generate token", http.StatusInternalServerError) + if err := h.Store.InsertPairingToken(token, expiresAt, now); err != nil { + RespondError(w, "Failed to generate token", http.StatusInternalServerError) return } @@ -49,34 +45,27 @@ func (h *Handler) WizardLink(w http.ResponseWriter, r *http.Request) { Alias string `json:"alias"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) + RespondError(w, "Invalid request", http.StatusBadRequest) return } if req.Token == "" { - http.Error(w, "Token is required", http.StatusBadRequest) + RespondError(w, "Token is required", http.StatusBadRequest) return } // Validate token exists and is not expired - var createdAt string - err := h.Store.DB.QueryRow( - "SELECT created_at FROM pairing_tokens WHERE token = ? AND expires_at > datetime('now')", - req.Token, - ).Scan(&createdAt) - if err != nil { - http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + isValid, err := h.Store.ValidatePairingToken(req.Token) + if err != nil || !isValid { + RespondError(w, "Invalid or expired token", http.StatusUnauthorized) return } - // Delete token immediately (single-use) - h.Store.DB.Exec("DELETE FROM pairing_tokens WHERE token = ?", req.Token) - // Generate node credentials nodeID := uuid.New().String() nodeKeyBytes := make([]byte, 32) if _, err := rand.Read(nodeKeyBytes); err != nil { - http.Error(w, "Node key generation failed", http.StatusInternalServerError) + RespondError(w, "Node key generation failed", http.StatusInternalServerError) return } nodeKey := hex.EncodeToString(nodeKeyBytes) @@ -90,12 +79,8 @@ func (h *Handler) WizardLink(w http.ResponseWriter, r *http.Request) { // Insert new node now := time.Now().Format(time.RFC3339) - _, err = h.Store.DB.Exec( - "INSERT INTO nodes (node_id, alias, node_key, ip_address, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?)", - nodeID, req.Alias, nodeKey, clientIP, now, now, - ) - if err != nil { - http.Error(w, "Failed to create node", http.StatusInternalServerError) + if err := h.Store.CreateNode(nodeID, req.Alias, nodeKey, clientIP, now); err != nil { + RespondError(w, "Failed to create node", http.StatusInternalServerError) return } @@ -105,4 +90,4 @@ func (h *Handler) WizardLink(w http.ResponseWriter, r *http.Request) { "alias": req.Alias, "timestamp": now, }) -} \ No newline at end of file +} diff --git a/Hub/internal/api/router.go b/Hub/internal/api/router.go index 027431b..6b9bd12 100644 --- a/Hub/internal/api/router.go +++ b/Hub/internal/api/router.go @@ -29,7 +29,7 @@ func ErrorOnlyLogger(next http.Handler) http.Handler { }) } -func SetupRouter(cfg *config.Config, s *store.Store, sessionStore *auth.SessionStore) *chi.Mux { +func SetupRouter(cfg *config.Config, s *store.SQLiteStore, sessionStore *auth.SessionStore) *chi.Mux { r := chi.NewRouter() r.Use(ErrorOnlyLogger) diff --git a/Hub/internal/api/sensors.go b/Hub/internal/api/sensors.go index 69562b4..7e4206d 100644 --- a/Hub/internal/api/sensors.go +++ b/Hub/internal/api/sensors.go @@ -2,13 +2,12 @@ package api import ( "encoding/json" - "fmt" + "io" "log" "net/http" - "time" - "io" "os" - + "time" + "github.com/go-chi/chi/v5" "github.com/honeywire/hub/internal/models" ) @@ -16,19 +15,19 @@ import ( func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) { var hb models.Heartbeat if err := json.NewDecoder(r.Body).Decode(&hb); err != nil { - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + RespondError(w, "Invalid JSON body", http.StatusBadRequest) return } // Validate required fields if hb.NodeID == "" || hb.SensorID == "" { - http.Error(w, "node_id and sensor_id are required", http.StatusBadRequest) + RespondError(w, "node_id and sensor_id are required", http.StatusBadRequest) return } // Per-node authentication if !h.validateNodeAuth(r, hb.NodeID) { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + RespondError(w, "Unauthorized", http.StatusUnauthorized) return } @@ -38,33 +37,19 @@ func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) { metadataJSON, _ := json.Marshal(hb.Metadata) // Update sensor last_seen and metadata with composite key (node_id, sensor_id) - _, err := h.Store.DB.Exec(` - INSERT INTO sensors (node_id, sensor_id, first_seen, last_seen, metadata, is_silenced) - VALUES (?, ?, ?, ?, ?, 0) - ON CONFLICT(node_id, sensor_id) DO UPDATE SET last_seen = ?, metadata = ?`, - hb.NodeID, hb.SensorID, nowStr, nowStr, string(metadataJSON), nowStr, string(metadataJSON), - ) - if err != nil { + if err := h.Store.UpsertSensor(&hb, nowStr, string(metadataJSON)); err != nil { log.Printf("[ERROR] Heartbeat DB Upsert failed for node %s/sensor %s: %v", hb.NodeID, hb.SensorID, err) - http.Error(w, "Database error", http.StatusInternalServerError) + RespondError(w, "Database error", http.StatusInternalServerError) return } // Update node last_seen - _, err = h.Store.DB.Exec(` - UPDATE nodes SET last_seen = ? WHERE node_id = ?`, - nowStr, hb.NodeID, - ) - if err != nil { + if err := h.Store.UpdateNodeLastSeen(hb.NodeID, nowStr); err != nil { log.Printf("[WARNING] Failed to update node %s last_seen: %v", hb.NodeID, err) } // Log heartbeat bucket with composite key - _, err = h.Store.DB.Exec( - "INSERT OR IGNORE INTO sensor_heartbeats (node_id, sensor_id, time_bucket) VALUES (?, ?, ?)", - hb.NodeID, hb.SensorID, minuteBucket, - ) - if err != nil { + if err := h.Store.InsertHeartbeat(hb.NodeID, hb.SensorID, minuteBucket); err != nil { log.Printf("[WARNING] Failed to log heartbeat bucket for node %s/sensor %s: %v", hb.NodeID, hb.SensorID, err) } @@ -78,52 +63,12 @@ func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) { } func (h *Handler) GetSensors(w http.ResponseWriter, r *http.Request) { - rows, err := h.Store.DB.Query(` - SELECT sensor_id, node_id, first_seen, last_seen, metadata, is_silenced - FROM sensors - ORDER BY COALESCE(node_id, 'ZZZ') ASC, sensor_id ASC - `) // Note: COALESCE(node_id, 'ZZZ') ensures orphan sensors drop to the bottom of the list. + fleet, err := h.Store.GetAllSensors() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + RespondError(w, err.Error(), http.StatusInternalServerError) return } - defer rows.Close() - var fleet []models.Sensor - for rows.Next() { - var s models.Sensor - var metadataStr string - var isSilencedInt int - var dbNodeID *string - - if err := rows.Scan(&s.SensorID, &dbNodeID, &s.FirstSeen, &s.LastSeen, &metadataStr, &isSilencedInt); err != nil { - log.Printf("Error scanning sensor: %v", err) - continue - } - - if dbNodeID != nil { - s.NodeID = *dbNodeID - } - - s.IsSilenced = isSilencedInt == 1 - - lastSeenTime, err := time.Parse(time.RFC3339, s.LastSeen) - if err == nil && time.Now().UTC().Sub(lastSeenTime) < 90*time.Second { - s.Status = "online" - } else { - s.Status = "offline" - } - - var metadata map[string]interface{} - json.Unmarshal([]byte(metadataStr), &metadata) - s.Metadata = metadata - - fleet = append(fleet, s) - } - - if fleet == nil { - fleet = []models.Sensor{} - } SendJSON(w, http.StatusOK, fleet) } @@ -134,174 +79,21 @@ func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) { } now := time.Now().UTC() - var numBlocks int - var delta time.Duration - var expectedPings float64 + params := CalculateUptimeParams(timeframe, now) - switch timeframe { - case "1H": - numBlocks, delta, expectedPings = 30, 2*time.Minute, 2 - case "7D": - numBlocks, delta, expectedPings = 7, 24*time.Hour, 1440 - case "30D": - numBlocks, delta, expectedPings = 30, 24*time.Hour, 1440 - case "24H": - fallthrough - default: - numBlocks, delta, expectedPings = 24, time.Hour, 60 - } - - cutoff := now.Add(-delta * time.Duration(numBlocks)).Truncate(time.Minute) - cutoffStr := cutoff.Format(time.RFC3339) - - sensorRows, err := h.Store.DB.Query("SELECT node_id, sensor_id, last_seen, COALESCE(first_seen, ?) FROM sensors ORDER BY node_id, sensor_id", now.Format(time.RFC3339)) + sensors, err := h.Store.GetSensorsForUptime(now.Format(time.RFC3339)) if err != nil { - http.Error(w, "Database error fetching sensors", http.StatusInternalServerError) + RespondError(w, "Database error fetching sensors", http.StatusInternalServerError) return } - defer sensorRows.Close() - type SensorData struct { - NodeID string - SensorID string - LastSeen time.Time - FirstSeen string - } - var sensors []SensorData - history := make(map[string][]float64) - - for sensorRows.Next() { - var s SensorData - var lastSeenStr string - sensorRows.Scan(&s.NodeID, &s.SensorID, &lastSeenStr, &s.FirstSeen) - s.LastSeen, _ = time.Parse(time.RFC3339, lastSeenStr) - sensors = append(sensors, s) - - // Create a unique key combining Node and Sensor - historyKey := s.NodeID + ":" + s.SensorID - history[historyKey] = make([]float64, numBlocks) - } - - - hbRows, err := h.Store.DB.Query("SELECT node_id, sensor_id, time_bucket FROM sensor_heartbeats WHERE time_bucket >= ?", cutoffStr) + hbs, err := h.Store.GetHeartbeatsSince(params.CutoffStr) if err != nil { - http.Error(w, "Database error fetching heartbeats", http.StatusInternalServerError) + RespondError(w, "Database error fetching heartbeats", http.StatusInternalServerError) return } - defer hbRows.Close() - for hbRows.Next() { - var nID, sID, tBucket string - hbRows.Scan(&nID, &sID, &tBucket) - parsedBucket, err := time.Parse(time.RFC3339, tBucket) - if err != nil { continue } - - if parsedBucket.Before(cutoff) { - continue - } - - idx := int(parsedBucket.Sub(cutoff) / delta) - if idx >= numBlocks { - idx = numBlocks - 1 - } - - historyKey := nID + ":" + sID - if idx >= 0 && history[historyKey] != nil { - history[historyKey][idx]++ - } - } - - var result []map[string]interface{} - for _, s := range sensors { - firstSeenParsed, _ := time.Parse(time.RFC3339, s.FirstSeen) - var blocks []map[string]string - - historyKey := s.NodeID + ":" + s.SensorID - - for i := 0; i < numBlocks; i++ { - blockStart := cutoff.Add(time.Duration(i) * delta) - blockEnd := blockStart.Add(delta) - - stepsAgo := numBlocks - 1 - i - timeLabel := "Current" - if stepsAgo > 0 { - switch timeframe { - case "1H": - timeLabel = fmt.Sprintf("%d mins ago", stepsAgo*int(delta.Minutes())) - case "24H": - timeLabel = fmt.Sprintf("%d hours ago", stepsAgo) - case "7D", "30D": - timeLabel = fmt.Sprintf("%d days ago", stepsAgo) - default: - timeLabel = fmt.Sprintf("%d ago", stepsAgo) - } - } - - status, label := "", "" - - if blockEnd.Before(firstSeenParsed) { - status, label = "nodata", "No Data (Not Deployed Yet)" - } else { - pings := history[historyKey][i] - targetPings := expectedPings - - if firstSeenParsed.After(blockStart) && firstSeenParsed.Before(blockEnd) { - activeDuration := blockEnd.Sub(firstSeenParsed) - targetPings = activeDuration.Minutes() - if targetPings > expectedPings { - targetPings = expectedPings - } - if targetPings < 1 && activeDuration > 0 { - targetPings = 1 - } - } else if i == numBlocks-1 { - activeDuration := now.Sub(blockStart) - targetPings = activeDuration.Minutes() - if targetPings > expectedPings { - targetPings = expectedPings - } - if targetPings < 1 && activeDuration > 0 { - targetPings = 1 - } - } - - if pings == 0 && targetPings >= 1 { - status, label = "down", "Offline" - } else if targetPings > 0 && pings < (targetPings*0.85) { - status, label = "degraded", fmt.Sprintf("Degraded (%.0f/%.0f pings)", pings, targetPings) - } else { - status, label = "up", "Online" - } - } - - blocks = append(blocks, map[string]string{ - "status": status, - "timeLabel": timeLabel, - "label": label, - }) - } - - isLive := now.Sub(s.LastSeen) < 90*time.Second - if isLive { - blocks[len(blocks)-1]["status"] = "up" - blocks[len(blocks)-1]["label"] = "Online (Live)" - } else { - blocks[len(blocks)-1]["status"] = "down" - blocks[len(blocks)-1]["label"] = "Offline (Live)" - } - - result = append(result, map[string]interface{}{ - "id": s.SensorID, - "node_id": s.NodeID, - "name": s.SensorID, - "isOnline": isLive, - "blocks": blocks, - }) - } - - if result == nil { - result = []map[string]interface{}{} - } + result := GenerateUptimeResult(timeframe, now, params, sensors, hbs) SendJSON(w, http.StatusOK, result) } @@ -314,12 +106,12 @@ func (h *Handler) ToggleSilence(w http.ResponseWriter, r *http.Request) { IsSilenced bool `json:"is_silenced"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) + RespondError(w, "Invalid JSON", http.StatusBadRequest) return } if req.NodeID == "" { - http.Error(w, "node_id is required", http.StatusBadRequest) + RespondError(w, "node_id is required", http.StatusBadRequest) return } @@ -328,9 +120,8 @@ func (h *Handler) ToggleSilence(w http.ResponseWriter, r *http.Request) { silenceVal = 1 } - _, err := h.Store.DB.Exec("UPDATE sensors SET is_silenced = ? WHERE node_id = ? AND sensor_id = ?", silenceVal, req.NodeID, sensorID) - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) + if err := h.Store.UpdateSensorSilence(req.NodeID, sensorID, silenceVal); err != nil { + RespondError(w, "Database error", http.StatusInternalServerError) return } @@ -354,21 +145,18 @@ func (h *Handler) ForgetSensor(w http.ResponseWriter, r *http.Request) { nodeID := r.URL.Query().Get("node_id") if nodeID == "" { - http.Error(w, "node_id query parameter is required", http.StatusBadRequest) + RespondError(w, "node_id query parameter is required", http.StatusBadRequest) return } - // Note: Because of 'ON DELETE CASCADE' in the schema, SQLite will automatically - // delete all events and heartbeats tied to this sensor! - result, err := h.Store.DB.Exec("DELETE FROM sensors WHERE node_id = ? AND sensor_id = ?", nodeID, sensorID) + rowsAffected, err := h.Store.DeleteSensor(nodeID, sensorID) if err != nil { - http.Error(w, "Database error while deleting sensor", http.StatusInternalServerError) + RespondError(w, "Database error while deleting sensor", http.StatusInternalServerError) return } - rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { - http.Error(w, "Sensor not found", http.StatusNotFound) + RespondError(w, "Sensor not found", http.StatusNotFound) return } @@ -383,7 +171,6 @@ func (h *Handler) ForgetSensor(w http.ResponseWriter, r *http.Request) { }) } - // GetManifests fetches the sensor manifest JSON. // It proxies the request through the Hub to bypass browser CORS restrictions. func (h *Handler) GetManifests(w http.ResponseWriter, r *http.Request) { @@ -397,13 +184,13 @@ func (h *Handler) GetManifests(w http.ResponseWriter, r *http.Request) { // 2. Fetch the manifest resp, err := http.Get(manifestURL) if err != nil { - http.Error(w, `{"error": "Failed to reach manifest registry"}`, http.StatusBadGateway) + RespondError(w, "Failed to reach manifest registry", http.StatusBadGateway) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - http.Error(w, `{"error": "Manifest registry returned an error"}`, http.StatusBadGateway) + RespondError(w, "Manifest registry returned an error", http.StatusBadGateway) return } @@ -411,4 +198,4 @@ func (h *Handler) GetManifests(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = io.Copy(w, resp.Body) -} \ No newline at end of file +} diff --git a/Hub/internal/api/uptime.go b/Hub/internal/api/uptime.go new file mode 100644 index 0000000..788435f --- /dev/null +++ b/Hub/internal/api/uptime.go @@ -0,0 +1,168 @@ +package api + +import ( + "fmt" + "time" + + "github.com/honeywire/hub/internal/store" +) + +// UptimeParams holds the parameters needed to fetch data from the database +type UptimeParams struct { + NumBlocks int + Delta time.Duration + ExpectedPings float64 + Cutoff time.Time + CutoffStr string +} + +func CalculateUptimeParams(timeframe string, now time.Time) UptimeParams { + var numBlocks int + var delta time.Duration + var expectedPings float64 + + switch timeframe { + case "1H": + numBlocks, delta, expectedPings = 30, 2*time.Minute, 2 + case "7D": + numBlocks, delta, expectedPings = 7, 24*time.Hour, 1440 + case "30D": + numBlocks, delta, expectedPings = 30, 24*time.Hour, 1440 + case "24H": + fallthrough + default: + numBlocks, delta, expectedPings = 24, time.Hour, 60 + } + + cutoff := now.Add(-delta * time.Duration(numBlocks)).Truncate(time.Minute) + return UptimeParams{ + NumBlocks: numBlocks, + Delta: delta, + ExpectedPings: expectedPings, + Cutoff: cutoff, + CutoffStr: cutoff.Format(time.RFC3339), + } +} + +func GenerateUptimeResult(timeframe string, now time.Time, params UptimeParams, sensors []store.SensorUptimeData, hbs []store.HeartbeatData) []map[string]interface{} { + history := make(map[string][]float64) + for _, s := range sensors { + historyKey := s.NodeID + ":" + s.SensorID + history[historyKey] = make([]float64, params.NumBlocks) + } + + for _, hb := range hbs { + parsedBucket, err := time.Parse(time.RFC3339, hb.TimeBucket) + if err != nil { + continue + } + + if parsedBucket.Before(params.Cutoff) { + continue + } + + idx := int(parsedBucket.Sub(params.Cutoff) / params.Delta) + if idx >= params.NumBlocks { + idx = params.NumBlocks - 1 + } + + historyKey := hb.NodeID + ":" + hb.SensorID + if idx >= 0 && history[historyKey] != nil { + history[historyKey][idx]++ + } + } + + var result []map[string]interface{} + for _, s := range sensors { + firstSeenParsed, _ := time.Parse(time.RFC3339, s.FirstSeen) + var blocks []map[string]string + + historyKey := s.NodeID + ":" + s.SensorID + + for i := 0; i < params.NumBlocks; i++ { + blockStart := params.Cutoff.Add(time.Duration(i) * params.Delta) + blockEnd := blockStart.Add(params.Delta) + + stepsAgo := params.NumBlocks - 1 - i + timeLabel := "Current" + if stepsAgo > 0 { + switch timeframe { + case "1H": + timeLabel = fmt.Sprintf("%d mins ago", stepsAgo*int(params.Delta.Minutes())) + case "24H": + timeLabel = fmt.Sprintf("%d hours ago", stepsAgo) + case "7D", "30D": + timeLabel = fmt.Sprintf("%d days ago", stepsAgo) + default: + timeLabel = fmt.Sprintf("%d ago", stepsAgo) + } + } + + status, label := "", "" + + if blockEnd.Before(firstSeenParsed) { + status, label = "nodata", "No Data (Not Deployed Yet)" + } else { + pings := history[historyKey][i] + targetPings := params.ExpectedPings + + if firstSeenParsed.After(blockStart) && firstSeenParsed.Before(blockEnd) { + activeDuration := blockEnd.Sub(firstSeenParsed) + targetPings = activeDuration.Minutes() + if targetPings > params.ExpectedPings { + targetPings = params.ExpectedPings + } + if targetPings < 1 && activeDuration > 0 { + targetPings = 1 + } + } else if i == params.NumBlocks-1 { + activeDuration := now.Sub(blockStart) + targetPings = activeDuration.Minutes() + if targetPings > params.ExpectedPings { + targetPings = params.ExpectedPings + } + if targetPings < 1 && activeDuration > 0 { + targetPings = 1 + } + } + + if pings == 0 && targetPings >= 1 { + status, label = "down", "Offline" + } else if targetPings > 0 && pings < (targetPings*0.85) { + status, label = "degraded", fmt.Sprintf("Degraded (%.0f/%.0f pings)", pings, targetPings) + } else { + status, label = "up", "Online" + } + } + + blocks = append(blocks, map[string]string{ + "status": status, + "timeLabel": timeLabel, + "label": label, + }) + } + + isLive := now.Sub(s.LastSeen) < 90*time.Second + if isLive { + blocks[len(blocks)-1]["status"] = "up" + blocks[len(blocks)-1]["label"] = "Online (Live)" + } else { + blocks[len(blocks)-1]["status"] = "down" + blocks[len(blocks)-1]["label"] = "Offline (Live)" + } + + result = append(result, map[string]interface{}{ + "id": s.SensorID, + "node_id": s.NodeID, + "name": s.SensorID, + "isOnline": isLive, + "blocks": blocks, + }) + } + + if result == nil { + result = []map[string]interface{}{} + } + + return result +} diff --git a/Hub/internal/store/config.go b/Hub/internal/store/config.go new file mode 100644 index 0000000..5cdbe42 --- /dev/null +++ b/Hub/internal/store/config.go @@ -0,0 +1,124 @@ +package store + +import ( + "strconv" + "strings" +) + +func (s *SQLiteStore) GetConfigValue(key string) (string, error) { + var value string + err := s.DB.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&value) + return value, err +} + +func (s *SQLiteStore) UpdateConfigValue(key, value string) error { + _, err := s.DB.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, value) + return err +} + +func (s *SQLiteStore) GetAllConfig() (map[string]string, error) { + rows, err := s.DB.Query("SELECT key, value FROM config") + if err != nil { + return nil, err + } + defer rows.Close() + + kv := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err == nil { + kv[k] = v + } + } + return kv, nil +} + +func (s *SQLiteStore) CompleteSetup(adminHash, hubEndpoint, hubKey string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('admin_hash', ?)", adminHash) + if err != nil { + return err + } + _, err = tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('hub_endpoint', ?)", hubEndpoint) + if err != nil { + return err + } + _, err = tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('hub_key', ?)", hubKey) + if err != nil { + return err + } + _, err = tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('is_setup', 'true')") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLiteStore) UpdateConfigBatch(req map[string]interface{}) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + validWebhooks := map[string]bool{"ntfy": true, "gotify": true, "discord": true, "slack": true} + validProtocols := map[string]bool{"tcp": true, "udp": true} + + for key, val := range req { + switch key { + case "hub_endpoint", "hub_key", "webhook_url", "siem_address": + if strVal, ok := val.(string); ok { + tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strVal) + } + case "webhook_type": + if strVal, ok := val.(string); ok && validWebhooks[strings.ToLower(strVal)] { + tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal)) + } + case "siem_protocol": + if strVal, ok := val.(string); ok && validProtocols[strings.ToLower(strVal)] { + tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal)) + } + case "auto_archive_days", "auto_purge_days": + if numVal, ok := val.(float64); ok { + tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strconv.Itoa(int(numVal))) + } + case "webhook_events": + if arrVal, ok := val.([]interface{}); ok { + var events []string + for _, v := range arrVal { + if str, ok := v.(string); ok { + events = append(events, str) + } + } + tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.Join(events, ",")) + } + } + } + + return tx.Commit() +} + +func (s *SQLiteStore) FactoryReset() error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + tx.Exec("DELETE FROM events") + tx.Exec("DELETE FROM sensors") + tx.Exec("DELETE FROM sensor_heartbeats") + tx.Exec("DELETE FROM config") + + if err := tx.Commit(); err != nil { + return err + } + + return InitializeDefaultConfig(s.DB) +} \ No newline at end of file diff --git a/Hub/internal/store/events.go b/Hub/internal/store/events.go new file mode 100644 index 0000000..e309e3b --- /dev/null +++ b/Hub/internal/store/events.go @@ -0,0 +1,140 @@ +package store + +import ( + "database/sql" + "encoding/json" + + "github.com/honeywire/hub/internal/models" +) + +func (s *SQLiteStore) InsertEvent(e *models.Event, nowStr string, detailsStr string) (int, error) { + result, err := s.DB.Exec(` + INSERT INTO events (node_id, sensor_id, timestamp, contract_version, event_trigger, severity, source, target, details, is_read, is_archived) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`, + e.NodeID, e.SensorID, nowStr, e.ContractVersion, e.EventTrigger, e.Severity, e.Source, e.Target, detailsStr, + ) + if err != nil { + return 0, err + } + lastInsertID, _ := result.LastInsertId() + return int(lastInsertID), nil +} + +func (s *SQLiteStore) UpdateNodeLastSeen(nodeID, timestamp string) error { + _, err := s.DB.Exec(`UPDATE nodes SET last_seen = ? WHERE node_id = ?`, timestamp, nodeID) + return err +} + +func (s *SQLiteStore) IsSensorSilenced(nodeID, sensorID string) (bool, error) { + var isSilencedInt int + err := s.DB.QueryRow( + "SELECT is_silenced FROM sensors WHERE node_id = ? AND sensor_id = ?", + nodeID, sensorID, + ).Scan(&isSilencedInt) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + return isSilencedInt == 1, nil +} + +func (s *SQLiteStore) GetEvents(isArchived int, nodeID string, sensorID string) ([]models.Event, error) { + query := "SELECT id, timestamp, contract_version, sensor_id, node_id, event_trigger, severity, source, target, details, is_read, is_archived FROM events WHERE is_archived = ?" + args := []interface{}{isArchived} + + // Apply Node Filter if present + if nodeID != "" { + query += " AND node_id = ?" + args = append(args, nodeID) + } + + // Apply Sensor Filter if present + if sensorID != "" { + query += " AND sensor_id = ?" + args = append(args, sensorID) + } + + query += " ORDER BY id DESC" + + rows, err := s.DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []models.Event + for rows.Next() { + var e models.Event + var detailsStr string + var isReadInt, isArchivedInt int + var dbNodeID *string // Use pointer to handle SQL NULL safely + + if err := rows.Scan( + &e.ID, &e.Timestamp, &e.ContractVersion, &e.SensorID, &dbNodeID, + &e.EventTrigger, &e.Severity, &e.Source, &e.Target, + &detailsStr, &isReadInt, &isArchivedInt, + ); err != nil { + continue + } + + if dbNodeID != nil { + e.NodeID = *dbNodeID + } + + e.IsRead = isReadInt == 1 + e.IsArchived = isArchivedInt == 1 + json.Unmarshal([]byte(detailsStr), &e.Details) + events = append(events, e) + } + + if events == nil { + events = []models.Event{} + } + + return events, nil +} + +func (s *SQLiteStore) GetUnreadEventCount() (int, error) { + var count int + err := s.DB.QueryRow("SELECT COUNT(*) FROM events WHERE is_read = 0 AND is_archived = 0").Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *SQLiteStore) MarkEventRead(eventID string) error { + _, err := s.DB.Exec("UPDATE events SET is_read = 1 WHERE id = ?", eventID) + return err +} + +func (s *SQLiteStore) MarkAllEventsRead() error { + _, err := s.DB.Exec("UPDATE events SET is_read = 1 WHERE is_read = 0") + return err +} + +func (s *SQLiteStore) ArchiveEvent(eventID string) error { + _, err := s.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE id = ?", eventID) + return err +} + +func (s *SQLiteStore) ArchiveAllEvents() error { + _, err := s.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE is_archived = 0") + return err +} + +func (s *SQLiteStore) GetEventCount() (int, error) { + var count int + err := s.DB.QueryRow("SELECT COUNT(*) FROM events").Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func (s *SQLiteStore) ClearAllEvents() error { + _, err := s.DB.Exec("DELETE FROM events") + return err +} diff --git a/Hub/internal/store/provisioning.go b/Hub/internal/store/provisioning.go new file mode 100644 index 0000000..3cb8850 --- /dev/null +++ b/Hub/internal/store/provisioning.go @@ -0,0 +1,40 @@ +package store + +import "time" + +func (s *SQLiteStore) InsertPairingToken(token string, expiresAt time.Time, createdAt time.Time) error { + _, err := s.DB.Exec( + "INSERT INTO pairing_tokens (token, expires_at, created_at) VALUES (?, ?, ?)", + token, expiresAt.Format(time.RFC3339), createdAt.Format(time.RFC3339), + ) + return err +} + +func (s *SQLiteStore) ValidatePairingToken(token string) (bool, error) { + var createdAt string + err := s.DB.QueryRow( + "SELECT created_at FROM pairing_tokens WHERE token = ? AND expires_at > datetime('now')", + token, + ).Scan(&createdAt) + if err != nil { + return false, err + } + + // Delete token immediately (single-use) + s.DB.Exec("DELETE FROM pairing_tokens WHERE token = ?", token) + return true, nil +} + +func (s *SQLiteStore) CreateNode(nodeID, alias, nodeKey, ipAddress, nowStr string) error { + _, err := s.DB.Exec( + "INSERT INTO nodes (node_id, alias, node_key, ip_address, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?)", + nodeID, alias, nodeKey, ipAddress, nowStr, nowStr, + ) + return err +} + +func (s *SQLiteStore) GetNodeKey(nodeID string) (string, error) { + var nodeKey string + err := s.DB.QueryRow("SELECT node_key FROM nodes WHERE node_id = ?", nodeID).Scan(&nodeKey) + return nodeKey, err +} diff --git a/Hub/internal/store/sensors.go b/Hub/internal/store/sensors.go new file mode 100644 index 0000000..ecd52ac --- /dev/null +++ b/Hub/internal/store/sensors.go @@ -0,0 +1,136 @@ +package store + +import ( + "encoding/json" + "time" + + "github.com/honeywire/hub/internal/models" +) + +type SensorUptimeData struct { + NodeID string + SensorID string + LastSeen time.Time + FirstSeen string +} + +type HeartbeatData struct { + NodeID string + SensorID string + TimeBucket string +} + +func (s *SQLiteStore) UpsertSensor(hb *models.Heartbeat, nowStr, metadataStr string) error { + _, err := s.DB.Exec(` + INSERT INTO sensors (node_id, sensor_id, first_seen, last_seen, metadata, is_silenced) + VALUES (?, ?, ?, ?, ?, 0) + ON CONFLICT(node_id, sensor_id) DO UPDATE SET last_seen = ?, metadata = ?`, + hb.NodeID, hb.SensorID, nowStr, nowStr, metadataStr, nowStr, metadataStr, + ) + return err +} + +func (s *SQLiteStore) InsertHeartbeat(nodeID, sensorID, timeBucket string) error { + _, err := s.DB.Exec( + "INSERT OR IGNORE INTO sensor_heartbeats (node_id, sensor_id, time_bucket) VALUES (?, ?, ?)", + nodeID, sensorID, timeBucket, + ) + return err +} + +func (s *SQLiteStore) GetAllSensors() ([]models.Sensor, error) { + rows, err := s.DB.Query(` + SELECT sensor_id, node_id, first_seen, last_seen, metadata, is_silenced + FROM sensors + ORDER BY COALESCE(node_id, 'ZZZ') ASC, sensor_id ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var fleet []models.Sensor + for rows.Next() { + var s models.Sensor + var metadataStr string + var isSilencedInt int + var dbNodeID *string + + if err := rows.Scan(&s.SensorID, &dbNodeID, &s.FirstSeen, &s.LastSeen, &metadataStr, &isSilencedInt); err != nil { + continue + } + + if dbNodeID != nil { + s.NodeID = *dbNodeID + } + + s.IsSilenced = isSilencedInt == 1 + + lastSeenTime, err := time.Parse(time.RFC3339, s.LastSeen) + if err == nil && time.Now().UTC().Sub(lastSeenTime) < 90*time.Second { + s.Status = "online" + } else { + s.Status = "offline" + } + + var metadata map[string]interface{} + json.Unmarshal([]byte(metadataStr), &metadata) + s.Metadata = metadata + + fleet = append(fleet, s) + } + + if fleet == nil { + fleet = []models.Sensor{} + } + return fleet, nil +} + +func (s *SQLiteStore) GetSensorsForUptime(nowStr string) ([]SensorUptimeData, error) { + rows, err := s.DB.Query("SELECT node_id, sensor_id, last_seen, COALESCE(first_seen, ?) FROM sensors ORDER BY node_id, sensor_id", nowStr) + if err != nil { + return nil, err + } + defer rows.Close() + + var sensors []SensorUptimeData + for rows.Next() { + var sd SensorUptimeData + var lastSeenStr string + if err := rows.Scan(&sd.NodeID, &sd.SensorID, &lastSeenStr, &sd.FirstSeen); err == nil { + sd.LastSeen, _ = time.Parse(time.RFC3339, lastSeenStr) + sensors = append(sensors, sd) + } + } + return sensors, nil +} + +func (s *SQLiteStore) GetHeartbeatsSince(cutoffStr string) ([]HeartbeatData, error) { + rows, err := s.DB.Query("SELECT node_id, sensor_id, time_bucket FROM sensor_heartbeats WHERE time_bucket >= ?", cutoffStr) + if err != nil { + return nil, err + } + defer rows.Close() + + var hbs []HeartbeatData + for rows.Next() { + var hb HeartbeatData + if err := rows.Scan(&hb.NodeID, &hb.SensorID, &hb.TimeBucket); err == nil { + hbs = append(hbs, hb) + } + } + return hbs, nil +} + +func (s *SQLiteStore) UpdateSensorSilence(nodeID, sensorID string, silenceVal int) error { + _, err := s.DB.Exec("UPDATE sensors SET is_silenced = ? WHERE node_id = ? AND sensor_id = ?", silenceVal, nodeID, sensorID) + return err +} + +func (s *SQLiteStore) DeleteSensor(nodeID, sensorID string) (int64, error) { + result, err := s.DB.Exec("DELETE FROM sensors WHERE node_id = ? AND sensor_id = ?", nodeID, sensorID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/Hub/internal/store/sqlite.go b/Hub/internal/store/sqlite.go index 4828e56..300b540 100644 --- a/Hub/internal/store/sqlite.go +++ b/Hub/internal/store/sqlite.go @@ -81,12 +81,12 @@ CREATE INDEX IF NOT EXISTS idx_heartbeats_time ON sensor_heartbeats(time_bucket) CREATE INDEX IF NOT EXISTS idx_pairing_tokens_expires ON pairing_tokens(expires_at); ` -type Store struct { +type SQLiteStore struct { DB *sql.DB } // NewStore initializes the SQLite database with the v2.0.0 schema -func NewStore(dbPath string) (*Store, error) { +func NewStore(dbPath string) (*SQLiteStore, error) { // Enable WAL mode, set a 5-second busy timeout to prevent locking, and enable Foreign Keys dsn := fmt.Sprintf("%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)", dbPath) @@ -110,7 +110,7 @@ func NewStore(dbPath string) (*Store, error) { } log.Println("[DB] Database v2.0.0 initialized successfully in WAL mode.") - return &Store{DB: db}, nil + return &SQLiteStore{DB: db}, nil } func InitializeDefaultConfig(db *sql.DB) error { diff --git a/Hub/internal/store/store.go b/Hub/internal/store/store.go new file mode 100644 index 0000000..05e82c7 --- /dev/null +++ b/Hub/internal/store/store.go @@ -0,0 +1,45 @@ +package store + +import ( + "time" + + "github.com/honeywire/hub/internal/models" +) + +// DataStore defines the interface for all database operations. +type DataStore interface { + GetConfigValue(key string) (string, error) + UpdateConfigValue(key, value string) error + GetAllConfig() (map[string]string, error) + CompleteSetup(adminHash, hubEndpoint, hubKey string) error + UpdateConfigBatch(req map[string]interface{}) error + FactoryReset() error + + // Events + InsertEvent(e *models.Event, nowStr string, detailsStr string) (int, error) + UpdateNodeLastSeen(nodeID, timestamp string) error + IsSensorSilenced(nodeID, sensorID string) (bool, error) + GetEvents(isArchived int, nodeID string, sensorID string) ([]models.Event, error) + GetUnreadEventCount() (int, error) + MarkEventRead(eventID string) error + MarkAllEventsRead() error + ArchiveEvent(eventID string) error + ArchiveAllEvents() error + GetEventCount() (int, error) + ClearAllEvents() error + + // Sensors + UpsertSensor(hb *models.Heartbeat, nowStr, metadataStr string) error + InsertHeartbeat(nodeID, sensorID, timeBucket string) error + GetAllSensors() ([]models.Sensor, error) + GetSensorsForUptime(nowStr string) ([]SensorUptimeData, error) + GetHeartbeatsSince(cutoffStr string) ([]HeartbeatData, error) + UpdateSensorSilence(nodeID, sensorID string, silenceVal int) error + DeleteSensor(nodeID, sensorID string) (int64, error) + + // Provisioning + InsertPairingToken(token string, expiresAt time.Time, createdAt time.Time) error + ValidatePairingToken(token string) (bool, error) + CreateNode(nodeID, alias, nodeKey, ipAddress, nowStr string) error + GetNodeKey(nodeID string) (string, error) +} diff --git a/README.md b/README.md index d150397..4cd7a8f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Whether it is a **Deep Packet Inspection (DPI)** engine, a **DNS sinkhole**, a * "event_trigger": "malformed_jwt_detected", "source": "104.28.19.12", "target": "Auth Gateway", + "node_id": "node-12345678", "sensor_id": "core-dpi-engine", "details": { "protocol": "TCP", @@ -82,7 +83,7 @@ Whether it is a **Deep Packet Inspection (DPI)** engine, a **DNS sinkhole**, a * ## Features - **The Sentinel Hub UI:** A fully responsive, Vue 3-powered dashboard featuring Dark/Light mode, live WebSocket event streaming, and dynamic forensic payload inspection. -- **In-Browser Configuration:** Manage Master Passwords, Hub API Keys, Data Retention policies, and Webhooks directly from the UI. No need to touch `.env` files or restart containers to update alert targets. +- **In-Browser Configuration:** Manage Master Passwords, Node Keys, Data Retention policies, and Webhooks directly from the UI. No need to touch `.env` files or restart containers to update alert targets. - **Universal Push Notifications:** Native, zero-dependency integration for routing critical alerts to **Discord, Slack, Ntfy, and Gotify**. - **Enterprise SIEM Integration:** Native RFC 3164 Syslog forwarding (TCP/UDP) for seamlessly pushing structured telemetry to Splunk, Elastic, Wazuh, or Vector. - **Suite of Official Sensors:** Includes native [TCP Tarpit](./Sensors/official/TcpTarpit/), [Web Router Decoy](./Sensors/official/WebRouterDecoy/), [File Canary (FIM)](./Sensors/official/FileCanary/), [ICMP Canary](./Sensors/official/IcmpCanary/), and [Network Scan Detector](./Sensors/official/NetworkScanDetector/). @@ -147,11 +148,11 @@ docker compose up -d Navigate to `http://:8080` in your browser. You will be greeted by the **Initialize Sentinel** screen. 1. Create your Master Password. 2. Verify your Hub Endpoint URL (the IP/URL where sensors will reach the Hub). -3. Generate your secure Sensor Secret Key. +3. Provision a Node and generate its secure Node Key. 4. Click "Initialize Hub". ### 3. Deploy Sensors -Inside the Dashboard, navigate to the **Sensor Store**. Click on any sensor (e.g., the TCP Tarpit) to view its documentation. The Hub will automatically generate a ready-to-use `docker-compose.yml` script pre-filled with your Hub's IP and API Key. Copy that script, drop it on your target machine, and run `docker compose up -d`! +Inside the Dashboard, navigate to the **Sensor Store**. Click on any sensor (e.g., the TCP Tarpit) to view its documentation. The Hub will automatically generate a ready-to-use `docker-compose.yml` script pre-filled with your Hub\'s IP and Node Key. Copy that script, drop it on your target machine, and run `docker compose up -d`! ### 4. Testing the Trap @@ -176,11 +177,11 @@ nc localhost 2222 --- ## Security Notes -* **API Secret:** Ensure your `Hub Secret Key` is strong and identical on both the Hub and the Sensors. The Hub will reject any payloads with mismatched keys. +* **Node Keys:** Ensure your sensors use their unique `Node Key` to communicate with the Hub. The Hub will reject any payloads with mismatched or invalid keys. * **System Arming:** You can toggle the "System Armed" button in the Hub UI to temporarily disable push notifications while doing internal network maintenance or vulnerability scanning. * **Container Hardening:** HoneyWire utilizes `gcr.io/distroless/static-debian12:nonroot`. We follow the principle of least privilege to make sure that if a container is compromised, the blast is contained. * **Distributed Deployment:** It is highly recommended to run the Hub and its Sensors on separate physical or virtual machines. If an attacker compromises a sensor node, they should not have immediate local access to the centralized Hub. -* ⚠️ **Encryption (HTTPS):** Always serve the Hub Web GUI and API over HTTPS using a reverse proxy (like Nginx, Caddy, or Traefik) in production. Failure to do so exposes your `Hub Secret Key` and Dashboard password to network sniffing. +* ⚠️ **Encryption (HTTPS):** Always serve the Hub Web GUI and API over HTTPS using a reverse proxy (like Nginx, Caddy, or Traefik) in production. Failure to do so exposes your Dashboard password and Node Keys to network sniffing. ## Tech Stack @@ -201,6 +202,7 @@ nc localhost 2222 ## Operational Checklist - [x] Complete the Web UI Initial Setup to set the Master Password. -- [x] Retrieve the generated `Hub Secret Key` from Settings and apply it to your sensors. +- [x] Provision a Node and retrieve the generated `Node Key` to apply it to your sensors. - [x] Configure your push notification webhooks via the Settings UI. -- [x] Rebuild/redeploy containers after any version bump in `VERSION` or environment variable changes. + +- [x] Rebuild/redeploy containers after any version bump in `VERSION` or e diff --git a/SDKs/go-honeywire/sdk.go b/SDKs/go-honeywire/sdk.go index 664f6f8..247dcd3 100644 --- a/SDKs/go-honeywire/sdk.go +++ b/SDKs/go-honeywire/sdk.go @@ -138,7 +138,7 @@ func (s *Sensor) sendHeartbeat() { defer resp.Body.Close() if resp.StatusCode >= 400 { - log.Printf("[-] Hub rejected heartbeat (HTTP %d). Check API keys or Node status.", resp.StatusCode) + log.Printf("[-] Hub rejected heartbeat (HTTP %d). Check Node Keys or Node status.", resp.StatusCode) } } diff --git a/Sensors/official/FileCanary/README.md b/Sensors/official/FileCanary/README.md index d384919..1acfcb2 100644 --- a/Sensors/official/FileCanary/README.md +++ b/Sensors/official/FileCanary/README.md @@ -16,7 +16,7 @@ Configuration is managed through an `.env` file located in the same directory as | Variable | Description | Example | |---|---|---| | `HW_HUB_ENDPOINT` | The URL of your central HoneyWire Hub. | `http://127.0.0.1:8080` | -| `HW_HUB_KEY` | The shared secret API key to authenticate with the Hub. | `super_secret_key_123` | +| `HW_HUB_KEY` | The Node Key to authenticate with the Hub. | `super_secret_key_123` | | `HW_SENSOR_ID` | A unique identifier for this specific trap. | `file-canary-01` | | `HW_SEVERITY` | Alert severity sent to the Hub (`info` to `critical`). | `critical` | ### Sensor-Specific Variables diff --git a/Sensors/official/IcmpCanary/README.md b/Sensors/official/IcmpCanary/README.md index 47f6d3e..93a111f 100644 --- a/Sensors/official/IcmpCanary/README.md +++ b/Sensors/official/IcmpCanary/README.md @@ -16,7 +16,7 @@ Configuration is managed through Environment Variables. | Variable | Description | Example | |---|---|---| | `HW_HUB_ENDPOINT` | The URL of your central HoneyWire Hub. | `http://127.0.0.1:8080` | -| `HW_HUB_KEY` | The shared secret API key to authenticate with the Hub. | `super_secret_key_123` | +| `HW_HUB_KEY` | The Node Key to authenticate with the Hub. | `super_secret_key_123` | | `HW_SENSOR_ID` | A unique identifier for this specific trap. | `ping-canary-01` | | `HW_SEVERITY` | Alert severity sent to the Hub (`info` to `critical`). | `high` | diff --git a/Sensors/official/NetworkScanDetector/README.md b/Sensors/official/NetworkScanDetector/README.md index f3e98b7..123181e 100644 --- a/Sensors/official/NetworkScanDetector/README.md +++ b/Sensors/official/NetworkScanDetector/README.md @@ -16,7 +16,7 @@ All configuration is handled via Environment Variables. | Variable | Description | Example | |---|---|---| | `HW_HUB_ENDPOINT` | The URL of your central HoneyWire Hub. | `http://127.0.0.1:8080` | -| `HW_HUB_KEY` | The shared secret API key to authenticate with the Hub. | `super_secret_key_123` | +| `HW_HUB_KEY` | The Node Key to authenticate with the Hub. | `super_secret_key_123` | | `HW_SENSOR_ID` | A unique identifier for this specific trap. | `scan-detector-01` | | `HW_SEVERITY` | Alert severity sent to the Hub (`info` to `critical`). | `critical` | diff --git a/Sensors/official/TcpTarpit/README.md b/Sensors/official/TcpTarpit/README.md index 9d9247d..3c9a79d 100644 --- a/Sensors/official/TcpTarpit/README.md +++ b/Sensors/official/TcpTarpit/README.md @@ -17,7 +17,7 @@ All configuration is handled via Environment Variables. Copy the `.env.example` | Variable | Description | Example | |---|---|---| | `HW_HUB_ENDPOINT` | The URL of your central HoneyWire Hub. | `http://127.0.0.1:8080` | -| `HW_HUB_KEY` | The shared secret API key to authenticate with the Hub. | `super_secret_key_123` | +| `HW_HUB_KEY` | The Node Key to authenticate with the Hub. | `super_secret_key_123` | | `HW_SENSOR_ID` | A unique identifier for this specific trap. | `ssh-tarpit-01` | | `HW_SEVERITY` | Alert severity sent to the Hub (`info` to `critical`). | `high` | diff --git a/Sensors/official/WebRouterDecoy/README.md b/Sensors/official/WebRouterDecoy/README.md index 09ae7b1..d16020f 100644 --- a/Sensors/official/WebRouterDecoy/README.md +++ b/Sensors/official/WebRouterDecoy/README.md @@ -15,7 +15,7 @@ All configuration is handled via Environment Variables. Copy the `.env.example` | Variable | Description | Example | |---|---|---| | `HW_HUB_ENDPOINT` | The URL of your central HoneyWire Hub. | `http://127.0.0.1:8080` | -| `HW_HUB_KEY` | The shared secret API key to authenticate with the Hub. | `super_secret_key_123` | +| `HW_HUB_KEY` | The Node Key to authenticate with the Hub. | `super_secret_key_123` | | `HW_SENSOR_ID` | A unique identifier for this specific trap. | `web-decoy-01` | | `HW_SEVERITY` | Alert severity sent to the Hub (`info` to `critical`). | `critical` | diff --git a/Sensors/official/manifests.dev.json b/Sensors/official/manifests.dev.json index 5de388c..60b3d11 100644 --- a/Sensors/official/manifests.dev.json +++ b/Sensors/official/manifests.dev.json @@ -46,7 +46,7 @@ "cap_add": ["NET_BIND_SERVICE"], "env_vars": [ { "name": "HW_HUB_ENDPOINT", "description": "The URL of your central HoneyWire Hub.", "default": "__HUB_ENDPOINT__", "type": "string", "required": true, "hidden": true }, - { "name": "HW_HUB_KEY", "description": "The shared secret API key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, + { "name": "HW_HUB_KEY", "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, { "name": "HW_NODE_ID", "description": "The Node UUID.", "default": "${HW_NODE_ID}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SENSOR_ID", "description": "Unique identifier for this specific trap.", "default": "${HW_SENSOR_ID:-tcp-tarpit-01}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SEVERITY", "description": "Alert severity sent to the Hub.", "default": "high", "type": "string", "required": false, "hidden": true }, @@ -103,7 +103,7 @@ "user": "0:0", "env_vars": [ { "name": "HW_HUB_ENDPOINT", "description": "The URL of your central HoneyWire Hub.", "default": "__HUB_ENDPOINT__", "type": "string", "required": true, "hidden": true }, - { "name": "HW_HUB_KEY", "description": "The shared secret API key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, + { "name": "HW_HUB_KEY", "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, { "name": "HW_NODE_ID", "description": "The Node UUID.", "default": "${HW_NODE_ID}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SENSOR_ID", "description": "Unique identifier for this specific trap.", "default": "${HW_SENSOR_ID:-web-decoy-01}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SEVERITY", "description": "Alert severity sent to the Hub.", "default": "critical", "type": "string", "required": false, "hidden": true }, @@ -167,7 +167,7 @@ "volume_mounts": [ { "type": "bind", "source": "{{ .TrapPath }}", "target": "/honey_dir", "read_only": true } ], "env_vars": [ { "name": "HW_HUB_ENDPOINT", "description": "The URL of your central HoneyWire Hub.", "default": "__HUB_ENDPOINT__", "type": "string", "required": true, "hidden": true }, - { "name": "HW_HUB_KEY", "description": "The shared secret API key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, + { "name": "HW_HUB_KEY", "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, { "name": "HW_NODE_ID", "description": "The Node UUID.", "default": "${HW_NODE_ID}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SENSOR_ID", "description": "Unique identifier for this specific trap.", "default": "${HW_SENSOR_ID:-file-canary-01}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SEVERITY", "description": "Alert severity sent to the Hub.", "default": "critical", "type": "string", "required": false, "hidden": true }, @@ -223,7 +223,7 @@ "cap_add": ["NET_RAW"], "env_vars": [ { "name": "HW_HUB_ENDPOINT", "description": "The URL of your central HoneyWire Hub.", "default": "__HUB_ENDPOINT__", "type": "string", "required": true, "hidden": true }, - { "name": "HW_HUB_KEY", "description": "The shared secret API key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, + { "name": "HW_HUB_KEY", "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, { "name": "HW_NODE_ID", "description": "The Node UUID.", "default": "${HW_NODE_ID}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SENSOR_ID", "description": "Unique identifier for this specific trap.", "default": "${HW_SENSOR_ID:-ping-canary-01}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SEVERITY", "description": "Alert severity sent to the Hub.", "default": "high", "type": "string", "required": false, "hidden": true }, @@ -275,7 +275,7 @@ "cap_add": ["NET_RAW"], "env_vars": [ { "name": "HW_HUB_ENDPOINT", "description": "The URL of your central HoneyWire Hub.", "default": "__HUB_ENDPOINT__", "type": "string", "required": true, "hidden": true }, - { "name": "HW_HUB_KEY", "description": "The shared secret API key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, + { "name": "HW_HUB_KEY", "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, "hidden": true }, { "name": "HW_NODE_ID", "description": "The Node UUID.", "default": "${HW_NODE_ID}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SENSOR_ID", "description": "Unique identifier for this specific trap.", "default": "${HW_SENSOR_ID:-scan-detector-01}", "type": "string", "required": true, "hidden": true }, { "name": "HW_SEVERITY", "description": "Alert severity sent to the Hub.", "default": "critical", "type": "string", "required": false, "hidden": true }, diff --git a/Sensors/official/manifests.json b/Sensors/official/manifests.json index ad0def3..713f3a5 100644 --- a/Sensors/official/manifests.json +++ b/Sensors/official/manifests.json @@ -52,7 +52,7 @@ }, { "name": "HW_HUB_KEY", - "description": "The shared secret API key.", + "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, @@ -138,7 +138,7 @@ }, { "name": "HW_HUB_KEY", - "description": "The shared secret API key.", + "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, @@ -241,7 +241,7 @@ }, { "name": "HW_HUB_KEY", - "description": "The shared secret API key.", + "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, @@ -334,7 +334,7 @@ }, { "name": "HW_HUB_KEY", - "description": "The shared secret API key.", + "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true, @@ -396,7 +396,7 @@ }, { "name": "HW_HUB_KEY", - "description": "The shared secret API key.", + "description": "The shared Node Key.", "default": "__HUB_KEY__", "type": "string", "required": true,