refactored backend for better modularity and compartimentalization of concerns, updated documentation to match current changes.

This commit is contained in:
AndReicscs
2026-05-10 09:51:55 +00:00
parent e36c395816
commit a42e96d49f
27 changed files with 944 additions and 544 deletions
+2
View File
@@ -1,3 +1,5 @@
hub
# =========================
# OS & IDE Clutter
# =========================
+1 -1
View File
@@ -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.
+79 -8
View File
@@ -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: <HW_HUB_KEY>
Authorization: Bearer <HW_HUB_KEY>
Authorization: Bearer <HW_NODE_KEY>
```
---
@@ -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",
+33 -41
View File
@@ -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)
}
func (h *Handler) invalidateNodeCache(nodeID string) {
h.nodeAuthCache.Delete(nodeID)
}
+52 -107
View File
@@ -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})
}
+38 -82
View File
@@ -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,
+12 -4
View File
@@ -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)
+2 -3
View File
@@ -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
+14 -29
View File
@@ -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,
})
}
}
+1 -1
View File
@@ -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)
+29 -242
View File
@@ -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)
}
}
+168
View File
@@ -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
}
+124
View File
@@ -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)
}
+140
View File
@@ -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
}
+40
View File
@@ -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
}
+136
View File
@@ -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()
}
+3 -3
View File
@@ -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 {
+45
View File
@@ -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)
}
+9 -7
View File
@@ -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://<your-server-ip>: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
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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` |
@@ -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` |
+1 -1
View File
@@ -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` |
+1 -1
View File
@@ -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` |
+5 -5
View File
@@ -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 },
+5 -5
View File
@@ -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,