mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
backend and sdk rewrite for hub centric sensors
This commit is contained in:
+271
-397
@@ -1,106 +1,70 @@
|
||||
# HoneyWire Hub — API Reference (v1.0)
|
||||
|
||||
[](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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user