mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
refactored backend for better modularity and compartimentalization of concerns, updated documentation to match current changes.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
hub
|
||||
|
||||
# =========================
|
||||
# OS & IDE Clutter
|
||||
# =========================
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user