backend and sdk rewrite for hub centric sensors

This commit is contained in:
AndReicscs
2026-05-17 19:02:29 +00:00
parent 8731262435
commit 05fcccd9c3
20 changed files with 1208 additions and 925 deletions
+271 -397
View File
@@ -1,106 +1,70 @@
# HoneyWire Hub — API Reference (v1.0)
[![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE)
All API routes are prefixed with `/api/v1` unless otherwise noted.
# HoneyWire Hub — API Reference
All Hub routes are prefixed with `/api/v1` unless otherwise noted.
## Authentication
HoneyWire uses two separate authentication mechanisms depending on the caller.
HoneyWire uses two separate authentication methods.
### Dashboard (UI) routes
Protected by an HTTP-only session cookie named `hw_auth`. The cookie is issued by `POST /login` and is valid for 30 days.
Dashboard endpoints are protected by an HTTP-only session cookie named `hw_auth`.
The cookie is issued by `POST /login` and is valid for 30 days.
### Agent (sensor) routes
Protected by a unique node key generated during provisioning. Pass the key using the Authorization header:
Sensor/agent endpoints are authenticated with a node API key using the Authorization header:
```text
```http
Authorization: Bearer <HW_NODE_KEY>
```
## WebSocket
## Public Endpoints
### GET /api/v1/ws
### GET /api/v1/version
Upgrades the connection to a persistent WebSocket for real-time dashboard updates. Requires a valid `hw_auth` session cookie.
The Hub pushes a JSON message to all connected dashboard clients whenever a sensor reports an event, a new sensor comes online, a sensor is removed, a sensor's silence state changes, or when time-series charts need synchronization.
All messages share the same envelope:
```json
{ "type": "<MESSAGE_TYPE>", "payload": { ... } }
```
| 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 (new sensor detected) | `{ "node_id": "...", "sensor_id": "...", "timestamp": "..." }` |
| `SENSOR_HEARTBEAT` | A sensor sends a periodic heartbeat (every ~30 seconds) | `{ "node_id": "...", "sensor_id": "...", "timestamp": "..." }` |
| `SYNC_CHARTS` | Periodic time-series chart synchronization orchestrator (every 1 minute) | `null` |
| `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:**
```json
{
"type": "NEW_EVENT",
"payload": {
"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",
"source": "104.28.19.12",
"target": "Auth Gateway",
"details": {
"protocol": "TCP",
"action_taken": "logged"
},
"is_read": false,
"is_archived": false
}
}
```
### Pure-Push Architecture
HoneyWire eliminates frontend polling entirely. The dashboard establishes a single persistent WebSocket connection and receives all state changes in real-time:
- **Events** push instantly via `NEW_EVENT`
- **New sensors** are detected instantly via `NEW_SENSOR` and trigger a full fleet refresh
- **Status indicators** (Live online/offline dots) are updated at 0-latency thanks to `SENSOR_HEARTBEAT` messages natively updating the Vue state.
- **Fleet changes** (remove/silence) broadcast immediately via `DELETE_SENSOR` and `SILENCE_SENSOR`
- **Uptime charts** update seamlessly via `SYNC_CHARTS` orchestrator messages (every 1 minute). This "Push-to-Pull" pattern keeps all connected clients' historical time-series blocks perfectly synchronized without relying on wasteful, throttle-prone browser timers.
**Reconnection Recovery:** If the user's connection drops, the frontend automatically reconnects, fires an `onReconnect` hook, and fetches any missed data. This ensures the dashboard never displays stale information or misses events that occurred while offline.
## System & Configuration
HoneyWire splits configuration into two layers:
1. **Infrastructure Level (`.env`):** Defines immutable properties like ports, database paths, and emergency dashboard password overrides (`HW_DASHBOARD_PASSWORD`).
2. **Runtime Level (SQLite):** Governs hot-reloadable application logic like API keys, retention policies, and webhooks.
### GET /api/v1/setup/status
Checks if the database requires an initial master password and routing configuration. Automatically returns `false` if the `HW_DASHBOARD_PASSWORD` environment variable is strictly set.
Returns the running Hub version.
**Response:**
```json
{
"requires_setup": true
}
{ "version": "2.0.0" }
```
### POST /login
Authenticates a dashboard user and sets the `hw_auth` cookie.
**Request:**
```json
{ "password": "your_password" }
```
**Response:** `200 OK` with cookie on success.
**Errors:** `401 Unauthorized` for invalid credentials, `429 Too Many Requests` after repeated failures.
### POST /logout
Invalidates the current dashboard session and redirects the client to `/`.
**Response:** `303 See Other`
### GET /api/v1/setup/status
Returns whether the Hub requires initial setup.
When `HW_DASHBOARD_PASSWORD` is set, setup is considered locked and this endpoint returns `requires_setup: false`.
**Response:**
```json
{ "requires_setup": true }
```
### POST /api/v1/setup
Initializes the runtime configuration and secures the Hub. Fails with `403 Forbidden` if the system has already been set up or if the environment variable lock is active.
**Payload:**
Completes first-time hub setup and stores the hashed admin password, hub endpoint, and hub API key.
If `HW_DASHBOARD_PASSWORD` is set, setup is blocked.
**Request:**
```json
{
"password": "super_secure_password123",
@@ -108,157 +72,43 @@ Initializes the runtime configuration and secures the Hub. Fails with `403 Forbi
"hub_key": "hw_sk_randomstring"
}
```
**Response:** `200 OK`
---
**Errors:** `403 Forbidden` if setup is locked or already complete.
### GET /api/v1/config
Retrieves the runtime settings.
**Requires Authentication:** Yes (UI Cookie)
## Dashboard Endpoints (UI Cookie Required)
**Default Values (On first boot):**
* `auto_archive_days` / `auto_purge_days`: `0` (Disabled)
* `webhook_type`: `ntfy`
* `webhook_events`: `["critical", "high", "medium", "low", "info"]`
These endpoints require a valid `hw_auth` session cookie.
### GET /api/v1/ws
Upgrades the connection to a WebSocket for realtime dashboard updates.
All messages use the envelope:
**Response:**
```json
{
"hub_endpoint": "https://honeywire.my-domain.com",
"hub_key": "hw_sk_randomstring",
"auto_archive_days": 0,
"auto_purge_days": 30,
"webhook_type": "ntfy",
"webhook_url": "https://ntfy.sh/my_alerts",
"webhook_events": ["critical", "high", "medium", "low", "info"]
}
{ "type": "<MESSAGE_TYPE>", "payload": { ... } }
```
### PATCH /api/v1/config
Partially updates the runtime configuration. Omitted fields are ignored and remain unchanged in the database. Valid types for `webhook_type` are: `ntfy`, `gotify`, `discord`, `slack`.
**Requires Authentication:** Yes (UI Cookie)
Common message types:
**Payload Example:**
```json
{
"auto_archive_days": 14,
"webhook_type": "discord",
"webhook_url": "https://discord.com/api/webhooks/...",
"webhook_events": ["critical", "high"]
}
```
**Response:** `200 OK`
- `NEW_EVENT` — a sensor reported an event.
- `NEW_SENSOR` — a new sensor was discovered.
- `SENSOR_HEARTBEAT` — a sensor heartbeat was received.
- `NODE_SYNCED` — a node reported that pending config was applied.
- `SILENCE_SENSOR` — a sensor silence state changed.
- `SYNC_CHARTS` — instructs UIs to refresh chart data.
---
### GET /api/v1/manifests
### GET /api/v1/version
Returns the running Hub version.
**Response**
```json
{ "version": "1.0.0" }
```
### GET /api/v1/system/state
Returns the current armed/disarmed state. Disarmed hubs log events normally but suppress all push notifications.
**Response**
```json
{ "is_armed": true }
```
### PATCH /api/v1/system/state
Sets the armed state.
**Request**
```json
{ "is_armed": false }
```
**Response**
```json
{ "status": "success", "is_armed": false }
```
---
### PATCH /api/v1/system/password
Updates the Master Password. The current password must be provided and validated against the database. On success, all active sessions are terminated. Fails with `403 Forbidden` if the `HW_DASHBOARD_PASSWORD` environment variable is set.
**Requires Authentication:** Yes (UI Cookie)
**Payload:**
```json
{
"current_password": "old_password123",
"new_password": "new_password456"
}
```
**Response:** `200 OK`
---
---
### POST /api/v1/system/reset
Performs a full factory reset. Wipes all events, sensors, heartbeats, and configurations. The Hub will immediately revert to Setup mode. Terminates all active sessions.
**Requires Authentication:** Yes (UI Cookie)
**Payload:**
```json
{
"password": "your_master_password"
}
```
**Response:** `200 OK`
**Errors**:
* 400 Bad Request if the payload is missing/malformed.
* 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
Fetches the sensor manifest catalog from `HW_MANIFEST_URL` or the default public manifest registry.
### 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**
Generates a `docker-compose.yml` preview from the selected sensor manifests and UI-provided environment values.
**Request:**
```json
{
"node_id": "node-12345678",
@@ -267,223 +117,281 @@ Generates a raw `docker-compose.yml` string for the target node based on selecte
"sensors": [
{
"sensor_id": "alpha-node-01",
"env_values": {},
"env_values": {
"HW_SEVERITY": "medium"
},
"manifest": { ... }
}
]
}
```
**Response** `application/x-yaml` payload containing the composed Docker instructions.
**Response:** `application/x-yaml` compose payload.
## Sensor Fleet
### Nodes and Sensors
### GET /api/v1/sensors
#### POST /api/v1/nodes
Returns all registered sensors with live status and metadata. A sensor is considered `offline` if its last heartbeat arrived more than 90 seconds ago.
Creates a new node entry and returns the generated node credentials.
**Response example**
**Request:**
```json
[
{
"node_id": "node-12345678",
"sensor_id": "tarpit-01",
"last_seen": "2026-04-07 15:25:11",
"metadata": {
"agent_version": "1.0.0",
"contract_version": "1.0",
"sensor_type": "web_honeypot"
},
"status": "online",
"is_silenced": false
{
"alias": "production-db-node",
"tags": ["database", "prod"]
}
```
**Response:**
```json
{
"node_id": "node-12345678",
"api_key": "hw_key_abcd1234...",
"alias": "production-db-node"
}
```
#### GET /api/v1/nodes
Returns all registered nodes and their installed sensors. Each node includes current status, heartbeat metadata, and pending config state.
#### GET /api/v1/nodes/{id}
Returns details for a single node, including installed sensors and per-sensor event counts.
#### PATCH /api/v1/nodes/{id}
Updates a node's alias, tags, public IP, or private IP.
**Request:**
```json
{
"alias": "prod-db-node",
"tags": ["database","primary"],
"publicIp": "198.51.100.12",
"privateIp": "10.0.0.12"
}
```
#### DELETE /api/v1/nodes/{id}
Deletes a node and cascades to remove its sensors, event history, and heartbeat records.
#### POST /api/v1/nodes/{id}/sensors
Adds a sensor to a node and flags the node as pending configuration sync.
**Request:**
```json
{
"sensor_id": "hw-tcp-tarpit",
"custom_name": "TCP Tarpit",
"config_values": {
"HW_SEVERITY": "high"
}
]
}
```
---
#### PUT /api/v1/nodes/{id}/sensors/{sensor_id}
### PATCH /api/v1/sensors/{sensor_id}/silence
Updates a sensor's custom name and config values and marks the parent node pending sync.
Silences or un-silences a sensor. Silenced sensors continue logging events to the database but never trigger push notifications, regardless of the system armed state.
#### DELETE /api/v1/nodes/{id}/sensors/{sensor_id}
**Request**
Removes a sensor from the node and marks the node pending sync.
#### PATCH /api/v1/nodes/{id}/sensors/{sensor_id}/silence
Toggles a sensor's silence state. Silenced sensors still log events but suppress push notifications.
**Request:**
```json
{ "node_id": "node-12345678", "is_silenced": true }
{ "is_silenced": true }
```
**Response**
**Response:**
```json
{ "status": "success", "node_id": "node-12345678", "sensor_id": "tarpit-01", "is_silenced": true }
{
"status": "success",
"node_id": "node-12345678",
"sensor_id": "hw-tcp-tarpit",
"is_silenced": true
}
```
---
### Configuration and System Settings
### DELETE /api/v1/sensors/{sensor_id}
#### GET /api/v1/config
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.
Returns runtime configuration loaded from SQLite.
**Query parameters**
| Parameter | Values | Default | Description |
|---|---|---|---|
| `node_id` | any node ID | — | The node owning the sensor |
**Response**
**Response:**
```json
{ "status": "success", "message": "Sensor forgotten successfully" }
{
"hub_endpoint": "https://honeywire.my-domain.com",
"hub_key": "hw_sk_randomstring",
"auto_archive_days": 90,
"auto_purge_days": 180,
"webhook_url": "",
"webhook_type": "none",
"webhook_events": [],
"siem_address": "",
"siem_protocol": "syslog"
}
```
**Error**`404 Not Found` if the sensor ID does not exist.
#### PATCH /api/v1/config
---
Updates runtime settings. Only supported fields are applied. This endpoint also hot-reloads webhook and SIEM configuration.
### GET /api/v1/uptime
**Supported fields:**
- `hub_endpoint`
- `hub_key`
- `auto_archive_days`
- `auto_purge_days`
- `webhook_url`
- `webhook_type` (`ntfy`, `gotify`, `discord`, `slack`, `none`)
- `webhook_events`
- `siem_address`
- `siem_protocol` (`tcp`, `udp`)
Returns heatmap data used by the Fleet Health dashboard. The response is an array of per-sensor block sequences where each block represents a time window and carries a status of `up`, `degraded`, `down`, or `nodata`.
**Query parameters**
| Parameter | Values | Default |
|---|---|---|
| `timeframe` | `1H`, `24H`, `7D`, `30D` | `24H` |
| Timeframe | Blocks | Window per block |
|---|---|---|
| `1H` | 30 | 2 minutes |
| `24H` | 24 | 1 hour |
| `7D` | 7 | 1 day |
| `30D` | 30 | 1 day |
**Response example**
**Request example:**
```json
[
{
"id": "tarpit-01",
"node_id": "node-12345678",
"name": "tarpit-01",
"isOnline": true,
"blocks": [
{ "status": "up", "timeLabel": "23 hours ago", "label": "Online" },
{ "status": "degraded", "timeLabel": "4 hours ago", "label": "Degraded (47/60 pings)" },
{ "status": "up", "timeLabel": "Current", "label": "Online (Live)" }
]
}
]
{
"auto_archive_days": 14,
"webhook_type": "discord",
"webhook_url": "https://discord.com/api/webhooks/...",
"webhook_events": ["critical", "high"]
}
```
---
#### GET /api/v1/system/state
## Events
Returns whether the Hub is armed.
**Response:**
```json
{ "is_armed": true }
```
#### PATCH /api/v1/system/state
Sets the armed/disarmed state.
**Request:**
```json
{ "is_armed": false }
```
#### PATCH /api/v1/system/password
Changes the dashboard admin password. When `HW_DASHBOARD_PASSWORD` is set, this operation is forbidden.
**Request:**
```json
{
"current_password": "old_password",
"new_password": "new_password"
}
```
#### POST /api/v1/system/reset
Performs a full factory reset of the runtime database, clearing events, sensors, heartbeats, and config.
Requires the current admin password.
**Request:**
```json
{ "password": "your_master_password" }
```
**Response:** `200 OK`
## Fleet and Events
### GET /api/v1/events/unread
Returns the count of active (non-archived), unread events. Intended for lightweight badge updates — does not return event payloads.
Returns the count of active, unread events.
**Response**
**Response:**
```json
{ "count": 12 }
```
---
### GET /api/v1/events
Returns a list of events, newest first.
**Query parameters**
| Parameter | Values | Default | Description |
|---|---|---|---|
| `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.
---
**Query parameters:**
- `archived``true` or `false` (default: `false`)
- `node_id` — filter by node ID
- `sensor_id` — filter by sensor ID
### PATCH /api/v1/events/read
Marks all unread active events as read.
**Response**
```json
{ "status": "success" }
```
---
Marks all active events as read.
### PATCH /api/v1/events/{event_id}/read
Marks a single event as read.
**Response**
```json
{ "status": "success" }
```
---
### PATCH /api/v1/events/{event_id}/archive
Archives a single event, marking it as read and hiding it from the active event view.
**Response**
```json
{ "status": "success" }
```
---
Archives a single event and marks it as read.
### PATCH /api/v1/events/archive-all
Archives all currently active (non-archived) events in bulk.
**Response**
```json
{ "status": "success" }
```
---
Archives all active events.
### DELETE /api/v1/events
Permanently deletes all events from the database. This action is irreversible and is logged server-side with the caller's IP address.
Deletes all events permanently.
**Query parameters**
**Query parameters:**
- `dryrun=true` — return the count of deletable events without deleting them.
| Parameter | Values | Default | Description |
|---|---|---|---|
| `dryrun` | `true`, `false` | `false` | If true, returns the count of events that *would* be deleted without executing the deletion. |
**Response (Standard)**
**Response (dryrun):**
```json
{
"status": "success",
"dryrun": false
}
```
**Response (Dryrun)**
```json
{
{
"status": "success",
"dryrun": true,
"would_delete": 4512
}
```
---
**Response:**
```json
{ "status": "success", "dryrun": false }
```
## Uptime and Health
### GET /api/v1/uptime
Returns uptime blocks for fleet health charts.
**Query parameters:**
- `timeframe``1H`, `24H`, `7D`, `30D` (default: `24H`)
## Node Compose
### GET /api/v1/nodes/compose
Returns a generated `docker-compose.yml` for a node based on the node's current installed sensors.
**Authentication:** `Authorization: Bearer <HW_NODE_KEY>`
This endpoint also updates the node's active revision and clears its pending config flag when successfully fetched.
## Agent Endpoints
These endpoints are called by sensors, not the dashboard. Both require the configured database `hub_key`.
These endpoints are used by sensor agents and require the node API key bearer token.
### POST /api/v1/heartbeat
Called by sensors every 30 seconds to signal they are alive and update their metadata. If this is the first heartbeat from a given `sensor_id`, the sensor is registered automatically and a `NEW_SENSOR` WebSocket message is broadcast to all dashboard clients.
Reports a sensor heartbeat and updates runtime metadata.
**Request**
**Request:**
```json
{
"node_id": "node-12345678",
@@ -491,25 +399,22 @@ Called by sensors every 30 seconds to signal they are alive and update their met
"metadata": {
"agent_version": "1.0.0",
"contract_version": "1.0",
"sensor_type": "tarpit"
"sensor_type": "tarpit",
"HW_CONFIG_REV": "rev_abc123"
}
}
```
**Response**
**Response:**
```json
{ "status": "alive" }
```
---
### POST /api/v1/event
Reports an intrusion event to the Hub. The Hub validates that the `contract_version` major number matches its own before accepting the event. On a mismatch, `426 Upgrade Required` is returned and the sensor should be updated.
Reports an intrusion event to the Hub.
If the Hub is armed and the reporting sensor is not silenced, a push notification is dispatched immediately via the configured notifiers (ntfy/Gotify/Discord/Slack).
**Request**
**Request:**
```json
{
"contract_version": "1.0",
@@ -521,11 +426,6 @@ If the Hub is armed and the reporting sensor is not silenced, a push notificatio
"target": "Auth Gateway",
"details": {
"protocol": "TCP",
"headers_stripped": true,
"payload_sample": [
"Authorization: Bearer eyJhbG... [TRUNCATED]",
"User-Agent: curl/7.64.1"
],
"action_taken": "logged"
}
}
@@ -533,37 +433,11 @@ If the Hub is armed and the reporting sensor is not silenced, a push notificatio
**Severity values:** `info`, `low`, `medium`, `high`, `critical`
**Response**
**Response:**
```json
{ "status": "success" }
```
**Errors**
| Status | Meaning |
|---|---|
| `400 Bad Request` | Malformed JSON or missing `contract_version` |
| `426 Upgrade Required` | Major version mismatch between sensor and Hub |
---
## Dashboard Auth
### POST /login
Authenticates a dashboard session. On success, sets an HTTP-only `hw_auth` cookie valid for 30 days. Repeated failed attempts from the same IP are rate-limited: 10 failures triggers a 15-minute lockout.
**Request**
```json
{ "password": "your_password" }
```
**Response**`200 OK` with cookie on success, `401 Unauthorized` on wrong password, `429 Too Many Requests` during lockout.
---
### POST /logout
Invalidates the current session token and clears the `hw_auth` cookie.
**Response** — Redirects to `/`.
**Errors:**
- `400 Bad Request` for invalid JSON or missing required fields.
- `426 Upgrade Required` if the sensor major version does not match the Hub.
+62 -27
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
@@ -17,42 +18,47 @@ import (
"github.com/honeywire/hub/internal/store"
)
// We will eventually load this from internal/config
const Version = "1.1.0"
const Version = "2.0.0"
// loadConfigSafe is a helper to fetch DB configs without panicking on empty/missing rows
func loadConfigSafe(s *store.SQLiteStore, key string, fallback string) string {
val, err := s.GetConfigValue(key)
if err != nil || val == "" {
return fallback
}
return val
}
func main() {
log.Println("Starting HoneyWire Go Hub initialization...")
log.Printf("Starting HoneyWire Hub v%s initialization...\n", Version)
cfg := config.Load()
dbStore, err := store.NewStore(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
log.Fatalf("[FATAL] Failed to initialize database: %v", err)
}
defer dbStore.DB.Close()
sessionStore := auth.NewSessionStore()
r, err := api.SetupRouter(cfg, dbStore, sessionStore)
if err != nil {
log.Fatalf("[FATAL] Router setup failed: %v", err)
}
r := api.SetupRouter(cfg, dbStore, sessionStore)
// 1. Start External Workers
notify.StartWorker()
siem.StartWorker()
// Load initial configurations from DB
var isArmed, webhookType, webhookURL, webhookEvents string
dbStore.DB.QueryRow("SELECT value FROM config WHERE key = 'is_armed'").Scan(&isArmed)
dbStore.DB.QueryRow("SELECT value FROM config WHERE key = 'webhook_type'").Scan(&webhookType)
dbStore.DB.QueryRow("SELECT value FROM config WHERE key = 'webhook_url'").Scan(&webhookURL)
dbStore.DB.QueryRow("SELECT value FROM config WHERE key = 'webhook_events'").Scan(&webhookEvents)
notify.UpdateConfig(isArmed == "true", webhookType, webhookURL, webhookEvents)
// 2. Load Runtime Configurations Safely
isArmed := loadConfigSafe(dbStore, "is_armed", "false") == "true"
webhookType := loadConfigSafe(dbStore, "webhook_type", "ntfy")
webhookURL := loadConfigSafe(dbStore, "webhook_url", "")
webhookEvents := loadConfigSafe(dbStore, "webhook_events", "[]")
notify.UpdateConfig(isArmed, webhookType, webhookURL, webhookEvents)
var siemAddress, siemProtocol string
dbStore.DB.QueryRow("SELECT value FROM config WHERE key = 'siem_address'").Scan(&siemAddress)
dbStore.DB.QueryRow("SELECT value FROM config WHERE key = 'siem_protocol'").Scan(&siemProtocol)
if siemProtocol == "" {
siemProtocol = "tcp" // default
}
siemAddress := loadConfigSafe(dbStore, "siem_address", "")
siemProtocol := loadConfigSafe(dbStore, "siem_protocol", "tcp")
siem.UpdateConfig(siemAddress, siemProtocol)
if siemAddress != "" {
@@ -61,32 +67,37 @@ func main() {
log.Println("[SIEM] Forwarding disabled (no address configured).")
}
// Start the HTTP server in a goroutine
// 3. Start Database Retention Worker (cancelable)
rootCtx, rootCancel := context.WithCancel(context.Background())
defer rootCancel()
go startRetentionWorker(rootCtx, dbStore)
// 4. Start HTTP Server
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
log.Printf("Server listening on port %s\n", cfg.Port)
log.Printf("[*] Server listening on port %s\n", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
log.Fatalf("[FATAL] Server failed to start: %v", err)
}
}()
// Graceful shutdown handling
// 5. Graceful Shutdown Handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
// Block until we receive a shutdown signal
<-sigChan
log.Println("\n[*] Received shutdown signal. Initiating graceful shutdown...")
// Create a context with a 10-second timeout for graceful shutdown
// Signal background workers to stop
rootCancel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Stop accepting new HTTP connections
if err := server.Shutdown(ctx); err != nil {
log.Printf("[!] Server forced to shut down: %v\n", err)
}
@@ -103,3 +114,27 @@ func main() {
log.Println("[*] Graceful shutdown complete. Goodbye!")
}
// startRetentionWorker wakes up periodically to archive/purge old events based on DB settings
func startRetentionWorker(ctx context.Context, s *store.SQLiteStore) {
// Wake up every hour to check retention
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("[Retention] worker stopped")
return
case <-ticker.C:
archiveDays, _ := strconv.Atoi(loadConfigSafe(s, "auto_archive_days", "0"))
purgeDays, _ := strconv.Atoi(loadConfigSafe(s, "auto_purge_days", "0"))
if archiveDays > 0 || purgeDays > 0 {
if err := s.EnforceRetention(archiveDays, purgeDays); err != nil {
log.Printf("[WARNING] Event retention task failed: %v", err)
}
}
}
}
}
+14 -25
View File
@@ -134,45 +134,34 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- Per-Node Authentication ---
// nodeAuthCache stores per-node keys in memory for fast validation
// Key: node_id, Value: node_key (64-char hex string)
// 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 {
// Extracts Bearer token from Authorization header and maps it to the expected node_id
func (h *Handler) validateNodeAuth(r *http.Request, expectedNodeID string) bool {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return false
}
// Parse "Bearer <token>" format
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return false
}
token := parts[1]
token := parts[1] // This is the api_key (hw_key_...)
// Check cache first
if cachedKey, ok := h.nodeAuthCache.Load(nodeID); ok {
return subtle.ConstantTimeCompare([]byte(token), []byte(cachedKey.(string))) == 1
// 1. Check cache first (Key: Token, Value: NodeID)
if cachedNodeID, ok := h.nodeAuthCache.Load(token); ok {
return subtle.ConstantTimeCompare([]byte(cachedNodeID.(string)), []byte(expectedNodeID)) == 1
}
// Cache miss - query database
nodeKey, err := h.Store.GetNodeKey(nodeID)
if err != nil {
// 2. Cache miss - query database to find the Node ID that owns this API key
actualNodeID, err := h.Store.GetNodeByKey(token)
if err != nil || actualNodeID == "" {
return false
}
// Cache the key for future requests
h.nodeAuthCache.Store(nodeID, nodeKey)
// 3. Cache the valid token-to-node mapping for future requests
h.nodeAuthCache.Store(token, actualNodeID)
// Validate token
return subtle.ConstantTimeCompare([]byte(token), []byte(nodeKey)) == 1
}
// invalidateNodeCache removes a node from the auth cache
// Called after node deletion or key rotation
func (h *Handler) invalidateNodeCache(nodeID string) {
h.nodeAuthCache.Delete(nodeID)
}
// 4. Validate that the token's owner matches the Node ID claiming the request
return subtle.ConstantTimeCompare([]byte(actualNodeID), []byte(expectedNodeID)) == 1
}
-21
View File
@@ -7,7 +7,6 @@ import (
"strings"
"sort"
"github.com/honeywire/hub/internal/auth"
"gopkg.in/yaml.v3"
)
@@ -93,26 +92,6 @@ func (h *Handler) GenerateCompose(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid payload", http.StatusBadRequest)
return
}
// 1. DUAL AUTHENTICATION CHECK
isAuthorized := false
if cookie, err := r.Cookie(auth.CookieName); err == nil {
if h.SessionStore.IsValid(cookie.Value) {
isAuthorized = true
}
}
if !isAuthorized {
isAuthorized = h.validateNodeAuth(r, req.NodeID)
}
if !isAuthorized {
http.Error(w, "Unauthorized: Valid UI Session or Node Key required", http.StatusUnauthorized)
return
}
// 2. COMPOSE GENERATION
var compose ComposeFile
for _, s := range req.Sensors {
+4 -6
View File
@@ -27,7 +27,7 @@ func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) {
return
}
// Per-node authentication
// Per-node authentication (API Key -> NodeID)
if !h.validateNodeAuth(r, e.NodeID) {
RespondError(w, "Unauthorized", http.StatusUnauthorized)
return
@@ -55,10 +55,8 @@ func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) {
e.ID = lastInsertID
e.Timestamp = nowStr
// Update node last_seen
if err := h.Store.UpdateNodeLastSeen(e.NodeID, nowStr); err != nil {
log.Printf("[WARNING] Failed to update node %s last_seen: %v", e.NodeID, err)
}
// update node and sensor last_heartbeat (an event proves it is alive)
h.Store.UpdateNodeLastHeartbeat(e.NodeID, e.SensorID, nowStr)
// Check if sensor is silenced
isSilenced, err := h.Store.IsSensorSilenced(e.NodeID, e.SensorID)
@@ -169,4 +167,4 @@ func (h *Handler) ClearEvents(w http.ResponseWriter, r *http.Request) {
"status": "success",
"dryrun": false,
})
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
@@ -56,7 +56,7 @@ func SendJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
fmt.Printf("SendJSON encode error: %v\n", err)
log.Printf("[ERROR] SendJSON encode error: %v\n", err)
}
}
+267
View File
@@ -0,0 +1,267 @@
package api
import (
"encoding/json"
"net/http"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"github.com/go-chi/chi/v5"
)
// CreateNode handles UI-first node creation
// Route: POST /api/v1/nodes
func (h *Handler) CreateNode(w http.ResponseWriter, r *http.Request) {
var req struct {
Alias string `json:"alias"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Alias == "" {
RespondError(w, "Alias is required", http.StatusBadRequest)
return
}
tagsJSON, _ := json.Marshal(req.Tags)
if string(tagsJSON) == "null" {
tagsJSON = []byte("[]")
}
nodeID, apiKey, err := h.Store.CreateNode(req.Alias, string(tagsJSON))
if err != nil {
RespondError(w, "Failed to create node in database", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusCreated, map[string]interface{}{
"node_id": nodeID,
"api_key": apiKey,
"alias": req.Alias,
})
}
// UpdateNode handles UI requests to edit a Node's metadata
// Route: PATCH /api/v1/nodes/{id}
func (h *Handler) UpdateNode(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "id")
var req struct {
Alias string `json:"alias"`
Tags []string `json:"tags"`
PublicIP *string `json:"publicIp"`
PrivateIP *string `json:"privateIp"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, "Invalid payload", http.StatusBadRequest)
return
}
if req.Alias == "" {
RespondError(w, "Alias cannot be empty", http.StatusBadRequest)
return
}
tagsJSON, _ := json.Marshal(req.Tags)
if string(tagsJSON) == "null" {
tagsJSON = []byte("[]")
}
if err := h.Store.UpdateNodeMeta(nodeID, req.Alias, string(tagsJSON), req.PublicIP, req.PrivateIP); err != nil {
RespondError(w, "Failed to update node", http.StatusInternalServerError)
return
}
// Optional: Broadcast WS to UI to instantly update the Fleet grid
h.broadcastWS("UPDATE_NODE", map[string]string{"node_id": nodeID})
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
}
// GetNodes handles GET /api/v1/nodes
func (h *Handler) GetNodes(w http.ResponseWriter, r *http.Request) {
nodes, err := h.Store.GetNodes()
if err != nil {
RespondError(w, "Failed to fetch fleet nodes", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, nodes)
}
// GetNodeDetails handles GET /api/v1/nodes/{id}
func (h *Handler) GetNodeDetails(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "id")
node, err := h.Store.GetNodeDetails(nodeID)
if err != nil {
RespondError(w, "Node not found", http.StatusNotFound)
return
}
SendJSON(w, http.StatusOK, node)
}
// AddNodeSensor handles POST /api/v1/nodes/{id}/sensors
func (h *Handler) AddNodeSensor(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "id")
var req struct {
SensorID string `json:"sensor_id"`
CustomName string `json:"custom_name"`
ConfigValues map[string]interface{} `json:"config_values"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, "Invalid payload", http.StatusBadRequest)
return
}
if req.SensorID == "" || req.CustomName == "" {
RespondError(w, "sensor_id and custom_name are required", http.StatusBadRequest)
return
}
err := h.Store.AddSensorToNode(nodeID, req.SensorID, req.CustomName, req.ConfigValues)
if err != nil {
RespondError(w, "Failed to add sensor. It may already be installed on this node.", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "Sensor added, node pending sync"})
}
// EditNodeSensor handles PUT /api/v1/nodes/{id}/sensors/{sensor_id}
func (h *Handler) EditNodeSensor(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "id")
sensorID := chi.URLParam(r, "sensor_id")
var req struct {
CustomName string `json:"custom_name"`
ConfigValues map[string]interface{} `json:"config_values"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, "Invalid payload", http.StatusBadRequest)
return
}
if err := h.Store.UpdateNodeSensor(nodeID, sensorID, req.CustomName, req.ConfigValues); err != nil {
RespondError(w, "Failed to update sensor", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
}
// DeleteNodeSensor handles DELETE /api/v1/nodes/{id}/sensors/{sensor_id}
func (h *Handler) DeleteNodeSensor(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "id")
sensorID := chi.URLParam(r, "sensor_id")
if err := h.Store.RemoveNodeSensor(nodeID, sensorID); err != nil {
RespondError(w, "Failed to remove sensor", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
}
// generateRevisionHash creates a random 8-char string for tracking config state
func generateRevisionHash() string {
b := make([]byte, 4)
rand.Read(b)
return "rev_" + hex.EncodeToString(b)
}
// GetNodeCompose generates the official docker-compose.yml for a specific agent
// Authentication: Bearer <API_KEY>
func (h *Handler) GetNodeCompose(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
RespondError(w, "Missing or invalid Authorization header", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
// Auth check - Get NodeID from DB using the API Key
nodeID, err := h.Store.GetNodeByKey(token)
if err != nil || nodeID == "" {
RespondError(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Fetch configured sensors from DB
nodeDetails, err := h.Store.GetNodeDetails(nodeID)
if err != nil {
RespondError(w, "Failed to load node configuration", http.StatusInternalServerError)
return
}
// Fetch Hub Endpoint from runtime DB config
hubEndpoint, err := h.Store.GetConfigValue("hub_endpoint")
if err != nil || hubEndpoint == "" {
hubEndpoint = "https://" + r.Host // Fallback
}
// Generate HW_CONFIG_REV
newRevision := generateRevisionHash()
// Build YAML skeleton
// Note: In the final version, this will merge with remote manifests.
// For now, it dynamically constructs the base YAML so the agent can boot.
var sb strings.Builder
sb.WriteString("version: '3.8'\n\nservices:\n")
for _, sensor := range nodeDetails.InstalledSensors {
sb.WriteString(fmt.Sprintf(" %s:\n", sensor.ID))
sb.WriteString(fmt.Sprintf(" image: ghcr.io/honeywire/%s:latest\n", sensor.ID))
sb.WriteString(" restart: always\n")
sb.WriteString(" environment:\n")
// Use the dynamically fetched hubEndpoint
sb.WriteString(fmt.Sprintf(" - HW_HUB_URL=%s\n", hubEndpoint))
sb.WriteString(fmt.Sprintf(" - HW_HUB_KEY=%s\n", token))
sb.WriteString(fmt.Sprintf(" - HW_NODE_ID=%s\n", nodeID))
sb.WriteString(fmt.Sprintf(" - HW_CONFIG_REV=%s\n", newRevision))
for k, v := range sensor.EnvVars {
sb.WriteString(fmt.Sprintf(" - %s=%v\n", k, v))
}
sb.WriteString("\n")
}
yamlString := sb.String()
// Update Database: Set the new active revision and clear the pending flag
if err := h.Store.ApplyNodeRevision(nodeID, newRevision); err != nil {
RespondError(w, "Failed to update node state", http.StatusInternalServerError)
return
}
// Serve the raw YAML
w.Header().Set("Content-Type", "application/x-yaml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(yamlString))
}
// DeleteNode handles DELETE /api/v1/nodes/{id}
func (h *Handler) DeleteNode(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "id")
if err := h.Store.DeleteNode(nodeID); err != nil {
RespondError(w, "Failed to delete node", http.StatusInternalServerError)
return
}
// Broadcast WS to UI to instantly remove the row from the Fleet grid
h.broadcastWS("DELETE_NODE", map[string]string{"node_id": nodeID})
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
}
-93
View File
@@ -1,93 +0,0 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
)
// GenerateToken creates a one-time pairing token for the wizard
// Protected by UI authentication (dashboard session cookie)
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 {
RespondError(w, "Token generation failed", http.StatusInternalServerError)
return
}
token := hex.EncodeToString(tokenBytes)
// Token expires in 15 minutes
now := time.Now()
expiresAt := now.Add(15 * time.Minute)
if err := h.Store.InsertPairingToken(token, expiresAt, now); err != nil {
RespondError(w, "Failed to generate token", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"expires_in": 900, // 15 minutes in seconds
})
}
// WizardLink handles node provisioning from the wizard
// Public endpoint - creates a new node with one-time token exchange
func (h *Handler) WizardLink(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
Alias string `json:"alias"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, "Invalid request", http.StatusBadRequest)
return
}
if req.Token == "" {
RespondError(w, "Token is required", http.StatusBadRequest)
return
}
// Validate token exists and is not expired
isValid, err := h.Store.ValidatePairingToken(req.Token)
if err != nil || !isValid {
RespondError(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Generate node credentials
nodeID := uuid.New().String()
nodeKeyBytes := make([]byte, 32)
if _, err := rand.Read(nodeKeyBytes); err != nil {
RespondError(w, "Node key generation failed", http.StatusInternalServerError)
return
}
nodeKey := hex.EncodeToString(nodeKeyBytes)
if req.Alias == "" {
req.Alias = "node-" + nodeID[:8] // Default alias if none provided
}
// Get client IP address
clientIP := h.getRealIP(r)
// Insert new node
now := time.Now().Format(time.RFC3339)
if err := h.Store.CreateNode(nodeID, req.Alias, nodeKey, clientIP, now); err != nil {
RespondError(w, "Failed to create node", http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, map[string]string{
"node_id": nodeID,
"node_key": nodeKey,
"alias": req.Alias,
"timestamp": now,
})
}
+24 -20
View File
@@ -1,6 +1,7 @@
package api
import (
"fmt"
"io/fs"
"log"
"net/http"
@@ -21,7 +22,6 @@ func ErrorOnlyLogger(next http.Handler) http.Handler {
next.ServeHTTP(ww, r)
// Only print to the terminal if something went wrong
if ww.Status() >= 400 {
log.Printf("[-] HTTP %d | %s %s from %s (took %v)",
ww.Status(), r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
@@ -29,7 +29,7 @@ func ErrorOnlyLogger(next http.Handler) http.Handler {
})
}
func SetupRouter(cfg *config.Config, s *store.SQLiteStore, sessionStore *auth.SessionStore) *chi.Mux {
func SetupRouter(cfg *config.Config, s *store.SQLiteStore, sessionStore *auth.SessionStore) (*chi.Mux, error) {
r := chi.NewRouter()
r.Use(ErrorOnlyLogger)
@@ -43,18 +43,29 @@ func SetupRouter(cfg *config.Config, s *store.SQLiteStore, sessionStore *auth.Se
r.Post("/logout", h.Logout)
r.Get("/api/v1/setup/status", h.GetSetupStatus)
r.Post("/api/v1/setup", h.CompleteSetup)
r.Post("/api/v1/wizard/link", h.WizardLink)
// UI Endpoints (Protected by Cookies)
r.Group(func(r chi.Router) {
r.Use(UIAuthMiddleware(sessionStore))
r.Get("/api/v1/ws", h.ServeWS)
r.Get("/api/v1/manifests", h.GetManifests) // Fetches catalog
r.Get("/api/v1/manifests", h.GetManifests)
// UI Compose Preview
r.Post("/api/v1/compose/generate", h.GenerateCompose) // Used by UI modal for live preview
// Provisioning
r.Post("/api/v1/tokens/generate", h.GenerateToken)
// --- Node & Fleet Management ---
r.Post("/api/v1/nodes", h.CreateNode)
r.Get("/api/v1/nodes", h.GetNodes)
r.Get("/api/v1/nodes/{id}", h.GetNodeDetails)
r.Patch("/api/v1/nodes/{id}", h.UpdateNode)
r.Delete("/api/v1/nodes/{id}", h.DeleteNode)
// --- Sensor Management ---
r.Post("/api/v1/nodes/{id}/sensors", h.AddNodeSensor)
r.Put("/api/v1/nodes/{id}/sensors/{sensor_id}", h.EditNodeSensor)
r.Delete("/api/v1/nodes/{id}/sensors/{sensor_id}", h.DeleteNodeSensor)
r.Patch("/api/v1/nodes/{id}/sensors/{sensor_id}/silence", h.ToggleSilence)
// System Configuration & Danger Zone
r.Get("/api/v1/config", h.GetConfig)
@@ -62,8 +73,7 @@ func SetupRouter(cfg *config.Config, s *store.SQLiteStore, sessionStore *auth.Se
r.Patch("/api/v1/system/password", h.ChangePassword)
r.Post("/api/v1/system/reset", h.FactoryReset)
// Telemetry & State
r.Get("/api/v1/sensors", h.GetSensors)
// Telemetry & State (For UI Dashboards)
r.Get("/api/v1/events", h.GetEvents)
r.Get("/api/v1/uptime", h.GetUptime)
r.Get("/api/v1/system/state", h.GetSystemState)
@@ -76,28 +86,22 @@ func SetupRouter(cfg *config.Config, s *store.SQLiteStore, sessionStore *auth.Se
r.Delete("/api/v1/events", h.ClearEvents)
r.Patch("/api/v1/events/{event_id}/archive", h.ArchiveEvent)
r.Patch("/api/v1/events/archive-all", h.ArchiveAll)
// Sensor Management
r.Patch("/api/v1/sensors/{sensor_id}/silence", h.ToggleSilence)
r.Delete("/api/v1/sensors/{sensor_id}", h.ForgetSensor)
})
// --- Sensor Endpoints ---
// no middleware, Node authentication requires the 'node_id', which is inside the JSON payload.
// If a middleware reads the http.Request Body to extract the ID, it drains the IO stream.
// Therefore, auth is checked inside the handler immediately AFTER the JSON is parsed.
// --- Wizard & Telemetry Endpoints ---
// Authentication is handled via API Key (Bearer Token) inside the handler
r.Get("/api/v1/nodes/compose", h.GetNodeCompose) // aggreagates all generated compose files for a node's sensors
r.Post("/api/v1/heartbeat", h.ReceiveHeartbeat)
r.Post("/api/v1/event", h.ReceiveEvent)
r.Post("/api/v1/compose/generate", h.GenerateCompose) // Dual Auth, both UI sessions and Nodes can generate compose files, so auth is checked inside the handler after JSON parsing.
// --- Serve the Vue Frontend ---
distFS, err := fs.Sub(ui.StaticFiles, "dist")
if err != nil {
panic("Failed to mount embedded UI files: " + err.Error())
return nil, fmt.Errorf("failed to mount embedded UI files: %w", err)
}
fileServer := http.FileServer(http.FS(distFS))
r.Handle("/*", fileServer)
return r
}
return r, nil
}
+32 -86
View File
@@ -25,7 +25,7 @@ func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) {
return
}
// Per-node authentication
// Per-node authentication (Using the Token -> NodeID cache logic)
if !h.validateNodeAuth(r, hb.NodeID) {
RespondError(w, "Unauthorized", http.StatusUnauthorized)
return
@@ -34,57 +34,45 @@ func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
nowStr := now.Format(time.RFC3339)
minuteBucket := now.Truncate(time.Minute).Format(time.RFC3339)
// Extract the revision the agent is currently running
agentRevision := ""
if rev, ok := hb.Metadata["HW_CONFIG_REV"].(string); ok {
agentRevision = rev
}
// NEW: Marshal the metadata so we can save it to the DB
metadataJSON, _ := json.Marshal(hb.Metadata)
// Check if this is a new sensor (first heartbeat)
existingSensor, _ := h.Store.GetSensor(hb.NodeID, hb.SensorID)
isNewSensor := existingSensor == nil
// Update sensor last_seen and metadata with composite key (node_id, sensor_id)
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)
// 1. Update Node & Sensor last_seen, Metadata & Reconcile Config
justSynced, err := h.Store.ProcessHeartbeat(hb.NodeID, hb.SensorID, agentRevision, nowStr, string(metadataJSON))
if err != nil {
log.Printf("[ERROR] Heartbeat DB update failed for node %s: %v", hb.NodeID, err)
RespondError(w, "Database error", http.StatusInternalServerError)
return
}
// Update node last_seen
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
// 2. Log heartbeat bucket
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)
log.Printf("[WARNING] Failed to log heartbeat bucket: %v", err)
}
// Broadcast NEW_SENSOR only on first heartbeat, then SENSOR_HEARTBEAT every time
if isNewSensor {
h.broadcastWS("NEW_SENSOR", map[string]string{
"node_id": hb.NodeID,
"sensor_id": hb.SensorID,
"timestamp": nowStr,
})
} else {
h.broadcastWS("SENSOR_HEARTBEAT", map[string]string{
"node_id": hb.NodeID,
"sensor_id": hb.SensorID,
"timestamp": nowStr,
// 3. Broadcasts
if justSynced {
h.broadcastWS("NODE_SYNCED", map[string]string{
"node_id": hb.NodeID,
})
}
h.broadcastWS("SENSOR_HEARTBEAT", map[string]string{
"node_id": hb.NodeID,
"sensor_id": hb.SensorID,
"timestamp": nowStr,
})
SendJSON(w, http.StatusOK, map[string]string{"status": "alive"})
}
func (h *Handler) GetSensors(w http.ResponseWriter, r *http.Request) {
fleet, err := h.Store.GetAllSensors()
if err != nil {
RespondError(w, err.Error(), http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, fleet)
}
func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) {
timeframe := r.URL.Query().Get("timeframe")
if timeframe == "" {
@@ -111,90 +99,49 @@ func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) ToggleSilence(w http.ResponseWriter, r *http.Request) {
// Extract both IDs from the updated URL route
nodeID := chi.URLParam(r, "id")
sensorID := chi.URLParam(r, "sensor_id")
// Add NodeID to the expected JSON payload
var req struct {
NodeID string `json:"node_id"`
IsSilenced bool `json:"is_silenced"`
IsSilenced bool `json:"is_silenced"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.NodeID == "" {
RespondError(w, "node_id is required", http.StatusBadRequest)
return
}
silenceVal := 0
if req.IsSilenced {
silenceVal = 1
}
if err := h.Store.UpdateSensorSilence(req.NodeID, sensorID, silenceVal); err != nil {
if err := h.Store.UpdateSensorSilence(nodeID, sensorID, silenceVal); err != nil {
RespondError(w, "Database error", http.StatusInternalServerError)
return
}
h.broadcastWS("SILENCE_SENSOR", map[string]interface{}{
"node_id": req.NodeID,
"node_id": nodeID,
"sensor_id": sensorID,
"is_silenced": req.IsSilenced,
})
SendJSON(w, http.StatusOK, map[string]interface{}{
"status": "success",
"node_id": req.NodeID,
"node_id": nodeID,
"sensor_id": sensorID,
"is_silenced": req.IsSilenced,
})
}
func (h *Handler) ForgetSensor(w http.ResponseWriter, r *http.Request) {
sensorID := chi.URLParam(r, "sensor_id")
// Use a URL query parameter for DELETE requests (e.g., ?node_id=1234)
nodeID := r.URL.Query().Get("node_id")
if nodeID == "" {
RespondError(w, "node_id query parameter is required", http.StatusBadRequest)
return
}
rowsAffected, err := h.Store.DeleteSensor(nodeID, sensorID)
if err != nil {
RespondError(w, "Database error while deleting sensor", http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
RespondError(w, "Sensor not found", http.StatusNotFound)
return
}
h.broadcastWS("DELETE_SENSOR", map[string]string{
"node_id": nodeID,
"sensor_id": sensorID,
})
SendJSON(w, http.StatusOK, map[string]string{
"status": "success",
"message": "Sensor forgotten successfully",
})
}
// 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) {
// 1. Determine the target URL
manifestURL := os.Getenv("HW_MANIFEST_URL")
if manifestURL == "" {
// Production fallback
manifestURL = "https://raw.githubusercontent.com/andreicscs/HoneyWire/main/Sensors/official/manifests.json"
}
// 2. Fetch the manifest
resp, err := http.Get(manifestURL)
if err != nil {
RespondError(w, "Failed to reach manifest registry", http.StatusBadGateway)
@@ -207,8 +154,7 @@ func (h *Handler) GetManifests(w http.ResponseWriter, r *http.Request) {
return
}
// 3. Proxy the JSON back to the Vue frontend
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, resp.Body)
}
}
+27 -20
View File
@@ -25,7 +25,7 @@ type Event struct {
ID int `json:"id"`
Timestamp string `json:"timestamp"`
ContractVersion string `json:"contract_version"`
SensorID string `json:"sensor_id"`
SensorID string `json:"sensor_id"` // Catalog ID (e.g. hw-tcp-tarpit)
NodeID string `json:"node_id"`
EventTrigger string `json:"event_trigger"`
Severity string `json:"severity"`
@@ -41,30 +41,37 @@ type Event struct {
type Heartbeat struct {
SensorID string `json:"sensor_id"`
NodeID string `json:"node_id"`
Metadata map[string]interface{} `json:"metadata"`
Metadata map[string]interface{} `json:"metadata"` // Contains HW_CONFIG_REV
}
// Sensor represents a known node in the fleet
type Sensor struct {
SensorID string `json:"sensor_id"`
NodeID string `json:"node_id"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
Metadata map[string]interface{} `json:"metadata"`
IsSilenced bool `json:"is_silenced"`
Status string `json:"status"`
}
// Node represents a physical server/agent managing sensors
// Node represents a physical server managing sensors
type Node struct {
NodeID string `json:"node_id"`
Alias string `json:"alias"`
IPAddress string `json:"ip_address"`
LastSeen string `json:"last_seen"`
Status string `json:"status"`
ID string `json:"id"`
Alias string `json:"alias"`
PublicIP *string `json:"publicIp"`
PrivateIP *string `json:"privateIp"`
Tags []string `json:"tags"`
HasPendingConfig bool `json:"hasPendingConfig"`
LastHeartbeat *string `json:"lastHeartbeat"`
Status string `json:"status"` // Derived status (up, down, pending)
InstalledSensors []NodeSensor `json:"installedSensors"`
}
// NodeSensor represents a deployed sensor on a node
type NodeSensor struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Display string `json:"display"`
Status string `json:"status"`
LastHeartbeat *string `json:"lastHeartbeat"`
IsSilenced bool `json:"isSilenced"`
Events24h int `json:"events24h"`
EnvVars map[string]interface{} `json:"envVars"`
Metadata map[string]interface{} `json:"metadata"`
}
// SystemState represents global hub settings
type SystemState struct {
IsArmed bool `json:"is_armed"`
}
}
+37 -13
View File
@@ -26,9 +26,10 @@ func (s *SQLiteStore) GetAllConfig() (map[string]string, error) {
kv := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err == nil {
kv[k] = v
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
kv[k] = v
}
return kv, nil
}
@@ -74,19 +75,27 @@ func (s *SQLiteStore) UpdateConfigBatch(req map[string]interface{}) error {
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)
if _, err := tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strVal); err != nil {
return err
}
}
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))
if _, err := tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal)); err != nil {
return err
}
}
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))
if _, err := tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal)); err != nil {
return err
}
}
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)))
if _, err := tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strconv.Itoa(int(numVal))); err != nil {
return err
}
}
case "webhook_events":
if arrVal, ok := val.([]interface{}); ok {
@@ -96,7 +105,9 @@ func (s *SQLiteStore) UpdateConfigBatch(req map[string]interface{}) error {
events = append(events, str)
}
}
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.Join(events, ","))
if _, err := tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.Join(events, ",")); err != nil {
return err
}
}
}
}
@@ -104,21 +115,34 @@ func (s *SQLiteStore) UpdateConfigBatch(req map[string]interface{}) error {
return tx.Commit()
}
// FactoryReset completely wipes the database back to a blank slate
func (s *SQLiteStore) FactoryReset() error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
// Defers are safe here; tx.Rollback() is a no-op if tx.Commit() succeeds.
defer tx.Rollback()
tx.Exec("DELETE FROM events")
tx.Exec("DELETE FROM sensors")
tx.Exec("DELETE FROM sensor_heartbeats")
tx.Exec("DELETE FROM config")
// Wipe all tables (Order matters if foreign keys aren't set to CASCADE,
// though ours are, it is best practice to be explicit).
queries := []string{
"DELETE FROM events",
"DELETE FROM sensor_heartbeats",
"DELETE FROM node_sensors",
"DELETE FROM nodes",
"DELETE FROM config",
}
for _, q := range queries {
if _, err := tx.Exec(q); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return InitializeDefaultConfig(s.DB)
}
}
+40 -26
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"encoding/json"
"time"
"github.com/honeywire/hub/internal/models"
)
@@ -16,24 +17,23 @@ func (s *SQLiteStore) InsertEvent(e *models.Event, nowStr string, detailsStr str
if err != nil {
return 0, err
}
lastInsertID, _ := result.LastInsertId()
lastInsertID, err := result.LastInsertId()
if err != nil {
return 0, err
}
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 = ?",
"SELECT is_silenced FROM node_sensors WHERE node_id = ? AND sensor_id = ?",
nodeID, sensorID,
).Scan(&isSilencedInt)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
return false, nil // Default to not silenced if somehow not found
}
return false, err
}
@@ -44,13 +44,10 @@ func (s *SQLiteStore) GetEvents(isArchived int, nodeID string, sensorID string)
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)
@@ -69,18 +66,13 @@ func (s *SQLiteStore) GetEvents(isArchived int, nodeID string, sensorID string)
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.ID, &e.Timestamp, &e.ContractVersion, &e.SensorID, &e.NodeID,
&e.EventTrigger, &e.Severity, &e.Source, &e.Target,
&detailsStr, &isReadInt, &isArchivedInt,
); err != nil {
continue
}
if dbNodeID != nil {
e.NodeID = *dbNodeID
return nil, err
}
e.IsRead = isReadInt == 1
@@ -99,10 +91,7 @@ func (s *SQLiteStore) GetEvents(isArchived int, nodeID string, sensorID string)
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
return count, err
}
func (s *SQLiteStore) MarkEventRead(eventID string) error {
@@ -128,13 +117,38 @@ func (s *SQLiteStore) ArchiveAllEvents() error {
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
return count, err
}
func (s *SQLiteStore) ClearAllEvents() error {
_, err := s.DB.Exec("DELETE FROM events")
return err
}
// EnforceRetention automatically archives and deletes old events based on configured days
func (s *SQLiteStore) EnforceRetention(archiveDays, purgeDays int) error {
now := time.Now().UTC()
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// 1. Auto-Archive (if enabled)
if archiveDays > 0 {
archiveCutoff := now.AddDate(0, 0, -archiveDays).Format(time.RFC3339)
if _, err := tx.Exec("UPDATE events SET is_archived = 1 WHERE timestamp < ? AND is_archived = 0", archiveCutoff); err != nil {
return err
}
}
// 2. Auto-Purge (if enabled)
if purgeDays > 0 {
purgeCutoff := now.AddDate(0, 0, -purgeDays).Format(time.RFC3339)
if _, err := tx.Exec("DELETE FROM events WHERE timestamp < ?", purgeCutoff); err != nil {
return err
}
}
return tx.Commit()
}
+306
View File
@@ -0,0 +1,306 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"github.com/google/uuid"
"time"
"github.com/honeywire/hub/internal/models"
)
// CreateNode initializes a new UI-first node and returns the generated credentials.
func (s *SQLiteStore) CreateNode(alias string, tagsJSON string) (string, string, error) {
nodeID := "node-" + uuid.New().String()[:8]
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return "", "", err
}
apiKey := "hw_key_" + hex.EncodeToString(keyBytes)
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.DB.Exec(`
INSERT INTO nodes (id, alias, api_key, tags, pending_config, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?)`,
nodeID, alias, apiKey, tagsJSON, now, now,
)
return nodeID, apiKey, err
}
// GetNodeByKey allows the agent/wizard to authenticate itself
func (s *SQLiteStore) GetNodeByKey(apiKey string) (string, error) {
var nodeID string
err := s.DB.QueryRow("SELECT id FROM nodes WHERE api_key = ?", apiKey).Scan(&nodeID)
return nodeID, err
}
// UpdateNodeMeta allows the UI to edit the Node's Alias, Tags, and IP Addresses
func (s *SQLiteStore) UpdateNodeMeta(nodeID string, alias string, tagsJSON string, publicIP *string, privateIP *string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.DB.Exec(`
UPDATE nodes
SET alias = ?, tags = ?, public_ip = ?, private_ip = ?, updated_at = ?
WHERE id = ?`,
alias, tagsJSON, publicIP, privateIP, now, nodeID,
)
return err
}
// Helper to determine if a node/sensor is alive based on the last heartbeat
func deriveStatus(lastHeartbeat *string) string {
if lastHeartbeat == nil || *lastHeartbeat == "" {
return "pending" // Never checked in
}
t, err := time.Parse(time.RFC3339, *lastHeartbeat)
if err != nil {
return "down"
}
// If heartbeat is older than 90 seconds, consider it offline
if time.Now().UTC().Sub(t) > 90*time.Second {
return "down"
}
return "up"
}
// GetNodes returns a list of all nodes and their installed sensors for the Fleet Dashboard
func (s *SQLiteStore) GetNodes() ([]models.Node, error) {
rows, err := s.DB.Query(`
SELECT id, alias, public_ip, private_ip, tags, pending_config, last_heartbeat
FROM nodes ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var nodes []models.Node
for rows.Next() {
var n models.Node
var tagsJSON string
var pendingInt int
if err := rows.Scan(&n.ID, &n.Alias, &n.PublicIP, &n.PrivateIP, &tagsJSON, &pendingInt, &n.LastHeartbeat); err != nil {
return nil, err
}
json.Unmarshal([]byte(tagsJSON), &n.Tags)
n.HasPendingConfig = pendingInt == 1
n.Status = deriveStatus(n.LastHeartbeat)
n.InstalledSensors = []models.NodeSensor{}
// Subquery to fetch sensors for this specific node
sensorRows, err := s.DB.Query(`
SELECT sensor_id, custom_name, config_values, is_silenced
FROM node_sensors WHERE node_id = ?`, n.ID)
if err != nil {
return nil, err
}
for sensorRows.Next() {
var ns models.NodeSensor
var configStr string
var silencedInt int
if err := sensorRows.Scan(&ns.Name, &ns.Display, &configStr, &silencedInt); err != nil {
sensorRows.Close()
return nil, err
}
ns.ID = ns.Name // Use catalog ID for frontend keys
ns.IsSilenced = silencedInt == 1
ns.Status = n.Status // Sensor status matches parent node status for now
json.Unmarshal([]byte(configStr), &ns.EnvVars)
n.InstalledSensors = append(n.InstalledSensors, ns)
}
sensorRows.Close()
nodes = append(nodes, n)
}
if nodes == nil {
nodes = []models.Node{} // Guarantee an empty array over null
}
return nodes, nil
}
// GetNodeDetails fetches the node, its installed sensors, and their recent event counts
func (s *SQLiteStore) GetNodeDetails(nodeID string) (*models.Node, error) {
var node models.Node
var tagsJSON string
var pendingInt int
// Fetch Node Meta
err := s.DB.QueryRow(`
SELECT id, alias, public_ip, private_ip, tags, pending_config, last_heartbeat
FROM nodes WHERE id = ?`, nodeID).
Scan(&node.ID, &node.Alias, &node.PublicIP, &node.PrivateIP, &tagsJSON, &pendingInt, &node.LastHeartbeat)
if err != nil {
return nil, err
}
json.Unmarshal([]byte(tagsJSON), &node.Tags)
node.HasPendingConfig = pendingInt == 1
node.Status = deriveStatus(node.LastHeartbeat)
node.InstalledSensors = []models.NodeSensor{}
// Fetch Installed Sensors (Added metadata and last_heartbeat)
rows, err := s.DB.Query(`
SELECT sensor_id, custom_name, config_values, metadata, is_silenced, last_heartbeat
FROM node_sensors WHERE node_id = ?`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var ns models.NodeSensor
var configStr string
var metaStr string
var silencedInt int
var lastHb sql.NullString
if err := rows.Scan(&ns.Name, &ns.Display, &configStr, &metaStr, &silencedInt, &lastHb); err != nil {
return nil, err
}
ns.ID = ns.Name
ns.NodeID = nodeID // Map the parent node ID for backend tracking
ns.IsSilenced = silencedInt == 1
if lastHb.Valid {
ns.LastHeartbeat = &lastHb.String
}
// Derive status specifically for this container!
ns.Status = deriveStatus(ns.LastHeartbeat)
json.Unmarshal([]byte(configStr), &ns.EnvVars)
json.Unmarshal([]byte(metaStr), &ns.Metadata)
// Count events in the last 24h for this specific sensor
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
if err := s.DB.QueryRow(`
SELECT COUNT(*) FROM events
WHERE node_id = ? AND sensor_id = ? AND timestamp >= ?`,
nodeID, ns.ID, yesterday).Scan(&ns.Events24h); err != nil {
return nil, err
}
node.InstalledSensors = append(node.InstalledSensors, ns)
}
return &node, nil
}
// AddSensorToNode securely inserts a configured sensor and flags the node as pending sync.
func (s *SQLiteStore) AddSensorToNode(nodeID, sensorID, customName string, configValues map[string]interface{}) error {
configJSON, err := json.Marshal(configValues)
if err != nil {
return err
}
now := time.Now().Format(time.RFC3339)
tx, err := s.DB.Begin()
if err != nil {
return err
}
_, err = tx.Exec(`
INSERT INTO node_sensors (node_id, sensor_id, custom_name, config_values, is_silenced, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?)`,
nodeID, sensorID, customName, string(configJSON), now, now,
)
if err != nil {
tx.Rollback()
return err // Likely hits UNIQUE constraint
}
_, err = tx.Exec(`UPDATE nodes SET pending_config = 1, updated_at = ? WHERE id = ?`, now, nodeID)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// UpdateNodeSensor updates the config/alias and sets pending_config = 1
func (s *SQLiteStore) UpdateNodeSensor(nodeID, sensorID, customName string, configValues map[string]interface{}) error {
configJSON, err := json.Marshal(configValues)
if err != nil {
return err
}
now := time.Now().Format(time.RFC3339)
tx, err := s.DB.Begin()
if err != nil {
return err
}
_, err = tx.Exec(`
UPDATE node_sensors
SET custom_name = ?, config_values = ?, updated_at = ?
WHERE node_id = ? AND sensor_id = ?`,
customName, string(configJSON), now, nodeID, sensorID,
)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(`UPDATE nodes SET pending_config = 1, updated_at = ? WHERE id = ?`, now, nodeID)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// RemoveNodeSensor deletes the sensor and sets pending_config = 1
func (s *SQLiteStore) RemoveNodeSensor(nodeID, sensorID string) error {
now := time.Now().Format(time.RFC3339)
tx, err := s.DB.Begin()
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM node_sensors WHERE node_id = ? AND sensor_id = ?`, nodeID, sensorID)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(`UPDATE nodes SET pending_config = 1, updated_at = ? WHERE id = ?`, now, nodeID)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// DeleteNode permanently removes a node and cascades to delete all its sensors, events, and heartbeats
func (s *SQLiteStore) DeleteNode(nodeID string) error {
_, err := s.DB.Exec("DELETE FROM nodes WHERE id = ?", nodeID)
return err
}
// ApplyNodeRevision saves the newly generated compose revision and clears the pending flag
func (s *SQLiteStore) ApplyNodeRevision(nodeID, revision string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.DB.Exec(`
UPDATE nodes
SET active_revision = ?, pending_config = 0, updated_at = ?
WHERE id = ?`,
revision, now, nodeID,
)
return err
}
-40
View File
@@ -1,40 +0,0 @@
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
}
+74 -104
View File
@@ -1,10 +1,8 @@
package store
import (
"encoding/json"
"database/sql"
"time"
"github.com/honeywire/hub/internal/models"
)
type SensorUptimeData struct {
@@ -20,14 +18,42 @@ type HeartbeatData struct {
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
// ProcessHeartbeat safely handles node updates and config reconciliation
func (s *SQLiteStore) ProcessHeartbeat(nodeID, sensorID, agentRevision, nowStr, metadataStr string) (bool, error) {
tx, err := s.DB.Begin()
if err != nil {
return false, err
}
defer tx.Rollback() // Safely catches any uncommitted exit
// Update the Host Node's heartbeat
if _, err := tx.Exec("UPDATE nodes SET last_heartbeat = ? WHERE id = ?", nowStr, nodeID); err != nil {
return false, err
}
// Update the specific Sensor's heartbeat AND metadata
if _, err := tx.Exec(`
UPDATE node_sensors
SET metadata = ?, last_heartbeat = ?, updated_at = ?
WHERE node_id = ? AND sensor_id = ?`,
metadataStr, nowStr, nowStr, nodeID, sensorID); err != nil {
return false, err
}
// Check config reconciliation
var dbRevision sql.NullString
var pendingConfig int
err = tx.QueryRow("SELECT active_revision, pending_config FROM nodes WHERE id = ?", nodeID).Scan(&dbRevision, &pendingConfig)
justSynced := false
if err == nil && pendingConfig == 1 && dbRevision.Valid && dbRevision.String == agentRevision {
if _, err := tx.Exec("UPDATE nodes SET pending_config = 0 WHERE id = ?", nodeID); err != nil {
return false, err
}
justSynced = true
}
return justSynced, tx.Commit()
}
func (s *SQLiteStore) InsertHeartbeat(nodeID, sensorID, timeBucket string) error {
@@ -38,89 +64,13 @@ func (s *SQLiteStore) InsertHeartbeat(nodeID, sensorID, timeBucket string) error
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
}
// GetSensor retrieves a single sensor by node_id and sensor_id composite key
func (s *SQLiteStore) GetSensor(nodeID, sensorID string) (*models.Sensor, error) {
var sensor models.Sensor
var metadataStr string
var isSilencedInt int
err := s.DB.QueryRow(
`SELECT sensor_id, node_id, first_seen, last_seen, metadata, is_silenced
FROM sensors
WHERE node_id = ? AND sensor_id = ?`,
nodeID, sensorID,
).Scan(&sensor.SensorID, &sensor.NodeID, &sensor.FirstSeen, &sensor.LastSeen, &metadataStr, &isSilencedInt)
if err != nil {
return nil, err
}
sensor.IsSilenced = isSilencedInt == 1
lastSeenTime, err := time.Parse(time.RFC3339, sensor.LastSeen)
if err == nil && time.Now().UTC().Sub(lastSeenTime) < 90*time.Second {
sensor.Status = "online"
} else {
sensor.Status = "offline"
}
var metadata map[string]interface{}
json.Unmarshal([]byte(metadataStr), &metadata)
sensor.Metadata = metadata
return &sensor, 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)
// Join nodes and node_sensors to get the creation date and the parent node's last heartbeat
rows, err := s.DB.Query(`
SELECT ns.node_id, ns.sensor_id, n.last_heartbeat, ns.created_at
FROM node_sensors ns
JOIN nodes n ON ns.node_id = n.id
ORDER BY ns.node_id, ns.sensor_id`)
if err != nil {
return nil, err
}
@@ -129,11 +79,18 @@ func (s *SQLiteStore) GetSensorsForUptime(nowStr string) ([]SensorUptimeData, er
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)
var lastSeenStr sql.NullString
if err := rows.Scan(&sd.NodeID, &sd.SensorID, &lastSeenStr, &sd.FirstSeen); err != nil {
return nil, err
}
if lastSeenStr.Valid {
sd.LastSeen, _ = time.Parse(time.RFC3339, lastSeenStr.String)
} else {
sd.LastSeen = time.Time{} // Never checked in
}
sensors = append(sensors, sd)
}
return sensors, nil
}
@@ -148,22 +105,35 @@ func (s *SQLiteStore) GetHeartbeatsSince(cutoffStr string) ([]HeartbeatData, err
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)
if err := rows.Scan(&hb.NodeID, &hb.SensorID, &hb.TimeBucket); err != nil {
return nil, err
}
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)
_, err := s.DB.Exec("UPDATE node_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)
// UpdateNodeLastHeartbeat flags BOTH the parent node and the specific sensor as alive
func (s *SQLiteStore) UpdateNodeLastHeartbeat(nodeID, sensorID, timestamp string) error {
tx, err := s.DB.Begin()
if err != nil {
return 0, err
return err
}
return result.RowsAffected()
if _, err := tx.Exec(`UPDATE nodes SET last_heartbeat = ? WHERE id = ?`, timestamp, nodeID); err != nil {
tx.Rollback()
return err
}
if _, err := tx.Exec(`UPDATE node_sensors SET last_heartbeat = ?, updated_at = ? WHERE node_id = ? AND sensor_id = ?`, timestamp, timestamp, nodeID, sensorID); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
+39 -41
View File
@@ -12,42 +12,50 @@ import (
const v2Schema = `
-- Nodes: Physical servers/agents managing sensors
CREATE TABLE IF NOT EXISTS nodes (
node_id TEXT PRIMARY KEY,
alias TEXT NOT NULL,
node_key TEXT UNIQUE NOT NULL,
ip_address TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
id TEXT PRIMARY KEY,
alias TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
public_ip TEXT,
private_ip TEXT,
tags TEXT NOT NULL DEFAULT '[]',
pending_config INTEGER NOT NULL DEFAULT 0,
active_revision TEXT,
last_heartbeat TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Sensors: Monitored honeypots, strictly owned by a Node
CREATE TABLE IF NOT EXISTS sensors (
node_id TEXT NOT NULL,
sensor_id TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
is_silenced INTEGER NOT NULL DEFAULT 0,
-- NodeSensors: Installed instances of catalog sensors
CREATE TABLE IF NOT EXISTS node_sensors (
node_id TEXT NOT NULL,
sensor_id TEXT NOT NULL,
custom_name TEXT NOT NULL,
config_values TEXT NOT NULL DEFAULT '{}',
metadata TEXT NOT NULL DEFAULT '{}',
last_heartbeat TEXT,
is_silenced INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (node_id, sensor_id),
FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
-- Events: Security alerts from sensors
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
sensor_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
sensor_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
contract_version TEXT NOT NULL DEFAULT '1.0.0',
event_trigger TEXT NOT NULL DEFAULT 'alert',
severity TEXT NOT NULL DEFAULT 'medium',
source TEXT NOT NULL DEFAULT 'Unknown',
target TEXT NOT NULL DEFAULT 'Unknown',
details TEXT NOT NULL DEFAULT '{}',
is_read INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
count INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (node_id, sensor_id) REFERENCES sensors(node_id, sensor_id) ON DELETE CASCADE
event_trigger TEXT NOT NULL DEFAULT 'alert',
severity TEXT NOT NULL DEFAULT 'medium',
source TEXT NOT NULL DEFAULT 'Unknown',
target TEXT NOT NULL DEFAULT 'Unknown',
details TEXT NOT NULL DEFAULT '{}',
is_read INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
count INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (node_id, sensor_id) REFERENCES node_sensors(node_id, sensor_id) ON DELETE CASCADE
);
-- Sensor Heartbeats: Routine health pings from sensors
@@ -56,14 +64,7 @@ CREATE TABLE IF NOT EXISTS sensor_heartbeats (
sensor_id TEXT NOT NULL,
time_bucket TEXT NOT NULL,
PRIMARY KEY (node_id, sensor_id, time_bucket),
FOREIGN KEY (node_id, sensor_id) REFERENCES sensors(node_id, sensor_id) ON DELETE CASCADE
);
-- Pairing Tokens: One-time tokens for secure node provisioning via wizard
CREATE TABLE IF NOT EXISTS pairing_tokens (
token TEXT PRIMARY KEY,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL
FOREIGN KEY (node_id, sensor_id) REFERENCES node_sensors(node_id, sensor_id) ON DELETE CASCADE
);
-- System Configuration
@@ -72,20 +73,18 @@ CREATE TABLE IF NOT EXISTS config (
value TEXT NOT NULL
);
-- High-performance indexes for dashboard queries
-- High-performance indexes
CREATE INDEX IF NOT EXISTS idx_events_archived ON events(is_archived, id DESC);
CREATE INDEX IF NOT EXISTS idx_events_node_sensor ON events(node_id, sensor_id);
CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity);
CREATE INDEX IF NOT EXISTS idx_sensors_node ON sensors(node_id);
CREATE INDEX IF NOT EXISTS idx_sensors_node ON node_sensors(node_id);
CREATE INDEX IF NOT EXISTS idx_heartbeats_time ON sensor_heartbeats(time_bucket);
CREATE INDEX IF NOT EXISTS idx_pairing_tokens_expires ON pairing_tokens(expires_at);
`
type SQLiteStore struct {
DB *sql.DB
}
// NewStore initializes the SQLite database with the v2.0.0 schema
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)
@@ -100,7 +99,6 @@ func NewStore(dbPath string) (*SQLiteStore, error) {
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Apply the clean v2 schema
if _, err := db.Exec(v2Schema); err != nil {
return nil, err
}
+1 -1
View File
@@ -31,7 +31,7 @@ type DataStore interface {
// Sensors
UpsertSensor(hb *models.Heartbeat, nowStr, metadataStr string) error
InsertHeartbeat(nodeID, sensorID, timeBucket string) error
GetAllSensors() ([]models.Sensor, error)
GetAllSensors() ([]models.NodeSensor, error)
GetSensorsForUptime(nowStr string) ([]SensorUptimeData, error)
GetHeartbeatsSince(cutoffStr string) ([]HeartbeatData, error)
UpdateSensorSilence(nodeID, sensorID string, silenceVal int) error
+4 -1
View File
@@ -11,12 +11,13 @@ import (
)
type Sensor struct {
NodeID string // Added NodeID
NodeID string
SensorID string
AgentVersion string
hubContractVersion string
HubEndpoint string
HubKey string
ConfigRev string
TestMode bool
client *http.Client
stopCh chan struct{}
@@ -29,6 +30,7 @@ func NewSensor() (*Sensor, error) {
AgentVersion: "1.0.0",
HubEndpoint: getEnv("HW_HUB_ENDPOINT", ""),
HubKey: getEnv("HW_HUB_KEY", ""),
ConfigRev: getEnv("HW_CONFIG_REV", ""),
TestMode: getEnv("HW_TEST_MODE", "false") == "true",
client: &http.Client{Timeout: 10 * time.Second},
stopCh: make(chan struct{}),
@@ -127,6 +129,7 @@ func (s *Sensor) sendHeartbeat() {
"metadata": map[string]string{
"agent_version": s.AgentVersion,
"contract_version": s.hubContractVersion,
"HW_CONFIG_REV": s.ConfigRev,
},
}
+4 -2
View File
@@ -17,6 +17,7 @@ class HoneyWireSensor(ABC):
self.hub_key = os.getenv("HW_HUB_KEY")
self.node_id = os.getenv("HW_NODE_ID")
self.sensor_id = os.getenv("HW_SENSOR_ID")
self.config_rev = os.getenv("HW_CONFIG_REV", "")
self.test_mode = os.getenv("HW_TEST_MODE", "false").lower() == "true"
self.agent_version = os.getenv("HONEYWIRE_VERSION", SDK_DEFAULT_AGENT_VERSION)
@@ -87,10 +88,11 @@ class HoneyWireSensor(ABC):
payload = {
"node_id": self.node_id,
"sensor_id": self.sensor_id,
"details": {
"metadata": {
"agent_version": self.agent_version,
"contract_version": self._hub_contract_version,
"sensor_type": self.sensor_type
"sensor_type": self.sensor_type,
"HW_CONFIG_REV": self.config_rev
}
}
try: