mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
finalized UI, implemented sensor store, settings handled by sqlite, code readability and mantainability improved, added api endpoints, updated
documentation to match. getting things ready for v1.0.0 release
This commit is contained in:
@@ -6,6 +6,7 @@ env:
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*.*.*" ]
|
||||
paths: [ 'Hub/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -110,6 +111,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/honeywire-hub
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and Push via Buildx
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
@@ -6,7 +6,7 @@ env:
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
# ONLY watch the official folder. Community doc updates won't waste CI minutes.
|
||||
tags: [ "v*.*.*" ]
|
||||
paths: [ 'Sensors/official/**' ]
|
||||
pull_request:
|
||||
paths: [ 'Sensors/official/**' ]
|
||||
@@ -40,11 +40,10 @@ jobs:
|
||||
- name: Generate Independent Matrix
|
||||
id: generate-matrix
|
||||
env:
|
||||
# 2. THE FIX: Pass the output safely via an environment variable
|
||||
ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
# On manual trigger: Find ALL folders in official/ containing a Dockerfile
|
||||
# NEW: If it's a manual trigger OR a new Tag Release, build ALL sensors
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] || [ "${{ github.ref_type }}" == "tag" ]; then
|
||||
MATRIX_JSON=$(find Sensors/official -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/Dockerfile' \; -print | awk -F/ '{print $3}' | jq -R -s -c 'split("\n")[:-1]')
|
||||
else
|
||||
# On Push/PR: Parse the changed files JSON from the safe environment variable
|
||||
@@ -150,13 +149,14 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' || github.ref_type == 'tag'
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/honeywire-${{ matrix.sensor }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and Push
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
+60
-43
@@ -1,57 +1,74 @@
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
# Contributing to HoneyWire
|
||||
|
||||
Welcome to HoneyWire! We are building a centralized, high-fidelity security and deception ecosystem for homelabs and SMBs.
|
||||
|
||||
To keep the ecosystem stable and structurally secure, all community-submitted sensors must adhere to a strict set of DevSecOps rules. We treat sensors as **isolated, unprivileged microservices**.
|
||||
Whether you want to build a new decoy sensor, improve the Vue.js frontend, or optimize the Go backend, your contributions are highly welcome.
|
||||
|
||||
## The Golden Rules of Sensors
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
To help you navigate the repository, here is a high-level overview of the architecture:
|
||||
|
||||
```text
|
||||
honeywire/
|
||||
├── .github/ # CI/CD workflows
|
||||
├── Docs/ # Documentation (API.md)
|
||||
├── Hub/ # Central brain (Go backend + Vue frontend)
|
||||
│ ├── cmd/hub/ # Main Go entrypoint (main.go)
|
||||
│ ├── internal/ # Go packages (api, auth, config, models, notify, store)
|
||||
│ ├── ui/ # Vue 3 Frontend (src, public, tailwind config)
|
||||
│ ├── docker-compose.yml
|
||||
│ └── Dockerfile
|
||||
├── SDKs/ # Libraries for writing custom sensors
|
||||
└── Sensors/ # Decoy nodes (Official and Community)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing to the Hub (Core)
|
||||
|
||||
The Hub is a unified monolith containing both the Go API and the embedded Vue 3 frontend.
|
||||
|
||||
### Frontend (Vue 3 + TailwindCSS)
|
||||
1. Navigate to the `Hub/ui/` directory.
|
||||
2. Run `npm install` to install dependencies.
|
||||
3. Run `npm run dev` to start the Vite development server.
|
||||
* *Note: You will need the Go backend running concurrently to serve the `/api/v1` routes.*
|
||||
4. When your UI changes are complete, run `npm run build`. This compiles the assets into `Hub/ui/dist/`, which the Go binary automatically embeds at compile time.
|
||||
|
||||
### Backend (Go 1.25 + SQLite)
|
||||
1. Navigate to the `Hub/` directory.
|
||||
2. The backend uses `modernc.org/sqlite` (a pure Go port of SQLite) to ensure the binary remains statically linked and cross-platform without requiring CGO.
|
||||
3. Run `go run cmd/hub/main.go` to start the Hub.
|
||||
4. **Database Migrations:** If you need to alter the database schema, **do not** modify the `baselineSchema` string in `Hub/internal/store/store.go`. Instead, append your `ALTER TABLE` SQL command to the `migrations` array within that file. The Hub will safely backup the database and apply the migration automatically on boot.
|
||||
|
||||
---
|
||||
|
||||
## Contributing a New Sensor
|
||||
|
||||
To keep the ecosystem stable, all community-submitted sensors must adhere to a strict set of DevSecOps rules. We treat sensors as **isolated, unprivileged microservices**.
|
||||
|
||||
### The Golden Rules of Sensors
|
||||
1. **Strict Sandboxing (Docker Only):** Every sensor must include a `Dockerfile`. We strongly enforce the use of minimal, hardened base images (like Distroless) running as non-root users (`UID 65532`) with all Linux kernel capabilities dropped (`cap_drop: ALL`).
|
||||
2. **Zero Blast Radius:** Your sensor must not crash or overwhelm the main Hub. All communication must happen asynchronously via HTTP POST requests containing JSON.
|
||||
3. **No Hardcoding:** All configurations (Ports, API keys, file paths, thresholds) must be handled dynamically via environment variables.
|
||||
|
||||
## How to Submit a New Sensor
|
||||
### How to Submit
|
||||
1. **Use the Official Template:** Copy the [`Sensors/templates/go-sensor-template/`](./Sensors/templates/go-sensor-template/) folder and rename it to your sensor's name inside the [`Sensors/community/`](./Sensors/community/) directory.
|
||||
*While you can technically build a custom sensor in any language, **pure Go is the official standard** for HoneyWire due to its minimal footprint, concurrency models, and ability to compile statically.*
|
||||
2. **Follow the JSON Contract:** Your sensor must POST a payload to the Hub matching the schema outlined in the main `README.md`. *(The official HoneyWire Go SDK handles this formatting for you).*
|
||||
3. **Implement Test Mode:** To ensure your code works before merging, our GitHub Actions will pass `HW_TEST_MODE=true` to your container. Your sensor must immediately send a synthetic payload to the Hub and exit gracefully.
|
||||
4. **Documentation:** Provide a `README.md` within your sensor directory containing:
|
||||
* **Technical Overview:** Purpose of the sensor.
|
||||
* **Environment Reference:** A table of all `HW_` configuration variables.
|
||||
* **Deployment Example:** A secure `docker-compose.yml` snippet.
|
||||
* **Security Architecture:** An explicit breakdown of the capability drops and isolation techniques utilized.
|
||||
|
||||
### 1. Use the Official Template
|
||||
Copy the [`Sensors/templates/go-sensor-template/`](./Sensors/templates/go-sensor-template/) folder and rename it to your sensor's name inside the [`Sensors/community/`](./Sensors/community/) directory.
|
||||
*While you can technically build a custom sensor in any language, **pure Go is the official standard** for HoneyWire due to its minimal footprint, concurrency models, and ability to compile statically without external dependencies.*
|
||||
|
||||
### 2. Follow the JSON Contract (v1.0)
|
||||
Your sensor must POST a payload to the Hub (`HW_HUB_ENDPOINT`) matching this exact schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"contract_version": "1.0",
|
||||
"severity": "critical",
|
||||
"event_trigger": "what_just_happened",
|
||||
"source": "104.28.19.12",
|
||||
"target": "Auth Gateway",
|
||||
"sensor_id": "provided-by-env",
|
||||
"details": {
|
||||
"ip": "192.168.1.5",
|
||||
"custom_data": "anything you want"
|
||||
}
|
||||
}
|
||||
```
|
||||
*(Note: If you use the official HoneyWire Go SDK provided in the template, this formatting is handled for you automatically).*
|
||||
|
||||
### 3. Implement Test Mode (Required for CI/CD)
|
||||
To ensure your code works before merging, our GitHub Actions will build your Docker container and pass `HW_TEST_MODE=true` as an environment variable.
|
||||
|
||||
If this variable is present, your sensor **must immediately send a synthetic payload to the Hub and exit gracefully**. (The HoneyWire Go SDK's `Start()` method handles this natively out-of-the-box).
|
||||
|
||||
### 4. Provide Thorough Documentation
|
||||
Provide a `README.md` within your sensor directory containing:
|
||||
* **Technical Overview:** Purpose of the sensor and the "lure" or monitoring it provides.
|
||||
* **Environment Reference:** A table of all `HW_` configuration variables.
|
||||
* **Deployment Example:** A secure `docker-compose.yml` snippet and a `.env.example`.
|
||||
* **Security Architecture:** An explicit breakdown of the capability drops, user privileges, and isolation techniques utilized by your container to ensure the host remains safe.
|
||||
|
||||
## Review Process
|
||||
### Review Process
|
||||
Once you open a Pull Request:
|
||||
1. **Automated Security Scanning:** GitHub Actions will run **Trivy** to scan your Docker image for OS and library vulnerabilities, and **CodeQL** to perform static code analysis for memory leaks and insecure patterns.
|
||||
2. **Functional Testing:** GitHub Actions will automatically build your Docker container and test it against a Mock Hub using `HW_TEST_MODE=true` to verify contract compliance.
|
||||
3. **Manual Review:** A core maintainer will manually review the code, specifically checking for malicious intent, proper capability stripping, and blast-radius risks. PRs that fail automated testing or security scanning will not be reviewed.
|
||||
1. **Automated Security Scanning:** GitHub Actions will run **Trivy** to scan your Docker image for vulnerabilities, and **CodeQL** to perform static code analysis for memory leaks.
|
||||
2. **Functional Testing:** GitHub Actions will automatically build your Docker container and test it against a Mock Hub using `HW_TEST_MODE=true`.
|
||||
3. **Manual Review:** A core maintainer will manually review the code, specifically checking for malicious intent and proper capability stripping.
|
||||
+108
-13
@@ -12,13 +12,13 @@ HoneyWire uses two separate authentication mechanisms depending on the caller.
|
||||
|
||||
### 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. If `HW_DASHBOARD_PASSWORD` is not set in the Hub's environment, authentication is bypassed entirely — do not deploy without this variable set.
|
||||
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 shared secret configured as `HW_HUB_KEY` on both the Hub and each sensor. Pass the key using either of these headers:
|
||||
Protected by a shared secret configured via the UI and stored in the Hub's SQLite database. Pass the key using either of these headers:
|
||||
|
||||
```
|
||||
```text
|
||||
X-Api-Key: <HW_HUB_KEY>
|
||||
Authorization: Bearer <HW_HUB_KEY>
|
||||
```
|
||||
@@ -72,10 +72,77 @@ All messages share the same envelope:
|
||||
|
||||
---
|
||||
|
||||
## System
|
||||
## 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.
|
||||
|
||||
**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:**
|
||||
```json
|
||||
{
|
||||
"password": "super_secure_password123",
|
||||
"hub_endpoint": "https://honeywire.my-domain.com",
|
||||
"hub_key": "hw_sk_randomstring"
|
||||
}
|
||||
```
|
||||
**Response:** `200 OK`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/config
|
||||
Retrieves the runtime settings.
|
||||
**Requires Authentication:** Yes (UI Cookie)
|
||||
|
||||
**Default Values (On first boot):**
|
||||
* `auto_archive_days` / `auto_purge_days`: `0` (Disabled)
|
||||
* `webhook_type`: `ntfy`
|
||||
* `webhook_events`: `["critical", "high", "medium", "low", "info"]`
|
||||
|
||||
**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"]
|
||||
}
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
**Payload Example:**
|
||||
```json
|
||||
{
|
||||
"auto_archive_days": 14,
|
||||
"webhook_type": "discord",
|
||||
"webhook_url": "https://discord.com/api/webhooks/...",
|
||||
"webhook_events": ["critical", "high"]
|
||||
}
|
||||
```
|
||||
**Response:** `200 OK`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/version
|
||||
|
||||
Returns the running Hub version.
|
||||
|
||||
**Response**
|
||||
@@ -83,10 +150,7 @@ Returns the running Hub version.
|
||||
{ "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**
|
||||
@@ -94,10 +158,7 @@ Returns the current armed/disarmed state. Disarmed hubs log events normally but
|
||||
{ "is_armed": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/system/state
|
||||
|
||||
Sets the armed state.
|
||||
|
||||
**Request**
|
||||
@@ -112,6 +173,40 @@ Sets the armed state.
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
---
|
||||
|
||||
## Sensor Fleet
|
||||
|
||||
### GET /api/v1/sensors
|
||||
@@ -304,7 +399,7 @@ Permanently deletes all events from the database. This action is irreversible an
|
||||
|
||||
## Agent Endpoints
|
||||
|
||||
These endpoints are called by sensors, not the dashboard. Both require the `HW_HUB_KEY` header.
|
||||
These endpoints are called by sensors, not the dashboard. Both require the configured database `hub_key`.
|
||||
|
||||
### POST /api/v1/heartbeat
|
||||
|
||||
@@ -333,7 +428,7 @@ Called by sensors every 30 seconds to signal they are alive and update their met
|
||||
|
||||
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.
|
||||
|
||||
If the Hub is armed and the reporting sensor is not silenced, a push notification is dispatched immediately via the configured notifiers (ntfy/Gotify).
|
||||
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**
|
||||
```json
|
||||
|
||||
@@ -38,5 +38,3 @@ services:
|
||||
environment:
|
||||
- HW_PORT=8080
|
||||
- HW_DB_PATH=/data/honeywire.db
|
||||
- HW_HUB_KEY=change_this_to_a_secure_random_string
|
||||
- HW_DASHBOARD_PASSWORD=admin
|
||||
+2
-1
@@ -14,7 +14,8 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -16,6 +16,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
@@ -23,6 +25,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/honeywire/hub/internal/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// --- Brute-Force Protection State ---
|
||||
type loginState struct {
|
||||
attempts int
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
authTracker = make(map[string]*loginState)
|
||||
authMutex sync.Mutex
|
||||
)
|
||||
|
||||
// Background routine to prevent memory leaks from abandoned IPs
|
||||
func (h *Handler) cleanupAuthTracker() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
authMutex.Lock()
|
||||
now := time.Now()
|
||||
for ip, state := range authTracker {
|
||||
if now.After(state.lockedUntil) {
|
||||
delete(authTracker, ip)
|
||||
}
|
||||
}
|
||||
authMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := h.getRealIP(r)
|
||||
|
||||
// Rate Limiter Pre-Check
|
||||
authMutex.Lock()
|
||||
if state, exists := authTracker[ip]; exists {
|
||||
if state.attempts >= 10 {
|
||||
if time.Now().Before(state.lockedUntil) {
|
||||
authMutex.Unlock()
|
||||
http.Error(w, "Too many failed attempts. Try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// Lockout expired, wipe the slate clean
|
||||
delete(authTracker, ip)
|
||||
}
|
||||
}
|
||||
authMutex.Unlock()
|
||||
|
||||
// Parse Request
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Evaluate Authorization
|
||||
var isAuthorized bool
|
||||
|
||||
if h.Cfg.DashboardPassword != "" {
|
||||
// Layer A: Infrastructure Override (.env file)
|
||||
isAuthorized = subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.Cfg.DashboardPassword)) == 1
|
||||
} else {
|
||||
// Layer B: Runtime Database Hash (Setup UI)
|
||||
var dbHash string
|
||||
err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='admin_hash'").Scan(&dbHash)
|
||||
if err == nil {
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbHash), []byte(req.Password))
|
||||
isAuthorized = (err == nil)
|
||||
}
|
||||
}
|
||||
|
||||
if isAuthorized {
|
||||
// Clear failed attempts for this IP on successful login
|
||||
authMutex.Lock()
|
||||
delete(authTracker, ip)
|
||||
authMutex.Unlock()
|
||||
|
||||
token, err := h.SessionStore.Create()
|
||||
if err != nil {
|
||||
http.Error(w, "Session creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
isProd := h.Cfg.Env == "production"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: token,
|
||||
MaxAge: 2592000,
|
||||
HttpOnly: true,
|
||||
Secure: isProd,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Failure & Increment Rate Limiter
|
||||
authMutex.Lock()
|
||||
if _, exists := authTracker[ip]; !exists {
|
||||
authTracker[ip] = &loginState{}
|
||||
}
|
||||
authTracker[ip].attempts++
|
||||
|
||||
if authTracker[ip].attempts >= 10 {
|
||||
authTracker[ip].lockedUntil = time.Now().Add(15 * time.Minute)
|
||||
log.Printf("[!] AUDIT: IP %s locked out of dashboard for 15 minutes due to brute-force", ip)
|
||||
}
|
||||
authMutex.Unlock()
|
||||
|
||||
http.Error(w, "Invalid Password", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.CookieName); err == nil {
|
||||
h.SessionStore.Delete(cookie.Value)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/honeywire/hub/internal/auth"
|
||||
"github.com/honeywire/hub/internal/models"
|
||||
"github.com/honeywire/hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (h *Handler) GetSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Cfg.DashboardPassword != "" {
|
||||
SendJSON(w, http.StatusOK, map[string]bool{"requires_setup": false})
|
||||
return
|
||||
}
|
||||
var isSetup string
|
||||
err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_setup'").Scan(&isSetup)
|
||||
SendJSON(w, http.StatusOK, map[string]bool{"requires_setup": err != nil || isSetup != "true"})
|
||||
}
|
||||
|
||||
func (h *Handler) CompleteSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Cfg.DashboardPassword != "" {
|
||||
http.Error(w, "Setup is locked by environment configuration.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var isSetup string
|
||||
err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_setup'").Scan(&isSetup)
|
||||
if err == nil && isSetup == "true" {
|
||||
http.Error(w, "Setup has already been completed. Unauthorized.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.SetupPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.HubEndpoint == "" || req.HubKey == "" || req.Password == ""{
|
||||
http.Error(w, "Invalid setup parameters. Missing required fields.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to secure password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx, _ := h.Store.DB.Begin()
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('admin_hash', ?)", string(hash))
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('hub_endpoint', ?)", req.HubEndpoint)
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('hub_key', ?)", req.HubKey)
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES ('is_setup', 'true')")
|
||||
tx.Commit()
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.Store.DB.Query("SELECT key, value FROM config")
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
kv := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
rows.Scan(&k, &v)
|
||||
kv[k] = v
|
||||
}
|
||||
|
||||
archiveDays, _ := strconv.Atoi(kv["auto_archive_days"])
|
||||
purgeDays, _ := strconv.Atoi(kv["auto_purge_days"])
|
||||
|
||||
var events []string
|
||||
if kv["webhook_events"] != "" {
|
||||
events = strings.Split(kv["webhook_events"], ",")
|
||||
}
|
||||
|
||||
cfg := models.ConfigPayload{
|
||||
HubEndpoint: kv["hub_endpoint"],
|
||||
HubKey: kv["hub_key"],
|
||||
AutoArchiveDays: archiveDays,
|
||||
AutoPurgeDays: purgeDays,
|
||||
WebhookURL: kv["webhook_url"],
|
||||
WebhookType: kv["webhook_type"],
|
||||
WebhookEvents: events,
|
||||
}
|
||||
|
||||
SendJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.Store.DB.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
validWebhooks := map[string]bool{"ntfy": true, "gotify": true, "discord": true, "slack": true}
|
||||
|
||||
for key, val := range req {
|
||||
switch key {
|
||||
case "hub_endpoint", "hub_key", "webhook_url":
|
||||
if strVal, ok := val.(string); ok {
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strVal)
|
||||
}
|
||||
case "webhook_type":
|
||||
if strVal, ok := val.(string); ok && validWebhooks[strings.ToLower(strVal)] {
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.ToLower(strVal))
|
||||
}
|
||||
case "auto_archive_days", "auto_purge_days":
|
||||
if numVal, ok := val.(float64); ok {
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strconv.Itoa(int(numVal)))
|
||||
}
|
||||
case "webhook_events":
|
||||
if arrVal, ok := val.([]interface{}); ok {
|
||||
var events []string
|
||||
for _, v := range arrVal {
|
||||
if str, ok := v.(string); ok {
|
||||
events = append(events, str)
|
||||
}
|
||||
}
|
||||
tx.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, strings.Join(events, ","))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Cfg.DashboardPassword != "" {
|
||||
http.Error(w, "Password is locked by environment configuration.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var dbHash string
|
||||
err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='admin_hash'").Scan(&dbHash)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(dbHash), []byte(req.CurrentPassword)); err != nil {
|
||||
http.Error(w, "Incorrect current password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to hash new password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.Store.DB.Exec("UPDATE config SET value = ? WHERE key = 'admin_hash'", string(newHash))
|
||||
|
||||
h.SessionStore.ClearAllSessions()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) FactoryReset(w http.ResponseWriter, r *http.Request) {
|
||||
// Decode the incoming JSON payload for the password
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the master password hash from the database
|
||||
var dbHash string
|
||||
err := h.Store.DB.QueryRow("SELECT value FROM config WHERE key='admin_hash'").Scan(&dbHash)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(dbHash), []byte(req.Password)); err != nil {
|
||||
http.Error(w, "Incorrect password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Proceed with Factory Reset
|
||||
ip := h.getRealIP(r)
|
||||
log.Printf("[!] AUDIT: IP %s initiated a full Factory Reset. Wiping database.", ip)
|
||||
|
||||
tx, _ := h.Store.DB.Begin()
|
||||
tx.Exec("DELETE FROM events")
|
||||
tx.Exec("DELETE FROM sensors")
|
||||
tx.Exec("DELETE FROM sensor_heartbeats")
|
||||
tx.Exec("DELETE FROM config")
|
||||
tx.Commit()
|
||||
|
||||
store.InitializeDefaultConfig(h.Store.DB)
|
||||
|
||||
// Terminate all sessions and clear the UI cookie
|
||||
h.SessionStore.ClearAllSessions()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSystemState(w http.ResponseWriter, r *http.Request) {
|
||||
var isArmedStr string
|
||||
h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_armed'").Scan(&isArmedStr)
|
||||
SendJSON(w, http.StatusOK, map[string]bool{"is_armed": isArmedStr == "true"})
|
||||
}
|
||||
|
||||
func (h *Handler) SetSystemState(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IsArmed bool `json:"is_armed"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
val := "false"
|
||||
if req.IsArmed {
|
||||
val = "true"
|
||||
}
|
||||
h.Store.DB.Exec("UPDATE config SET value=? WHERE key='is_armed'", val)
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "is_armed": req.IsArmed})
|
||||
}
|
||||
|
||||
func (h *Handler) HandleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
SendJSON(w, http.StatusOK, map[string]string{"version": h.Cfg.Version})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/honeywire/hub/internal/models"
|
||||
"github.com/honeywire/hub/internal/notify"
|
||||
)
|
||||
|
||||
func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var e models.Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
||||
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hubMajor := strings.Split(h.Cfg.Version, ".")[0]
|
||||
agentMajor := strings.Split(e.ContractVersion, ".")[0]
|
||||
if agentMajor == "" || hubMajor != agentMajor {
|
||||
http.Error(w, "Upgrade Required", http.StatusUpgradeRequired)
|
||||
return
|
||||
}
|
||||
|
||||
nowStr := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
detailsJSON, _ := json.Marshal(e.Details)
|
||||
|
||||
result, err := h.Store.DB.Exec(`
|
||||
INSERT INTO events (timestamp, contract_version, sensor_id, event_trigger, severity, source, target, details, is_read, is_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`,
|
||||
nowStr, e.ContractVersion, e.SensorID, e.EventTrigger, e.Severity, e.Source, e.Target, string(detailsJSON),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
lastInsertID, _ := result.LastInsertId()
|
||||
e.ID = int(lastInsertID)
|
||||
e.Timestamp = nowStr
|
||||
|
||||
// Fetch all configs
|
||||
var isArmed, webhookType, webhookURL, webhookEvents string
|
||||
rows, err := h.Store.DB.Query("SELECT key, value FROM config WHERE key IN ('is_armed', 'webhook_type', 'webhook_url', 'webhook_events')")
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
rows.Scan(&k, &v)
|
||||
switch k {
|
||||
case "is_armed":
|
||||
isArmed = v
|
||||
case "webhook_type":
|
||||
webhookType = v
|
||||
case "webhook_url":
|
||||
webhookURL = v
|
||||
case "webhook_events":
|
||||
webhookEvents = v
|
||||
}
|
||||
}
|
||||
|
||||
// FIX: Closing the DB scope *after* reading but *before* executing the dispatch logic
|
||||
var isSilencedInt int
|
||||
h.Store.DB.QueryRow("SELECT is_silenced FROM sensors WHERE sensor_id = ?", e.SensorID).Scan(&isSilencedInt)
|
||||
|
||||
if isArmed == "true" && isSilencedInt == 0 && webhookURL != "" {
|
||||
if strings.Contains(strings.ToLower(webhookEvents), strings.ToLower(e.Severity)) {
|
||||
title := fmt.Sprintf("Intrusion Alert: %s", e.SensorID)
|
||||
message := fmt.Sprintf("Trigger: %s\nSource: %s\nTarget: %s", e.EventTrigger, e.Source, e.Target)
|
||||
|
||||
notify.Dispatch(webhookType, webhookURL, title, message, e.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.broadcastWS("NEW_EVENT", e)
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetEvents(w http.ResponseWriter, r *http.Request) {
|
||||
archivedParam := r.URL.Query().Get("archived")
|
||||
isArchived := 0
|
||||
if archivedParam == "true" {
|
||||
isArchived = 1
|
||||
}
|
||||
|
||||
query := "SELECT id, timestamp, contract_version, sensor_id, event_trigger, severity, source, target, details, is_read, is_archived FROM events WHERE is_archived = ?"
|
||||
args := []interface{}{isArchived}
|
||||
|
||||
if sensorID := r.URL.Query().Get("sensor_id"); sensorID != "" {
|
||||
query += " AND sensor_id = ?"
|
||||
args = append(args, sensorID)
|
||||
}
|
||||
|
||||
query += " ORDER BY id DESC"
|
||||
|
||||
rows, err := h.Store.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []models.Event
|
||||
for rows.Next() {
|
||||
var e models.Event
|
||||
var detailsStr string
|
||||
var isReadInt, isArchivedInt int
|
||||
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.Timestamp, &e.ContractVersion, &e.SensorID,
|
||||
&e.EventTrigger, &e.Severity, &e.Source, &e.Target,
|
||||
&detailsStr, &isReadInt, &isArchivedInt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
e.IsRead = isReadInt == 1
|
||||
e.IsArchived = isArchivedInt == 1
|
||||
json.Unmarshal([]byte(detailsStr), &e.Details)
|
||||
events = append(events, e)
|
||||
}
|
||||
|
||||
if events == nil {
|
||||
events = []models.Event{}
|
||||
}
|
||||
|
||||
SendJSON(w, http.StatusOK, events)
|
||||
}
|
||||
|
||||
func (h *Handler) GetUnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
var count int
|
||||
err := h.Store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE is_read = 0 AND is_archived = 0").Scan(&count)
|
||||
if err != nil {
|
||||
count = 0
|
||||
}
|
||||
SendJSON(w, http.StatusOK, map[string]int{"count": count})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkSingleEventRead(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "event_id")
|
||||
h.Store.DB.Exec("UPDATE events SET is_read = 1 WHERE id = ?", eventID)
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkEventsRead(w http.ResponseWriter, r *http.Request) {
|
||||
h.Store.DB.Exec("UPDATE events SET is_read = 1 WHERE is_read = 0")
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "event_id")
|
||||
h.Store.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE id = ?", eventID)
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveAll(w http.ResponseWriter, r *http.Request) {
|
||||
h.Store.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE is_archived = 0")
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) ClearEvents(w http.ResponseWriter, r *http.Request) {
|
||||
dryrun := r.URL.Query().Get("dryrun") == "true"
|
||||
|
||||
if dryrun {
|
||||
var count int
|
||||
h.Store.DB.QueryRow("SELECT COUNT(*) FROM events").Scan(&count)
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"dryrun": true,
|
||||
"would_delete": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip := h.getRealIP(r)
|
||||
log.Printf("[!] AUDIT: Database purged by IP %s", ip)
|
||||
|
||||
h.Store.DB.Exec("DELETE FROM events")
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"dryrun": false,
|
||||
})
|
||||
}
|
||||
+19
-627
@@ -1,22 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/honeywire/hub/internal/auth"
|
||||
"github.com/honeywire/hub/internal/config"
|
||||
"github.com/honeywire/hub/internal/models"
|
||||
"github.com/honeywire/hub/internal/notify"
|
||||
"github.com/honeywire/hub/internal/store"
|
||||
)
|
||||
|
||||
@@ -26,17 +20,6 @@ var upgrader = websocket.Upgrader{
|
||||
},
|
||||
}
|
||||
|
||||
// --- Brute-Force Protection State ---
|
||||
type loginState struct {
|
||||
attempts int
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
authTracker = make(map[string]*loginState)
|
||||
authMutex sync.Mutex
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
@@ -53,44 +36,18 @@ func NewHandler(s *store.Store, cfg *config.Config, sess *auth.SessionStore) *Ha
|
||||
SessionStore: sess,
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
}
|
||||
|
||||
go h.cleanupAuthTracker()
|
||||
return h
|
||||
}
|
||||
|
||||
// Background routine to prevent memory leaks from abandoned IPs
|
||||
func (h *Handler) cleanupAuthTracker() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
authMutex.Lock()
|
||||
now := time.Now()
|
||||
for ip, state := range authTracker {
|
||||
if now.After(state.lockedUntil) {
|
||||
delete(authTracker, ip)
|
||||
}
|
||||
}
|
||||
authMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
func (h *Handler) broadcastWS(msgType string, payload interface{}) {
|
||||
var deadClients []*websocket.Conn
|
||||
h.clientsMu.Lock()
|
||||
for client := range h.clients {
|
||||
err := client.WriteJSON(map[string]interface{}{
|
||||
"type": msgType,
|
||||
"payload": payload,
|
||||
})
|
||||
if err != nil {
|
||||
deadClients = append(deadClients, client)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
for _, c := range deadClients {
|
||||
delete(h.clients, c)
|
||||
c.Close()
|
||||
}
|
||||
h.clientsMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) getRealIP(r *http.Request) string {
|
||||
@@ -109,15 +66,8 @@ func (h *Handler) getRealIP(r *http.Request) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
|
||||
func (h *Handler) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -143,579 +93,21 @@ func (h *Handler) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
// --- Dashboard API ---
|
||||
func (h *Handler) GetSensors(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.Store.DB.Query("SELECT sensor_id, last_seen, metadata, is_silenced FROM sensors ORDER BY sensor_id")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var fleet []models.Sensor
|
||||
for rows.Next() {
|
||||
var s models.Sensor
|
||||
var metadataStr string
|
||||
var isSilencedInt int
|
||||
|
||||
if err := rows.Scan(&s.SensorID, &s.LastSeen, &metadataStr, &isSilencedInt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.IsSilenced = isSilencedInt == 1
|
||||
|
||||
lastSeenTime, err := time.Parse("2006-01-02 15:04:05", s.LastSeen)
|
||||
if err == nil && time.Now().UTC().Sub(lastSeenTime) < 90*time.Second {
|
||||
s.Status = "online"
|
||||
} else {
|
||||
s.Status = "offline"
|
||||
}
|
||||
|
||||
var metadata map[string]interface{}
|
||||
json.Unmarshal([]byte(metadataStr), &metadata)
|
||||
s.Metadata = metadata
|
||||
|
||||
fleet = append(fleet, s)
|
||||
}
|
||||
|
||||
if fleet == nil {
|
||||
fleet = []models.Sensor{}
|
||||
}
|
||||
SendJSON(w, http.StatusOK, fleet)
|
||||
}
|
||||
|
||||
// GET /api/v1/events
|
||||
func (h *Handler) GetEvents(w http.ResponseWriter, r *http.Request) {
|
||||
archivedParam := r.URL.Query().Get("archived")
|
||||
isArchived := 0
|
||||
if archivedParam == "true" {
|
||||
isArchived = 1
|
||||
}
|
||||
|
||||
query := "SELECT id, timestamp, contract_version, sensor_id, event_trigger, severity, source, target, details, is_read, is_archived FROM events WHERE is_archived = ?"
|
||||
args := []interface{}{isArchived}
|
||||
|
||||
if sensorID := r.URL.Query().Get("sensor_id"); sensorID != "" {
|
||||
query += " AND sensor_id = ?"
|
||||
args = append(args, sensorID)
|
||||
}
|
||||
|
||||
query += " ORDER BY id DESC"
|
||||
|
||||
rows, err := h.Store.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []models.Event
|
||||
for rows.Next() {
|
||||
var e models.Event
|
||||
var detailsStr string
|
||||
var isReadInt, isArchivedInt int
|
||||
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.Timestamp, &e.ContractVersion, &e.SensorID,
|
||||
&e.EventTrigger, &e.Severity, &e.Source, &e.Target,
|
||||
&detailsStr, &isReadInt, &isArchivedInt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
e.IsRead = isReadInt == 1
|
||||
e.IsArchived = isArchivedInt == 1
|
||||
json.Unmarshal([]byte(detailsStr), &e.Details)
|
||||
events = append(events, e)
|
||||
}
|
||||
|
||||
if events == nil {
|
||||
events = []models.Event{}
|
||||
}
|
||||
|
||||
SendJSON(w, http.StatusOK, events)
|
||||
}
|
||||
|
||||
func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) {
|
||||
timeframe := r.URL.Query().Get("timeframe")
|
||||
if timeframe == "" {
|
||||
timeframe = "24H"
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var numBlocks int
|
||||
var delta time.Duration
|
||||
var expectedPings float64
|
||||
|
||||
switch timeframe {
|
||||
case "1H":
|
||||
numBlocks, delta, expectedPings = 30, 2*time.Minute, 2
|
||||
case "7D":
|
||||
numBlocks, delta, expectedPings = 7, 24*time.Hour, 1440
|
||||
case "30D":
|
||||
numBlocks, delta, expectedPings = 30, 24*time.Hour, 1440
|
||||
case "24H":
|
||||
fallthrough
|
||||
default:
|
||||
numBlocks, delta, expectedPings = 24, time.Hour, 60
|
||||
}
|
||||
|
||||
cutoff := now.Add(-delta * time.Duration(numBlocks))
|
||||
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
||||
|
||||
sensorRows, err := h.Store.DB.Query("SELECT sensor_id, last_seen, COALESCE(first_seen, ?) FROM sensors ORDER BY sensor_id", now.Format("2006-01-02 15:04:05"))
|
||||
if err != nil {
|
||||
http.Error(w, "Database error fetching sensors", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer sensorRows.Close()
|
||||
|
||||
type SensorData struct {
|
||||
ID string
|
||||
LastSeen time.Time
|
||||
FirstSeen string
|
||||
}
|
||||
var sensors []SensorData
|
||||
history := make(map[string][]float64)
|
||||
|
||||
for sensorRows.Next() {
|
||||
var s SensorData
|
||||
var lastSeenStr string
|
||||
sensorRows.Scan(&s.ID, &lastSeenStr, &s.FirstSeen)
|
||||
s.LastSeen, _ = time.Parse("2006-01-02 15:04:05", lastSeenStr)
|
||||
sensors = append(sensors, s)
|
||||
history[s.ID] = make([]float64, numBlocks)
|
||||
}
|
||||
|
||||
hbRows, err := h.Store.DB.Query("SELECT sensor_id, time_bucket FROM sensor_heartbeats WHERE time_bucket >= ?", cutoffStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error fetching heartbeats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer hbRows.Close()
|
||||
|
||||
for hbRows.Next() {
|
||||
var sID, tBucket string
|
||||
hbRows.Scan(&sID, &tBucket)
|
||||
parsedBucket, _ := time.Parse("2006-01-02 15:04:00", tBucket)
|
||||
|
||||
if parsedBucket.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := int(parsedBucket.Sub(cutoff) / delta)
|
||||
if idx >= numBlocks {
|
||||
idx = numBlocks - 1
|
||||
}
|
||||
|
||||
if idx >= 0 && history[sID] != nil {
|
||||
history[sID][idx]++
|
||||
}
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, s := range sensors {
|
||||
firstSeenParsed, _ := time.Parse("2006-01-02 15:04:05", s.FirstSeen)
|
||||
var blocks []map[string]string
|
||||
|
||||
for i := 0; i < numBlocks; i++ {
|
||||
blockStart := cutoff.Add(time.Duration(i) * delta)
|
||||
blockEnd := blockStart.Add(delta)
|
||||
|
||||
stepsAgo := numBlocks - 1 - i
|
||||
timeLabel := "Current"
|
||||
if stepsAgo > 0 {
|
||||
switch timeframe {
|
||||
case "1H":
|
||||
timeLabel = fmt.Sprintf("%d mins ago", stepsAgo*int(delta.Minutes()))
|
||||
case "24H":
|
||||
timeLabel = fmt.Sprintf("%d hours ago", stepsAgo)
|
||||
case "7D", "30D":
|
||||
timeLabel = fmt.Sprintf("%d days ago", stepsAgo)
|
||||
default:
|
||||
timeLabel = fmt.Sprintf("%d ago", stepsAgo)
|
||||
}
|
||||
}
|
||||
|
||||
status, label := "", ""
|
||||
|
||||
if blockEnd.Before(firstSeenParsed) {
|
||||
status, label = "nodata", "No Data (Not Deployed Yet)"
|
||||
} else {
|
||||
pings := history[s.ID][i]
|
||||
targetPings := expectedPings
|
||||
|
||||
if firstSeenParsed.After(blockStart) && firstSeenParsed.Before(blockEnd) {
|
||||
activeDuration := blockEnd.Sub(firstSeenParsed)
|
||||
targetPings = activeDuration.Minutes()
|
||||
if targetPings > expectedPings {
|
||||
targetPings = expectedPings
|
||||
}
|
||||
if targetPings < 1 && activeDuration > 0 {
|
||||
targetPings = 1
|
||||
}
|
||||
} else if i == numBlocks-1 {
|
||||
activeDuration := now.Sub(blockStart)
|
||||
targetPings = activeDuration.Minutes()
|
||||
if targetPings > expectedPings {
|
||||
targetPings = expectedPings
|
||||
}
|
||||
if targetPings < 1 && activeDuration > 0 {
|
||||
targetPings = 1
|
||||
}
|
||||
}
|
||||
|
||||
if pings == 0 && targetPings >= 1 {
|
||||
status, label = "down", "Offline"
|
||||
} else if targetPings > 0 && pings < (targetPings*0.85) {
|
||||
status, label = "degraded", fmt.Sprintf("Degraded (%.0f/%.0f pings)", pings, targetPings)
|
||||
} else {
|
||||
status, label = "up", "Online"
|
||||
}
|
||||
}
|
||||
|
||||
blocks = append(blocks, map[string]string{
|
||||
"status": status,
|
||||
"timeLabel": timeLabel,
|
||||
"label": label,
|
||||
})
|
||||
}
|
||||
|
||||
isLive := now.Sub(s.LastSeen) < 90*time.Second
|
||||
if isLive {
|
||||
blocks[len(blocks)-1]["status"] = "up"
|
||||
blocks[len(blocks)-1]["label"] = "Online (Live)"
|
||||
} else {
|
||||
blocks[len(blocks)-1]["status"] = "down"
|
||||
blocks[len(blocks)-1]["label"] = "Offline (Live)"
|
||||
}
|
||||
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"name": s.ID,
|
||||
"isOnline": isLive,
|
||||
"blocks": blocks,
|
||||
func (h *Handler) broadcastWS(msgType string, payload interface{}) {
|
||||
var deadClients []*websocket.Conn
|
||||
h.clientsMu.Lock()
|
||||
for client := range h.clients {
|
||||
err := client.WriteJSON(map[string]interface{}{
|
||||
"type": msgType,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []map[string]interface{}{}
|
||||
}
|
||||
SendJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSystemState(w http.ResponseWriter, r *http.Request) {
|
||||
var isArmedStr string
|
||||
h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_armed'").Scan(&isArmedStr)
|
||||
SendJSON(w, http.StatusOK, map[string]bool{"is_armed": isArmedStr == "true"})
|
||||
}
|
||||
|
||||
func (h *Handler) SetSystemState(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IsArmed bool `json:"is_armed"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
val := "false"
|
||||
if req.IsArmed {
|
||||
val = "true"
|
||||
}
|
||||
h.Store.DB.Exec("UPDATE config SET value=? WHERE key='is_armed'", val)
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "is_armed": req.IsArmed})
|
||||
}
|
||||
|
||||
func (h *Handler) HandleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
SendJSON(w, http.StatusOK, map[string]string{"version": h.Cfg.Version})
|
||||
}
|
||||
|
||||
func (h *Handler) GetUnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
var count int
|
||||
err := h.Store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE is_read = 0 AND is_archived = 0").Scan(&count)
|
||||
if err != nil {
|
||||
count = 0
|
||||
}
|
||||
SendJSON(w, http.StatusOK, map[string]int{"count": count})
|
||||
}
|
||||
|
||||
// --- Event Mutations ---
|
||||
func (h *Handler) MarkSingleEventRead(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "event_id")
|
||||
h.Store.DB.Exec("UPDATE events SET is_read = 1 WHERE id = ?", eventID)
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkEventsRead(w http.ResponseWriter, r *http.Request) {
|
||||
h.Store.DB.Exec("UPDATE events SET is_read = 1 WHERE is_read = 0")
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "event_id")
|
||||
h.Store.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE id = ?", eventID)
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveAll(w http.ResponseWriter, r *http.Request) {
|
||||
h.Store.DB.Exec("UPDATE events SET is_archived = 1, is_read = 1 WHERE is_archived = 0")
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/events
|
||||
func (h *Handler) ClearEvents(w http.ResponseWriter, r *http.Request) {
|
||||
dryrun := r.URL.Query().Get("dryrun") == "true"
|
||||
|
||||
if dryrun {
|
||||
var count int
|
||||
h.Store.DB.QueryRow("SELECT COUNT(*) FROM events").Scan(&count)
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"dryrun": true,
|
||||
"would_delete": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip := h.getRealIP(r)
|
||||
log.Printf("[!] AUDIT: Database purged by IP %s", ip)
|
||||
|
||||
h.Store.DB.Exec("DELETE FROM events")
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"dryrun": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ToggleSilence(w http.ResponseWriter, r *http.Request) {
|
||||
sensorID := chi.URLParam(r, "sensor_id")
|
||||
var req struct {
|
||||
IsSilenced bool `json:"is_silenced"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
silenceVal := 0
|
||||
if req.IsSilenced {
|
||||
silenceVal = 1
|
||||
}
|
||||
|
||||
_, err := h.Store.DB.Exec("UPDATE sensors SET is_silenced = ? WHERE sensor_id = ?", silenceVal, sensorID)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcastWS("SILENCE_SENSOR", map[string]interface{}{
|
||||
"sensor_id": sensorID,
|
||||
"is_silenced": req.IsSilenced,
|
||||
})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"sensor_id": sensorID,
|
||||
"is_silenced": req.IsSilenced,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ForgetSensor(w http.ResponseWriter, r *http.Request) {
|
||||
sensorID := chi.URLParam(r, "sensor_id")
|
||||
h.Store.DB.Exec("DELETE FROM sensor_heartbeats WHERE sensor_id = ?", sensorID)
|
||||
|
||||
result, err := h.Store.DB.Exec("DELETE FROM sensors WHERE sensor_id = ?", sensorID)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error while deleting sensor", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
http.Error(w, "Sensor not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcastWS("DELETE_SENSOR", map[string]string{"sensor_id": sensorID})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "success",
|
||||
"message": "Sensor forgotten successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Agent Endpoints ---
|
||||
func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
var hb models.Heartbeat
|
||||
if err := json.NewDecoder(r.Body).Decode(&hb); err != nil {
|
||||
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
nowStr := now.Format("2006-01-02 15:04:05")
|
||||
minuteBucket := now.Format("2006-01-02 15:04:00")
|
||||
metadataJSON, _ := json.Marshal(hb.Metadata)
|
||||
|
||||
// SQLite ON CONFLICT RowsAffected fix:
|
||||
// Try insert-only first to reliably detect genuine new rows
|
||||
res, err := h.Store.DB.Exec(`
|
||||
INSERT OR IGNORE INTO sensors (sensor_id, first_seen, last_seen, metadata)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
hb.SensorID, nowStr, nowStr, string(metadataJSON),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := res.RowsAffected()
|
||||
isNew := affected == 1
|
||||
|
||||
// Update existing record
|
||||
h.Store.DB.Exec(`UPDATE sensors SET last_seen=?, metadata=? WHERE sensor_id=?`,
|
||||
nowStr, string(metadataJSON), hb.SensorID)
|
||||
|
||||
// Log historical bucket
|
||||
h.Store.DB.Exec("INSERT OR IGNORE INTO sensor_heartbeats (sensor_id, time_bucket) VALUES (?, ?)", hb.SensorID, minuteBucket)
|
||||
|
||||
if isNew {
|
||||
h.broadcastWS("NEW_SENSOR", map[string]string{"sensor_id": hb.SensorID})
|
||||
}
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "alive"})
|
||||
}
|
||||
|
||||
func (h *Handler) ReceiveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var e models.Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
||||
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hubMajor := strings.Split(h.Cfg.Version, ".")[0]
|
||||
agentMajor := strings.Split(e.ContractVersion, ".")[0]
|
||||
if agentMajor == "" || hubMajor != agentMajor {
|
||||
http.Error(w, "Upgrade Required", http.StatusUpgradeRequired)
|
||||
return
|
||||
}
|
||||
|
||||
nowStr := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
detailsJSON, _ := json.Marshal(e.Details)
|
||||
|
||||
result, err := h.Store.DB.Exec(`
|
||||
INSERT INTO events (timestamp, contract_version, sensor_id, event_trigger, severity, source, target, details, is_read, is_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`,
|
||||
nowStr, e.ContractVersion, e.SensorID, e.EventTrigger, e.Severity, e.Source, e.Target, string(detailsJSON),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
lastInsertID, _ := result.LastInsertId()
|
||||
|
||||
var isArmedStr string
|
||||
h.Store.DB.QueryRow("SELECT value FROM config WHERE key='is_armed'").Scan(&isArmedStr)
|
||||
|
||||
var isSilencedInt int
|
||||
h.Store.DB.QueryRow("SELECT is_silenced FROM sensors WHERE sensor_id = ?", e.SensorID).Scan(&isSilencedInt)
|
||||
|
||||
msg := "[" + e.SensorID + "] " + strings.ToUpper(e.EventTrigger) + " — " + e.Source + " -> " + e.Target
|
||||
|
||||
if isArmedStr == "true" && isSilencedInt == 0 {
|
||||
notify.Dispatch(h.Cfg, "HoneyWire Alert ("+strings.ToUpper(e.Severity)+")", msg, e.Severity)
|
||||
}
|
||||
|
||||
e.ID = int(lastInsertID)
|
||||
e.Timestamp = nowStr
|
||||
|
||||
h.broadcastWS("NEW_EVENT", e)
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := h.getRealIP(r)
|
||||
|
||||
authMutex.Lock()
|
||||
if state, exists := authTracker[ip]; exists {
|
||||
// FIX: Only check the lockout timer if they actually hit the 10-attempt limit!
|
||||
if state.attempts >= 10 {
|
||||
if time.Now().Before(state.lockedUntil) {
|
||||
authMutex.Unlock()
|
||||
http.Error(w, "Too many failed attempts. Try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// Lockout expired, wipe the slate clean
|
||||
delete(authTracker, ip)
|
||||
}
|
||||
}
|
||||
authMutex.Unlock()
|
||||
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.Cfg.DashboardPassword)) == 1 {
|
||||
authMutex.Lock()
|
||||
delete(authTracker, ip)
|
||||
authMutex.Unlock()
|
||||
|
||||
token, err := h.SessionStore.Create()
|
||||
if err != nil {
|
||||
http.Error(w, "Session creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
deadClients = append(deadClients, client)
|
||||
}
|
||||
|
||||
isProd := h.Cfg.Env == "production"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: token,
|
||||
MaxAge: 2592000,
|
||||
HttpOnly: true,
|
||||
Secure: isProd,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
authMutex.Lock()
|
||||
if _, exists := authTracker[ip]; !exists {
|
||||
authTracker[ip] = &loginState{}
|
||||
for _, c := range deadClients {
|
||||
delete(h.clients, c)
|
||||
c.Close()
|
||||
}
|
||||
authTracker[ip].attempts++
|
||||
|
||||
if authTracker[ip].attempts >= 10 {
|
||||
authTracker[ip].lockedUntil = time.Now().Add(15 * time.Minute)
|
||||
log.Printf("[!] AUDIT: IP %s locked out of dashboard for 15 minutes due to brute-force", ip)
|
||||
}
|
||||
authMutex.Unlock()
|
||||
|
||||
http.Error(w, "Invalid Password", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// POST /logout
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.CookieName); err == nil {
|
||||
h.SessionStore.Delete(cookie.Value)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
h.clientsMu.Unlock()
|
||||
}
|
||||
@@ -6,33 +6,36 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/honeywire/hub/internal/auth"
|
||||
"github.com/honeywire/hub/internal/config"
|
||||
"github.com/honeywire/hub/internal/store"
|
||||
)
|
||||
|
||||
// UIAuthMiddleware requires a valid session cookie
|
||||
func UIAuthMiddleware(cfg *config.Config, store *auth.SessionStore) func(http.Handler) http.Handler {
|
||||
// UIAuthMiddleware strictly requires a valid session cookie for ALL dashboard routes
|
||||
func UIAuthMiddleware(sessionStore *auth.SessionStore) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.DashboardPassword != "" {
|
||||
cookie, err := r.Cookie(auth.CookieName)
|
||||
if err != nil || !store.IsValid(cookie.Value) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(auth.CookieName)
|
||||
if err != nil || !sessionStore.IsValid(cookie.Value) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// If authorized, pass the request to the actual endpoint
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAuthMiddleware requires the correct API Secret from the sensors
|
||||
func AgentAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
// AgentAuthMiddleware securely validates sensor heartbeats/events against the DB Config
|
||||
func AgentAuthMiddleware(s *store.Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var configuredKey string
|
||||
err := s.DB.QueryRow("SELECT value FROM config WHERE key='hub_key'").Scan(&configuredKey)
|
||||
if err != nil || configuredKey == "" {
|
||||
http.Error(w, "Hub is not fully configured.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
token := r.Header.Get("X-Api-Key")
|
||||
|
||||
// Fallback to Bearer token
|
||||
if token == "" {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
@@ -40,9 +43,9 @@ func AgentAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// subtle.ConstantTimeCompare prevents timing attacks
|
||||
if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(cfg.APISecret)) != 1 {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
//Constant Time Compare prevents timing-attack vulnerability scanning
|
||||
if token == "" || len(token) != len(configuredKey) || subtle.ConstantTimeCompare([]byte(token), []byte(configuredKey)) != 1 {
|
||||
http.Error(w, "Unauthorized Sensor", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -41,34 +41,44 @@ func SetupRouter(cfg *config.Config, s *store.Store, sessionStore *auth.SessionS
|
||||
r.Get("/api/v1/version", h.HandleVersion)
|
||||
r.Post("/login", h.Login)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Get("/api/v1/setup/status", h.GetSetupStatus)
|
||||
r.Post("/api/v1/setup", h.CompleteSetup)
|
||||
|
||||
// UI Endpoints (Protected by Cookies)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(UIAuthMiddleware(cfg, sessionStore))
|
||||
r.Use(UIAuthMiddleware(sessionStore))
|
||||
|
||||
r.Get("/api/v1/ws", h.ServeWS)
|
||||
|
||||
// System Configuration & Danger Zone
|
||||
r.Get("/api/v1/config", h.GetConfig)
|
||||
r.Patch("/api/v1/config", h.UpdateConfig)
|
||||
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)
|
||||
r.Get("/api/v1/events", h.GetEvents)
|
||||
r.Get("/api/v1/uptime", h.GetUptime)
|
||||
|
||||
r.Get("/api/v1/system/state", h.GetSystemState)
|
||||
r.Patch("/api/v1/system/state", h.SetSystemState)
|
||||
|
||||
// Event Management
|
||||
r.Get("/api/v1/events/unread", h.GetUnreadCount)
|
||||
r.Patch("/api/v1/events/read", h.MarkEventsRead)
|
||||
r.Patch("/api/v1/events/{event_id}/read", h.MarkSingleEventRead)
|
||||
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 (Protected by API Key)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AgentAuthMiddleware(cfg))
|
||||
r.Use(AgentAuthMiddleware(s))
|
||||
r.Post("/api/v1/heartbeat", h.ReceiveHeartbeat)
|
||||
r.Post("/api/v1/event", h.ReceiveEvent)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/honeywire/hub/internal/models"
|
||||
)
|
||||
|
||||
func (h *Handler) ReceiveHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
var hb models.Heartbeat
|
||||
if err := json.NewDecoder(r.Body).Decode(&hb); err != nil {
|
||||
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
nowStr := now.Format("2006-01-02 15:04:05")
|
||||
minuteBucket := now.Format("2006-01-02 15:04:00")
|
||||
metadataJSON, _ := json.Marshal(hb.Metadata)
|
||||
|
||||
res, err := h.Store.DB.Exec(`
|
||||
INSERT OR IGNORE INTO sensors (sensor_id, first_seen, last_seen, metadata)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
hb.SensorID, nowStr, nowStr, string(metadataJSON),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := res.RowsAffected()
|
||||
isNew := affected == 1
|
||||
|
||||
h.Store.DB.Exec(`UPDATE sensors SET last_seen=?, metadata=? WHERE sensor_id=?`,
|
||||
nowStr, string(metadataJSON), hb.SensorID)
|
||||
|
||||
h.Store.DB.Exec("INSERT OR IGNORE INTO sensor_heartbeats (sensor_id, time_bucket) VALUES (?, ?)", hb.SensorID, minuteBucket)
|
||||
|
||||
if isNew {
|
||||
h.broadcastWS("NEW_SENSOR", map[string]string{"sensor_id": hb.SensorID})
|
||||
}
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{"status": "alive"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSensors(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.Store.DB.Query("SELECT sensor_id, last_seen, metadata, is_silenced FROM sensors ORDER BY sensor_id")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var fleet []models.Sensor
|
||||
for rows.Next() {
|
||||
var s models.Sensor
|
||||
var metadataStr string
|
||||
var isSilencedInt int
|
||||
|
||||
if err := rows.Scan(&s.SensorID, &s.LastSeen, &metadataStr, &isSilencedInt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.IsSilenced = isSilencedInt == 1
|
||||
|
||||
lastSeenTime, err := time.Parse("2006-01-02 15:04:05", s.LastSeen)
|
||||
if err == nil && time.Now().UTC().Sub(lastSeenTime) < 90*time.Second {
|
||||
s.Status = "online"
|
||||
} else {
|
||||
s.Status = "offline"
|
||||
}
|
||||
|
||||
var metadata map[string]interface{}
|
||||
json.Unmarshal([]byte(metadataStr), &metadata)
|
||||
s.Metadata = metadata
|
||||
|
||||
fleet = append(fleet, s)
|
||||
}
|
||||
|
||||
if fleet == nil {
|
||||
fleet = []models.Sensor{}
|
||||
}
|
||||
SendJSON(w, http.StatusOK, fleet)
|
||||
}
|
||||
|
||||
func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) {
|
||||
timeframe := r.URL.Query().Get("timeframe")
|
||||
if timeframe == "" {
|
||||
timeframe = "24H"
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var numBlocks int
|
||||
var delta time.Duration
|
||||
var expectedPings float64
|
||||
|
||||
switch timeframe {
|
||||
case "1H":
|
||||
numBlocks, delta, expectedPings = 30, 2*time.Minute, 2
|
||||
case "7D":
|
||||
numBlocks, delta, expectedPings = 7, 24*time.Hour, 1440
|
||||
case "30D":
|
||||
numBlocks, delta, expectedPings = 30, 24*time.Hour, 1440
|
||||
case "24H":
|
||||
fallthrough
|
||||
default:
|
||||
numBlocks, delta, expectedPings = 24, time.Hour, 60
|
||||
}
|
||||
|
||||
cutoff := now.Add(-delta * time.Duration(numBlocks))
|
||||
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
|
||||
|
||||
sensorRows, err := h.Store.DB.Query("SELECT sensor_id, last_seen, COALESCE(first_seen, ?) FROM sensors ORDER BY sensor_id", now.Format("2006-01-02 15:04:05"))
|
||||
if err != nil {
|
||||
http.Error(w, "Database error fetching sensors", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer sensorRows.Close()
|
||||
|
||||
type SensorData struct {
|
||||
ID string
|
||||
LastSeen time.Time
|
||||
FirstSeen string
|
||||
}
|
||||
var sensors []SensorData
|
||||
history := make(map[string][]float64)
|
||||
|
||||
for sensorRows.Next() {
|
||||
var s SensorData
|
||||
var lastSeenStr string
|
||||
sensorRows.Scan(&s.ID, &lastSeenStr, &s.FirstSeen)
|
||||
s.LastSeen, _ = time.Parse("2006-01-02 15:04:05", lastSeenStr)
|
||||
sensors = append(sensors, s)
|
||||
history[s.ID] = make([]float64, numBlocks)
|
||||
}
|
||||
|
||||
hbRows, err := h.Store.DB.Query("SELECT sensor_id, time_bucket FROM sensor_heartbeats WHERE time_bucket >= ?", cutoffStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error fetching heartbeats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer hbRows.Close()
|
||||
|
||||
for hbRows.Next() {
|
||||
var sID, tBucket string
|
||||
hbRows.Scan(&sID, &tBucket)
|
||||
parsedBucket, _ := time.Parse("2006-01-02 15:04:00", tBucket)
|
||||
|
||||
if parsedBucket.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := int(parsedBucket.Sub(cutoff) / delta)
|
||||
if idx >= numBlocks {
|
||||
idx = numBlocks - 1
|
||||
}
|
||||
|
||||
if idx >= 0 && history[sID] != nil {
|
||||
history[sID][idx]++
|
||||
}
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, s := range sensors {
|
||||
firstSeenParsed, _ := time.Parse("2006-01-02 15:04:05", s.FirstSeen)
|
||||
var blocks []map[string]string
|
||||
|
||||
for i := 0; i < numBlocks; i++ {
|
||||
blockStart := cutoff.Add(time.Duration(i) * delta)
|
||||
blockEnd := blockStart.Add(delta)
|
||||
|
||||
stepsAgo := numBlocks - 1 - i
|
||||
timeLabel := "Current"
|
||||
if stepsAgo > 0 {
|
||||
switch timeframe {
|
||||
case "1H":
|
||||
timeLabel = fmt.Sprintf("%d mins ago", stepsAgo*int(delta.Minutes()))
|
||||
case "24H":
|
||||
timeLabel = fmt.Sprintf("%d hours ago", stepsAgo)
|
||||
case "7D", "30D":
|
||||
timeLabel = fmt.Sprintf("%d days ago", stepsAgo)
|
||||
default:
|
||||
timeLabel = fmt.Sprintf("%d ago", stepsAgo)
|
||||
}
|
||||
}
|
||||
|
||||
status, label := "", ""
|
||||
|
||||
if blockEnd.Before(firstSeenParsed) {
|
||||
status, label = "nodata", "No Data (Not Deployed Yet)"
|
||||
} else {
|
||||
pings := history[s.ID][i]
|
||||
targetPings := expectedPings
|
||||
|
||||
if firstSeenParsed.After(blockStart) && firstSeenParsed.Before(blockEnd) {
|
||||
activeDuration := blockEnd.Sub(firstSeenParsed)
|
||||
targetPings = activeDuration.Minutes()
|
||||
if targetPings > expectedPings {
|
||||
targetPings = expectedPings
|
||||
}
|
||||
if targetPings < 1 && activeDuration > 0 {
|
||||
targetPings = 1
|
||||
}
|
||||
} else if i == numBlocks-1 {
|
||||
activeDuration := now.Sub(blockStart)
|
||||
targetPings = activeDuration.Minutes()
|
||||
if targetPings > expectedPings {
|
||||
targetPings = expectedPings
|
||||
}
|
||||
if targetPings < 1 && activeDuration > 0 {
|
||||
targetPings = 1
|
||||
}
|
||||
}
|
||||
|
||||
if pings == 0 && targetPings >= 1 {
|
||||
status, label = "down", "Offline"
|
||||
} else if targetPings > 0 && pings < (targetPings*0.85) {
|
||||
status, label = "degraded", fmt.Sprintf("Degraded (%.0f/%.0f pings)", pings, targetPings)
|
||||
} else {
|
||||
status, label = "up", "Online"
|
||||
}
|
||||
}
|
||||
|
||||
blocks = append(blocks, map[string]string{
|
||||
"status": status,
|
||||
"timeLabel": timeLabel,
|
||||
"label": label,
|
||||
})
|
||||
}
|
||||
|
||||
isLive := now.Sub(s.LastSeen) < 90*time.Second
|
||||
if isLive {
|
||||
blocks[len(blocks)-1]["status"] = "up"
|
||||
blocks[len(blocks)-1]["label"] = "Online (Live)"
|
||||
} else {
|
||||
blocks[len(blocks)-1]["status"] = "down"
|
||||
blocks[len(blocks)-1]["label"] = "Offline (Live)"
|
||||
}
|
||||
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"name": s.ID,
|
||||
"isOnline": isLive,
|
||||
"blocks": blocks,
|
||||
})
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []map[string]interface{}{}
|
||||
}
|
||||
SendJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) ToggleSilence(w http.ResponseWriter, r *http.Request) {
|
||||
sensorID := chi.URLParam(r, "sensor_id")
|
||||
var req struct {
|
||||
IsSilenced bool `json:"is_silenced"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
silenceVal := 0
|
||||
if req.IsSilenced {
|
||||
silenceVal = 1
|
||||
}
|
||||
|
||||
_, err := h.Store.DB.Exec("UPDATE sensors SET is_silenced = ? WHERE sensor_id = ?", silenceVal, sensorID)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcastWS("SILENCE_SENSOR", map[string]interface{}{
|
||||
"sensor_id": sensorID,
|
||||
"is_silenced": req.IsSilenced,
|
||||
})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"sensor_id": sensorID,
|
||||
"is_silenced": req.IsSilenced,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ForgetSensor(w http.ResponseWriter, r *http.Request) {
|
||||
sensorID := chi.URLParam(r, "sensor_id")
|
||||
h.Store.DB.Exec("DELETE FROM sensor_heartbeats WHERE sensor_id = ?", sensorID)
|
||||
|
||||
result, err := h.Store.DB.Exec("DELETE FROM sensors WHERE sensor_id = ?", sensorID)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error while deleting sensor", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
http.Error(w, "Sensor not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcastWS("DELETE_SENSOR", map[string]string{"sensor_id": sensorID})
|
||||
|
||||
SendJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "success",
|
||||
"message": "Sensor forgotten successfully",
|
||||
})
|
||||
}
|
||||
@@ -9,16 +9,29 @@ import (
|
||||
|
||||
const CookieName = "hw_auth"
|
||||
|
||||
// Mutex for thread safety
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]time.Time),
|
||||
}
|
||||
s := &SessionStore{sessions: make(map[string]time.Time)}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanup() {
|
||||
for {
|
||||
time.Sleep(1 * time.Hour)
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
for token, exp := range s.sessions {
|
||||
if now.After(exp) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Create generates a secure 32-byte hex token and stores it for 30 days
|
||||
@@ -47,7 +60,7 @@ func (s *SessionStore) IsValid(token string) bool {
|
||||
|
||||
if time.Now().After(expiration) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, token) // Clean up expired session
|
||||
delete(s.sessions, token)
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
@@ -59,4 +72,11 @@ func (s *SessionStore) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, token)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Wipes all active sessions, forcing everyone to log back in.
|
||||
func (s *SessionStore) ClearAllSessions() {
|
||||
s.mu.Lock()
|
||||
s.sessions = make(map[string]time.Time)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -5,17 +5,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config represents immutable infrastructure-level settings.
|
||||
// Runtime settings (like API keys and webhooks) are managed in SQLite.
|
||||
type Config struct {
|
||||
APISecret string
|
||||
DashboardPassword string
|
||||
DashboardPassword string // Optional override. If set, bypasses the Setup UI.
|
||||
DBPath string
|
||||
NtfyURL string
|
||||
GotifyURL string
|
||||
GotifyToken string
|
||||
Port string
|
||||
Version string
|
||||
Env string
|
||||
TrustProxy bool
|
||||
TrustProxy bool
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
@@ -26,20 +24,16 @@ func getEnv(key, fallback string) string {
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
// Parse the TrustProxy boolean safely
|
||||
trustProxyStr := strings.ToLower(getEnv("HW_TRUST_PROXY", "false"))
|
||||
isProxyTrusted := trustProxyStr == "true" || trustProxyStr == "1"
|
||||
|
||||
return &Config{
|
||||
APISecret: getEnv("HW_HUB_KEY", "change_this_to_a_secure_random_string"),
|
||||
DashboardPassword: getEnv("HW_DASHBOARD_PASSWORD", "admin"),
|
||||
DBPath: getEnv("HW_DB_PATH", "test_honeywire.db"), // Defaulting to local for testing
|
||||
NtfyURL: getEnv("HW_NTFY_URL", ""),
|
||||
GotifyURL: getEnv("HW_GOTIFY_URL", ""),
|
||||
GotifyToken: getEnv("HW_GOTIFY_TOKEN", ""),
|
||||
// Default to empty string. If empty, the Vue frontend will force the Setup screen.
|
||||
DashboardPassword: getEnv("HW_DASHBOARD_PASSWORD", ""),
|
||||
DBPath: getEnv("HW_DB_PATH", "honeywire.db"),
|
||||
Port: getEnv("HW_PORT", "8080"),
|
||||
Version: getEnv("HW_VERSION", "1.0.0"),
|
||||
Env: getEnv("HW_ENV", "development"), // Defaults to development (set the Secure flag on the hw_auth cookie in production)
|
||||
TrustProxy: isProxyTrusted, // Defaults to false (trust the X-Forwarded-For header only if explicitly configured to do so)
|
||||
Env: getEnv("HW_ENV", "production"),
|
||||
TrustProxy: isProxyTrusted,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,24 @@
|
||||
package models
|
||||
|
||||
|
||||
// SetupPayload represents the initial setup POST request
|
||||
type SetupPayload struct {
|
||||
Password string `json:"password"`
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
HubKey string `json:"hub_key"`
|
||||
}
|
||||
|
||||
// ConfigPayload represents the runtime configuration of the Hub
|
||||
type ConfigPayload struct {
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
HubKey string `json:"hub_key"`
|
||||
AutoArchiveDays int `json:"auto_archive_days"`
|
||||
AutoPurgeDays int `json:"auto_purge_days"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
WebhookType string `json:"webhook_type"`
|
||||
WebhookEvents []string `json:"webhook_events"`
|
||||
}
|
||||
|
||||
// Event represents an incoming alert from a sensor
|
||||
type Event struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
@@ -6,22 +6,30 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/honeywire/hub/internal/config"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var client = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
func Dispatch(cfg *config.Config, title, message, severity string) {
|
||||
if cfg.NtfyURL != "" {
|
||||
go sendNtfy(cfg, title, message, severity)
|
||||
func Dispatch(webhookType, webhookURL, title, message, severity string) {
|
||||
if webhookURL == "" {
|
||||
return
|
||||
}
|
||||
if cfg.GotifyURL != "" && cfg.GotifyToken != "" {
|
||||
go sendGotify(cfg, title, message, severity)
|
||||
|
||||
switch strings.ToLower(webhookType) {
|
||||
case "discord", "slack":
|
||||
go sendDiscordSlack(webhookURL, title, message, severity)
|
||||
case "gotify":
|
||||
go sendGotify(webhookURL, title, message, severity)
|
||||
case "ntfy":
|
||||
fallthrough
|
||||
default:
|
||||
go sendNtfy(webhookURL, title, message, severity)
|
||||
}
|
||||
}
|
||||
|
||||
func sendGotify(cfg *config.Config, title, message, severity string) {
|
||||
func sendGotify(webhookURL, title, message, severity string) {
|
||||
priorities := map[string]int{"info": 1, "low": 3, "medium": 5, "high": 8, "critical": 10}
|
||||
priority, exists := priorities[severity]
|
||||
if !exists {
|
||||
@@ -35,8 +43,9 @@ func sendGotify(cfg *config.Config, title, message, severity string) {
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", cfg.GotifyURL, bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Gotify-App-Token", cfg.GotifyToken)
|
||||
// If the user pasted the Gotify URL as https://gotify.domain.com/message?token=XYZ,
|
||||
// standard POSTing to it natively works without needing the separate Header.
|
||||
req, _ := http.NewRequest("POST", webhookURL, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -51,26 +60,45 @@ func sendGotify(cfg *config.Config, title, message, severity string) {
|
||||
}
|
||||
}
|
||||
|
||||
func sendNtfy(cfg *config.Config, title, message, severity string) {
|
||||
func sendNtfy(webhookURL, title, message, severity string) {
|
||||
priorities := map[string]string{"info": "1", "low": "2", "medium": "3", "high": "4", "critical": "5"}
|
||||
priority, exists := priorities[severity]
|
||||
if !exists {
|
||||
priority = "3"
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", cfg.NtfyURL, bytes.NewBufferString(message))
|
||||
req, _ := http.NewRequest("POST", webhookURL, strings.NewReader(message))
|
||||
req.Header.Set("Title", title)
|
||||
req.Header.Set("Priority", priority)
|
||||
req.Header.Set("Tags", "rotating_light")
|
||||
|
||||
if cfg.NtfyURL != "" { // Only add auth if token exists (logic can be expanded here)
|
||||
// req.Header.Set("Authorization", "Bearer "+cfg.NtfyToken)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Ntfy connection failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func sendDiscordSlack(webhookURL, title, message, severity string) {
|
||||
// Discord and Slack both accept basic JSON payloads with a "content" string.
|
||||
icon := "⚠️"
|
||||
if severity == "critical" || severity == "high" {
|
||||
icon = "🚨"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"content": fmt.Sprintf("%s **%s**\n%s", icon, title, message),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", webhookURL, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Discord/Slack connection failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
+134
-13
@@ -2,13 +2,16 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const InitSchema = `
|
||||
// baselineSchema represents v1 of the database
|
||||
const baselineSchema = `
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
@@ -21,9 +24,14 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
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
|
||||
count INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- HIGH PERFORMANCE INDEXES FOR DASHBOARD QUERIES
|
||||
CREATE INDEX IF NOT EXISTS idx_events_archived ON events(is_archived, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_sensor ON events(sensor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sensors (
|
||||
sensor_id TEXT PRIMARY KEY,
|
||||
first_seen TEXT,
|
||||
@@ -36,7 +44,6 @@ CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO config (key, value) VALUES ('is_armed', 'true');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sensor_heartbeats (
|
||||
sensor_id TEXT NOT NULL,
|
||||
@@ -46,27 +53,141 @@ CREATE TABLE IF NOT EXISTS sensor_heartbeats (
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeats_time ON sensor_heartbeats(time_bucket);
|
||||
`
|
||||
|
||||
// migrations holds all schema changes in chronological order.
|
||||
var migrations = []string{
|
||||
baselineSchema, // Version 1 (v1.0.0)
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// NewStore connects to SQLite, runs the migrations, and returns the Store
|
||||
// backupDB creates a copy of the SQLite file before applying destructive changes
|
||||
func backupDB(dbPath string, version int) {
|
||||
if version == 0 {
|
||||
return // Nothing to back up on a completely fresh install
|
||||
}
|
||||
|
||||
backupPath := fmt.Sprintf("%s.v%d.bak", dbPath, version)
|
||||
|
||||
sourceFile, err := os.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[!] Warning: Could not open DB for backup: %v", err)
|
||||
return
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(backupPath)
|
||||
if err != nil {
|
||||
log.Printf("[!] Warning: Could not create DB backup file: %v", err)
|
||||
return
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, sourceFile); err == nil {
|
||||
log.Printf("[DB] Auto-Backup created at %s before applying migrations.", backupPath)
|
||||
}
|
||||
}
|
||||
|
||||
// runMigrations safely advances the database schema to the latest version
|
||||
func runMigrations(db *sql.DB, dbPath string) error {
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
var currentVersion int
|
||||
err = db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(¤tVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read current schema version: %w", err)
|
||||
}
|
||||
|
||||
// If we are about to apply new migrations to an existing DB, back it up first.
|
||||
if currentVersion > 0 && currentVersion < len(migrations) {
|
||||
backupDB(dbPath, currentVersion)
|
||||
}
|
||||
|
||||
for i := currentVersion; i < len(migrations); i++ {
|
||||
targetVersion := i + 1
|
||||
log.Printf("[DB] Applying database migration v%d...", targetVersion)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to apply migration v%d: %v", targetVersion, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, targetVersion); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[DB] Migration v%d applied successfully.", targetVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewStore(dbPath string) (*Store, error) {
|
||||
dsn := fmt.Sprintf("%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", dbPath)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(25)
|
||||
|
||||
|
||||
_, err = db.Exec(InitSchema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Pass the dbPath to the migration runner so it knows what file to copy
|
||||
if err := runMigrations(db, dbPath); err != nil {
|
||||
return nil, fmt.Errorf("database migration failed: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database initialized successfully in WAL mode.")
|
||||
if err := InitializeDefaultConfig(db); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize default config: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[DB] Database initialized successfully in WAL mode.")
|
||||
return &Store{DB: db}, nil
|
||||
}
|
||||
|
||||
func InitializeDefaultConfig(db *sql.DB) error {
|
||||
defaults := map[string]string{
|
||||
"is_armed": "true",
|
||||
"is_setup": "false",
|
||||
"hub_endpoint": "",
|
||||
"hub_key": "",
|
||||
"auto_archive_days": "0",
|
||||
"auto_purge_days": "0",
|
||||
"webhook_url": "",
|
||||
"webhook_type": "ntfy",
|
||||
"webhook_events": "critical,high,medium,low,info",
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmt, err := tx.Prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for k, v := range defaults {
|
||||
if _, err := stmt.Exec(k, v); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
+44
-16
@@ -5,32 +5,55 @@
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Login from './views/Login.vue'
|
||||
import { useSentinel } from './api/useSentinel'
|
||||
import Store from './views/Store.vue'
|
||||
import Settings from './views/Settings.vue'
|
||||
import { useConfig } from './api/useConfig'
|
||||
import Setup from './views/Setup.vue'
|
||||
|
||||
const {
|
||||
version,
|
||||
isArmed,
|
||||
unreadCount,
|
||||
viewingArchive,
|
||||
startPolling,
|
||||
startRealtimeSync,
|
||||
toggleArmed,
|
||||
markAllRead,
|
||||
events,
|
||||
logout // <-- Extracted logout action
|
||||
logout
|
||||
} = useSentinel()
|
||||
const { fetchConfig } = useConfig()
|
||||
|
||||
const requiresSetup = ref(false)
|
||||
const isAuthenticated = ref(false)
|
||||
const currentView = ref('dashboard')
|
||||
const sidebarOpen = ref(true)
|
||||
|
||||
const checkAuthAndInit = async () => {
|
||||
try {
|
||||
const setupRes = await fetch('/api/v1/setup/status')
|
||||
if (setupRes.ok) {
|
||||
const setupData = await setupRes.json()
|
||||
if (setupData.requires_setup) {
|
||||
requiresSetup.value = true
|
||||
isAuthenticated.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
requiresSetup.value = false
|
||||
|
||||
const res = await fetch('/api/v1/system/state')
|
||||
if (res.ok) {
|
||||
isAuthenticated.value = true
|
||||
startPolling()
|
||||
await fetchConfig()
|
||||
|
||||
startRealtimeSync()
|
||||
} else {
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
// Not authenticated
|
||||
console.error("Hub connection error:", e)
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +71,7 @@
|
||||
// --- DRYRUN PURGE LOGIC ---
|
||||
const clearLogs = async () => {
|
||||
try {
|
||||
// Step 1: Perform the Dry Run to get the exact count
|
||||
//Perform the Dry Run to get the exact count
|
||||
const dryRes = await fetch('/api/v1/events?dryrun=true', { method: 'DELETE' })
|
||||
if (!dryRes.ok) throw new Error("Failed to fetch dryrun data")
|
||||
|
||||
@@ -60,15 +83,15 @@
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Ask user with the specific count
|
||||
//Ask user with the specific count
|
||||
if (confirm(`Confirm Database Purge?\n\nThis will permanently delete ${count} active and archived event logs.\n\nThis action cannot be undone.`)) {
|
||||
|
||||
// Optimistic UI wipe
|
||||
if (events) events.value = []
|
||||
if (unreadCount) unreadCount.value = 0
|
||||
|
||||
// Step 3: The actual deletion
|
||||
const response = await fetch('/api/v1/events', {
|
||||
//The actual deletion
|
||||
const response = await fetch('/api/v1/events?dryrun=false', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
@@ -88,16 +111,21 @@
|
||||
|
||||
onMounted(() => {
|
||||
checkAuthAndInit()
|
||||
|
||||
if (localStorage.getItem('theme') === 'light' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="requiresSetup" class="h-screen bg-slate-100 dark:bg-[#0a0a0c]">
|
||||
<Setup @setup-complete="checkAuthAndInit" @toggle-theme="toggleTheme" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isAuthenticated" class="h-screen bg-slate-100 dark:bg-[#0a0a0c]">
|
||||
<Login
|
||||
@login-success="checkAuthAndInit"
|
||||
@@ -137,11 +165,11 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentView === 'store'">
|
||||
<h1 class="text-xl font-bold">Sensor Store Placeholder</h1>
|
||||
<Store />
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentView === 'settings'">
|
||||
<h1 class="text-xl font-bold">Settings Placeholder</h1>
|
||||
<Settings />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
isLoaded: false,
|
||||
hubEndpoint: '',
|
||||
hubKey: '',
|
||||
autoArchiveDays: 0,
|
||||
autoPurgeDays: 0,
|
||||
webhookType: 'ntfy',
|
||||
webhookUrl: '',
|
||||
webhookEvents: []
|
||||
})
|
||||
|
||||
export function useConfig() {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/config')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
state.hubEndpoint = data.hub_endpoint || window.location.origin
|
||||
state.hubKey = data.hub_key || ''
|
||||
state.autoArchiveDays = data.auto_archive_days || 0
|
||||
state.autoPurgeDays = data.auto_purge_days || 0
|
||||
state.webhookType = data.webhook_type || 'ntfy'
|
||||
state.webhookUrl = data.webhook_url || ''
|
||||
state.webhookEvents = data.webhook_events || []
|
||||
state.isLoaded = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config", error)
|
||||
}
|
||||
}
|
||||
|
||||
const patchConfig = async (updates) => {
|
||||
try {
|
||||
const payload = {}
|
||||
if (updates.hubEndpoint !== undefined) payload.hub_endpoint = updates.hubEndpoint
|
||||
if (updates.hubKey !== undefined) payload.hub_key = updates.hubKey
|
||||
if (updates.autoArchiveDays !== undefined) payload.auto_archive_days = updates.autoArchiveDays
|
||||
if (updates.autoPurgeDays !== undefined) payload.auto_purge_days = updates.autoPurgeDays
|
||||
if (updates.webhookType !== undefined) payload.webhook_type = updates.webhookType
|
||||
if (updates.webhookUrl !== undefined) payload.webhook_url = updates.webhookUrl
|
||||
if (updates.webhookEvents !== undefined) payload.webhook_events = updates.webhookEvents
|
||||
|
||||
const res = await fetch('/api/v1/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
Object.assign(state, updates)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: readonly(state),
|
||||
fetchConfig,
|
||||
patchConfig
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function useSentinel() {
|
||||
|
||||
// --- WEBSOCKET ENGINE ---
|
||||
let ws = null;
|
||||
let pollInterval = null;
|
||||
let healthSyncInterval = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
const connectWS = () => {
|
||||
@@ -106,8 +106,6 @@ const connectWS = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//${window.location.host}/api/v1/ws`);
|
||||
|
||||
ws.onopen = () => console.log('🟢 WebSockets Connected. Real-time active.');
|
||||
|
||||
ws.onmessage = (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.data);
|
||||
@@ -151,16 +149,25 @@ const connectWS = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = async () => {
|
||||
const startRealtimeSync = async () => {
|
||||
isDestroyed = false;
|
||||
|
||||
// Initial data load
|
||||
await Promise.all([fetchEvents(), fetchFleet(), fetchUptime()])
|
||||
|
||||
// 1. Establish the WebSocket for instant Push Notifications (Events, Silence toggles, etc.)
|
||||
connectWS()
|
||||
pollInterval = setInterval(() => { fetchFleet(); fetchUptime() }, 30000)
|
||||
|
||||
// 2. Establish the Health Sync loop for fleet and uptime data.
|
||||
healthSyncInterval = setInterval(() => {
|
||||
fetchFleet();
|
||||
fetchUptime()
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
const stopRealtimeSync = () => {
|
||||
isDestroyed = true;
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
if (healthSyncInterval) clearInterval(healthSyncInterval);
|
||||
if (ws) ws.close();
|
||||
}
|
||||
|
||||
@@ -245,7 +252,7 @@ const connectWS = () => {
|
||||
return {
|
||||
events, fleet, uptimeData, isArmed, version, viewingArchive, selectedSensor, activeTimeframe,
|
||||
unreadCount, overallUptime, isFetching,
|
||||
logout, startPolling, stopPolling, toggleArmed, markAllRead, archiveAll, archiveEvent, toggleSilence, forgetSensor, markEventRead,
|
||||
logout, startRealtimeSync, stopRealtimeSync, toggleArmed, markAllRead, archiveAll, archiveEvent, toggleSilence, forgetSensor, markEventRead,
|
||||
activeEvent, isActiveSensorSilenced
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const emit = defineEmits(['login-success', 'toggle-theme'])
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const rateLimited = ref(false) // Track lockout state
|
||||
const rateLimited = ref(false)
|
||||
|
||||
const doLogin = async () => {
|
||||
loading.value = true
|
||||
@@ -53,7 +53,7 @@ const doLogin = async () => {
|
||||
|
||||
<div class="w-full max-w-100 space-y-8 z-10">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 h-16 flex items-center justify-center rounded-xl bg-white dark:bg-[#121215] border border-slate-200 dark:border-zinc-800/80 shadow-sm mb-5 p-3">
|
||||
<div class="w-16 h-16 flex items-center justify-center rounded-lg bg-white dark:bg-[#121215] border border-slate-200 dark:border-zinc-800/80 shadow-sm mb-5 p-3">
|
||||
<svg class="w-full h-full text-slate-900 dark:text-white fill-current shrink-0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M511.995 377.74q0-.166-.004-.333c-.189-68.109-26.791-132.112-74.972-180.292-48.352-48.352-112.638-74.98-181.019-74.98-68.38 0-132.667 26.628-181.02 74.981C26.628 245.468 0 309.754 0 378.135c0 5.632 4.566 10.199 10.199 10.199 1.864 0 3.606-.509 5.11-1.382l125.089-83.831 109.438 84.656a10.14 10.14 0 0 0 6.164 2.088c2.181 0 5.404-.696 7.261-2.133l109.361-84.611 121.315 81.509c2.254 1.919 5.719 3.958 8.883 3.958 5.632 0 9.179-4.822 9.179-10.454.001-.131-.004-.262-.004-.394M21.212 358.623c3.517-42.685 18.459-82.176 41.757-115.408l60.428 46.753zM131.57 270.5l-56.183-43.468a237.4 237.4 0 0 1 56.183-48.87zm20.398-103.713a233.8 233.8 0 0 1 83.541-23.352l-83.541 115.904zm93.833 192.092-75.958-58.768h75.958zm20.398.788v-59.556h76.977zm0-79.954v-57.116c0-5.632-4.567-10.199-10.199-10.199-5.633 0-10.199 4.567-10.199 10.199v57.116h-83.372l94.082-130.528 94.082 130.528zm94.853-20.375-83.471-115.806a233.8 233.8 0 0 1 83.471 23.762zm20.398-80.539a237.5 237.5 0 0 1 55.567 48.71L381.45 270.5zm8.173 111.169 59.764-46.239c22.96 32.935 37.728 71.98 41.335 114.166z"/><path d="M234.5 172.5 229 196l6 6.5c6 7.5 7 8.5 4.5 7l-8-4.5-7-3.5-3.5-18q.5-2.5-1.5-2-4.5.5-3 3l3 16.5.5 3.5 3 1.5 19 10.5-11 5-13 6.5-2 1-.5 12.5v12h2l2.5.5.5-11v-11l21-9.5-5.5 5.5-7.5 7.5-2 2.5 4 15 5 15.5q0 1 2 .5l2-1.5-4-14.5-3.5-14 7.5-8q11-12 6.5-5.5c-2 2.5-2.5 4.5-5 17v2l7 5.5 7.5 5.5 1.5-1 12.5-10c1-.5 1-1-.5-8-1.5-8.5-2-9-4-11l-1-2 8 7.5 7.5 7.5-1 3.5-6.5 26 2 .5q2 1 2-.5c2.5-6.5 8.5-29.5 8.5-30l-4.5-5q-9.5-10-9-10.5l10 5 10 4.5v21.5h1l2.5.5h1v-25l-11.5-5.5-13-6.5-1.5-1.5 10.5-5 11-6 .5-2.5 3.5-19.5-2.5-.5-2-.5-1.5 9-2 10q0 1.5-6.5 4L271 210l2.5-4 9-10.5L280 185l-5-17.5h-2.5q-2 .5-1.5 1.5l3.5 13 3 12.5-2.5 2.5-8 9.5v-2q1-1-1-3.5c-2.5-2.5-2.5-2.5-1-5l1-1.5-2.5-3.5q-2-4-3-4-2 1-1 4 .5 4.5-1.5 4.5h-3.5q-3 .5-2.5-1.5v-3.5q1-2-.5-3l-1-1-1.5 2-2 3.5q-2 2.5-.5 4 2.5 2.5-.5 5-2.5 1.5-1.5 4v2l-1-1-8.5-10.5-1-1 1-4.5 5.5-21.5-3.5-1.5zm25.5 30q2.5 2 1.5 3c-.5 3.5-3 8-4.5 9.5l-1.5 1-1.5-1-3-5c-2-5-2-6 0-7.5q4.5-4.5 9 0m2 26.5 3 13c0 .5-8.5 7.5-9.5 7.5-.5 0-9-7-9-8l3-13 3.5-4 3-2.5 2.5 3 3 4"/></svg>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ const doLogin = async () => {
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-500 mt-1">Authorized personnel only</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-[#0c0c0e] border border-slate-200 dark:border-zinc-800/80 rounded-xl shadow-xl shadow-slate-200/50 dark:shadow-black/50 p-8 transition-colors duration-300 relative overflow-hidden">
|
||||
<div class="bg-white dark:bg-[#0c0c0e] border border-slate-200 dark:border-zinc-800/80 rounded-lg shadow-xl shadow-slate-200/50 dark:shadow-black/50 p-8 transition-colors duration-300 relative overflow-hidden">
|
||||
|
||||
<div class="absolute top-0 left-0 right-0 h-1 opacity-90 bg-[linear-gradient(to_right,#64748b,#3b82f6,#eab308,#fb923c,#f43f5e)]"></div>
|
||||
|
||||
@@ -69,21 +69,21 @@ const doLogin = async () => {
|
||||
<div class="space-y-2">
|
||||
<label for="pwd" class="text-[11px] uppercase tracking-wider font-bold text-slate-500 dark:text-zinc-500">Authentication Key</label>
|
||||
<input type="password" id="pwd" v-model="password" placeholder="••••••••••••"
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 dark:focus:ring-blue-500/30 focus:border-blue-500 transition-all placeholder-slate-400 dark:placeholder-zinc-700 shadow-inner" required>
|
||||
class="w-full px-4 py-2.5 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 transition-all placeholder-slate-400 dark:placeholder-zinc-700 shadow-inner" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading || rateLimited"
|
||||
class="w-full py-2.5 rounded-lg text-sm font-bold transition-all duration-200 shadow-md active:scale-[0.98]"
|
||||
:class="(loading || rateLimited) ? 'bg-slate-400 dark:bg-zinc-800 text-slate-100 dark:text-zinc-500 cursor-not-allowed' : 'bg-slate-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-slate-800 dark:hover:bg-white hover:shadow-lg'">
|
||||
class="w-full py-2.5 rounded-md text-sm font-bold transition-all duration-200 shadow-md active:scale-[0.98]"
|
||||
:class="(loading || rateLimited) ? 'bg-slate-400 dark:bg-zinc-800 text-slate-100 dark:text-zinc-500 cursor-not-allowed' : 'bg-slate-900 dark:bg-zinc-300 text-white dark:text-zinc-900 hover:bg-slate-800 dark:hover:bg-white hover:shadow-lg'">
|
||||
{{ loading ? 'Authenticating...' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="error" class="mt-6 p-3 rounded-lg bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-900/50 text-center animate-bounce-subtle">
|
||||
<div v-if="error" class="mt-6 p-3 rounded-md bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-900/50 text-center animate-bounce-subtle">
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-rose-600 dark:text-rose-400">Access Denied: Invalid Key</p>
|
||||
</div>
|
||||
|
||||
<div v-if="rateLimited" class="mt-6 p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900/50 text-center">
|
||||
<div v-if="rateLimited" class="mt-6 p-3 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900/50 text-center">
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">Too many attempts.<br/>Please try again in 15 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useConfig } from '../api/useConfig'
|
||||
|
||||
const { config, patchConfig } = useConfig()
|
||||
const activeTab = ref('general')
|
||||
|
||||
const settings = ref({
|
||||
hubEndpoint: window.location.origin,
|
||||
hubKey: '',
|
||||
autoArchiveDays: 0,
|
||||
autoPurgeDays: 0,
|
||||
webhookType: 'ntfy',
|
||||
webhookUrl: '',
|
||||
webhookEvents: []
|
||||
})
|
||||
|
||||
// Deep clone to track original state
|
||||
const initialSettings = ref(null)
|
||||
|
||||
watch(() => config.isLoaded, (loaded) => {
|
||||
if (loaded) {
|
||||
const loadedSettings = {
|
||||
hubEndpoint: config.hubEndpoint || window.location.origin,
|
||||
hubKey: config.hubKey || '',
|
||||
autoArchiveDays: config.autoArchiveDays || 0,
|
||||
autoPurgeDays: config.autoPurgeDays || 0,
|
||||
webhookType: config.webhookType || 'ntfy',
|
||||
webhookUrl: config.webhookUrl || '',
|
||||
webhookEvents: [...(config.webhookEvents || [])]
|
||||
}
|
||||
settings.value = JSON.parse(JSON.stringify(loadedSettings))
|
||||
initialSettings.value = JSON.parse(JSON.stringify(loadedSettings))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Peak UX: Compute if changes have been made
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
if (!initialSettings.value) return false
|
||||
return JSON.stringify(settings.value) !== JSON.stringify(initialSettings.value)
|
||||
})
|
||||
|
||||
const isSaving = ref(false)
|
||||
const saveMessage = ref('')
|
||||
|
||||
const saveSettings = async () => {
|
||||
isSaving.value = true
|
||||
saveMessage.value = ''
|
||||
|
||||
const success = await patchConfig(settings.value)
|
||||
|
||||
isSaving.value = false
|
||||
if (success) {
|
||||
// Sync initial settings to the newly saved state
|
||||
initialSettings.value = JSON.parse(JSON.stringify(settings.value))
|
||||
saveMessage.value = 'Configuration saved successfully.'
|
||||
setTimeout(() => saveMessage.value = '', 3000)
|
||||
} else {
|
||||
alert("Failed to save configuration. Check server logs.")
|
||||
}
|
||||
}
|
||||
|
||||
const regenerateKey = () => {
|
||||
if(confirm("Regenerating the Hub Key will immediately disconnect all active sensors. You must save changes to apply this. Continue?")) {
|
||||
const array = new Uint8Array(16)
|
||||
crypto.getRandomValues(array)
|
||||
settings.value.hubKey = 'hw_sk_' + Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
}
|
||||
|
||||
const adjustDays = (field, delta, min, max) => {
|
||||
let val = Number(settings.value[field]) + delta
|
||||
if (val < min) val = min
|
||||
if (val > max) val = max
|
||||
settings.value[field] = val
|
||||
}
|
||||
|
||||
const toggleSeverity = (sev) => {
|
||||
const index = settings.value.webhookEvents.indexOf(sev)
|
||||
if (index === -1) {
|
||||
settings.value.webhookEvents.push(sev)
|
||||
} else {
|
||||
settings.value.webhookEvents.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityPillClass = (sev, isActive) => {
|
||||
if (!isActive) {
|
||||
return 'bg-slate-100 dark:bg-[#121215] border-slate-200 dark:border-zinc-800/80 text-slate-400 dark:text-zinc-600 hover:bg-slate-200 dark:hover:bg-zinc-800 transition-colors'
|
||||
}
|
||||
const map = {
|
||||
'critical': 'bg-rose-100 dark:bg-rose-500/20 border-rose-400 dark:border-rose-500/50 text-rose-700 dark:text-rose-400 shadow-sm',
|
||||
'high': 'bg-orange-100 dark:bg-orange-500/20 border-orange-400 dark:border-orange-500/50 text-orange-700 dark:text-orange-400 shadow-sm',
|
||||
'medium': 'bg-amber-100 dark:bg-amber-500/20 border-amber-400 dark:border-amber-500/50 text-amber-700 dark:text-amber-400 shadow-sm',
|
||||
'low': 'bg-blue-100 dark:bg-blue-500/20 border-blue-400 dark:border-blue-500/50 text-blue-700 dark:text-blue-400 shadow-sm',
|
||||
'info': 'bg-slate-200 dark:bg-zinc-600/30 border-slate-400 dark:border-zinc-500/50 text-slate-700 dark:text-zinc-300 shadow-sm'
|
||||
}
|
||||
return map[sev] || map['info']
|
||||
}
|
||||
|
||||
// --- CUSTOM MODALS FOR DANGER ZONE ---
|
||||
const showPasswordModal = ref(false)
|
||||
const pwdData = ref({ current: '', new: '', confirmNew: '' })
|
||||
const pwdError = ref('')
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
const showResetModal = ref(false)
|
||||
const resetPassword = ref('')
|
||||
const resetError = ref('')
|
||||
const resetLoading = ref(false)
|
||||
|
||||
const submitPasswordChange = async () => {
|
||||
if (!pwdData.value.current || !pwdData.value.new || !pwdData.value.confirmNew) {
|
||||
pwdError.value = "All fields are required."
|
||||
return
|
||||
}
|
||||
|
||||
if (pwdData.value.new !== pwdData.value.confirmNew) {
|
||||
pwdError.value = "New passwords do not match."
|
||||
return
|
||||
}
|
||||
|
||||
pwdLoading.value = true
|
||||
pwdError.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/system/password', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: pwdData.value.current, new_password: pwdData.value.new })
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload()
|
||||
} else if (res.status === 401) {
|
||||
pwdError.value = "Incorrect current password."
|
||||
} else {
|
||||
const err = await res.text()
|
||||
pwdError.value = err || "Failed to update password."
|
||||
}
|
||||
} catch (e) {
|
||||
pwdError.value = "Network error."
|
||||
} finally {
|
||||
pwdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitFactoryReset = async () => {
|
||||
if (!resetPassword.value) {
|
||||
resetError.value = "Master password is required."
|
||||
return
|
||||
}
|
||||
|
||||
resetLoading.value = true
|
||||
resetError.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/v1/system/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: resetPassword.value })
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload()
|
||||
} else if (res.status === 401) {
|
||||
resetError.value = "Incorrect password."
|
||||
} else {
|
||||
resetError.value = "Factory reset failed."
|
||||
}
|
||||
} catch (e) {
|
||||
resetError.value = "Network error."
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col max-w-[1600px] w-full mx-auto px-2 sm:px-4 lg:px-6 transition-colors duration-200">
|
||||
<div class="mb-6 shrink-0 mt-4 sm:mt-6 flex justify-between items-end">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">System Settings</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-400 mt-1 max-w-3xl">Manage Hub configuration, retention policies, and push notifications.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
<span v-if="saveMessage" class="text-xs font-bold text-emerald-600 dark:text-emerald-500 animate-pulse hidden sm:block">{{ saveMessage }}</span>
|
||||
<span v-else-if="hasUnsavedChanges" class="text-xs font-bold text-amber-500 dark:text-amber-400 animate-pulse hidden sm:flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-amber-500 dark:bg-amber-400 inline-block"></span> Unsaved changes
|
||||
</span>
|
||||
|
||||
<button @click="saveSettings" :disabled="isSaving || !hasUnsavedChanges"
|
||||
class="px-4 py-2 rounded-md text-xs font-bold uppercase tracking-wider transition-all shadow-sm active:scale-95 border"
|
||||
:class="hasUnsavedChanges ? 'bg-slate-900 dark:bg-zinc-300 text-white dark:text-slate-900 border-slate-500 dark:border-zinc-100 hover:bg-slate-700 dark:hover:bg-white' : 'bg-slate-100 dark:bg-zinc-800 text-slate-400 dark:text-zinc-600 border-transparent cursor-not-allowed'">
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6 pb-10 flex-1 min-h-0">
|
||||
<nav class="w-full md:w-56 shrink-0 flex flex-col gap-2">
|
||||
<button @click="activeTab = 'general'"
|
||||
class="w-full text-left px-4 py-2.5 rounded-lg text-sm transition-all flex items-center gap-3 border"
|
||||
:class="activeTab === 'general' ? 'bg-slate-100 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100 font-bold shadow-sm border-slate-300 dark:border-zinc-700' : 'bg-white dark:bg-zinc-900 font-medium text-slate-500 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-800/50 hover:text-slate-700 dark:hover:text-zinc-300 border-slate-200 dark:border-zinc-800/50'">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Hub Configuration
|
||||
</button>
|
||||
<button @click="activeTab = 'data'"
|
||||
class="w-full text-left px-4 py-2.5 rounded-lg text-sm transition-all flex items-center gap-3 border"
|
||||
:class="activeTab === 'data' ? 'bg-slate-100 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100 font-bold shadow-sm border-slate-300 dark:border-zinc-700' : 'bg-white dark:bg-zinc-900 font-medium text-slate-500 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-800/50 hover:text-slate-700 dark:hover:text-zinc-300 border-slate-200 dark:border-zinc-800/50'">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>
|
||||
Data Retention
|
||||
</button>
|
||||
<button @click="activeTab = 'alerts'"
|
||||
class="w-full text-left px-4 py-2.5 rounded-lg text-sm transition-all flex items-center gap-3 border"
|
||||
:class="activeTab === 'alerts' ? 'bg-slate-100 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100 font-bold shadow-sm border-slate-300 dark:border-zinc-700' : 'bg-white dark:bg-zinc-900 font-medium text-slate-500 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-800/50 hover:text-slate-700 dark:hover:text-zinc-300 border-slate-200 dark:border-zinc-800/50'">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Push Notifications
|
||||
</button>
|
||||
<div class="h-px bg-slate-200 dark:bg-zinc-800/80 my-2 mx-4"></div>
|
||||
<button @click="activeTab = 'security'"
|
||||
class="w-full text-left px-4 py-2.5 rounded-lg text-sm transition-all flex items-center gap-3 border"
|
||||
:class="activeTab === 'security' ? 'bg-rose-100 dark:bg-rose-500/20 text-rose-800 dark:text-rose-400 font-bold shadow-sm border-rose-300 dark:border-rose-900/50' : 'bg-white dark:bg-zinc-900 font-medium text-slate-500 dark:text-zinc-400 hover:bg-rose-50 dark:hover:bg-rose-950/10 hover:text-rose-600 dark:hover:text-rose-400 border-slate-200 dark:border-zinc-800/50'">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
||||
Security & Access
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scroll pr-2 space-y-6">
|
||||
<div v-show="activeTab === 'general'" class="space-y-6">
|
||||
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-lg p-5 md:p-6 shadow-sm transition-colors">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-zinc-100 mb-5">Network Configuration</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-2">Hub Endpoint URL</label>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mb-3">The publicly accessible URL or IP where sensors will send their telemetry.</p>
|
||||
<input type="text" v-model="settings.hubEndpoint"
|
||||
class="w-full max-w-md px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-800 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 shadow-inner transition-colors">
|
||||
</div>
|
||||
<div class="pt-5 border-t border-slate-100 dark:border-zinc-800/50">
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-2">Hub Secret Key</label>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mb-3">The shared secret required by sensors to authenticate with the Hub API.</p>
|
||||
<div class="flex gap-3 items-center flex-wrap sm:flex-nowrap">
|
||||
<input type="text" v-model="settings.hubKey"
|
||||
class="flex-1 w-full max-w-md px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-800 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 shadow-inner transition-colors">
|
||||
<button @click="regenerateKey"
|
||||
class="px-4 py-2 rounded-md bg-white dark:bg-[#1f1f22] border border-slate-200 dark:border-zinc-700 text-slate-700 dark:text-zinc-300 text-xs font-bold hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors shadow-sm whitespace-nowrap">
|
||||
Regenerate Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'data'" class="space-y-6">
|
||||
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-lg p-5 md:p-6 shadow-sm transition-colors">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-zinc-100 mb-5">Database Retention Policies</h3>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between gap-4 max-w-2xl">
|
||||
<div>
|
||||
<label class="block text-[13px] font-bold text-slate-800 dark:text-zinc-200">Auto-Archive Events</label>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mt-1">Move events from the Live Queue to the Archive automatically.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center rounded-md border border-slate-200 dark:border-zinc-800 overflow-hidden bg-slate-50 dark:bg-[#121215] shadow-inner">
|
||||
<button @click="adjustDays('autoArchiveDays', -1, 0, 90)" class="px-3 py-1.5 text-slate-500 dark:text-zinc-400 hover:bg-slate-200 dark:hover:bg-zinc-800 transition-colors font-bold select-none outline-none">-</button>
|
||||
<input type="number" v-model="settings.autoArchiveDays" min="0" max="90"
|
||||
class="w-12 text-center text-sm mono font-bold bg-transparent border-none focus:outline-none focus:ring-0 text-slate-800 dark:text-zinc-200 hide-arrows p-0" />
|
||||
<button @click="adjustDays('autoArchiveDays', 1, 0, 90)" class="px-3 py-1.5 text-slate-500 dark:text-zinc-400 hover:bg-slate-200 dark:hover:bg-zinc-800 transition-colors font-bold select-none outline-none">+</button>
|
||||
</div>
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-zinc-500 w-10">Days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px w-full bg-slate-100 dark:bg-zinc-800/50"></div>
|
||||
<div class="flex items-center justify-between gap-4 max-w-2xl">
|
||||
<div>
|
||||
<label class="block text-[13px] font-bold text-slate-800 dark:text-zinc-200">Auto-Purge Archive</label>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mt-1">Permanently delete archived events from the SQLite database.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center rounded-md border border-slate-200 dark:border-zinc-800 overflow-hidden bg-slate-50 dark:bg-[#121215] shadow-inner">
|
||||
<button @click="adjustDays('autoPurgeDays', -1, 0, 365)" class="px-3 py-1.5 text-slate-500 dark:text-zinc-400 hover:bg-slate-200 dark:hover:bg-zinc-800 transition-colors font-bold select-none outline-none">-</button>
|
||||
<input type="number" v-model="settings.autoPurgeDays" min="0" max="365"
|
||||
class="w-12 text-center text-sm mono font-bold bg-transparent border-none focus:outline-none focus:ring-0 text-slate-800 dark:text-zinc-200 hide-arrows p-0" />
|
||||
<button @click="adjustDays('autoPurgeDays', 1, 0, 365)" class="px-3 py-1.5 text-slate-500 dark:text-zinc-400 hover:bg-slate-200 dark:hover:bg-zinc-800 transition-colors font-bold select-none outline-none">+</button>
|
||||
</div>
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-zinc-500 w-10">Days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'alerts'" class="space-y-6">
|
||||
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-lg p-5 md:p-6 shadow-sm transition-colors">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-zinc-100">Push Notifications</h3>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-3">Service Provider</label>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<button v-for="provider in ['ntfy', 'gotify', 'discord', 'slack']" :key="provider"
|
||||
@click="settings.webhookType = provider"
|
||||
class="px-3.5 py-1.5 rounded-md text-[11px] font-bold uppercase tracking-wider border transition-all flex items-center justify-center"
|
||||
:class="settings.webhookType === provider
|
||||
? 'bg-slate-800 dark:bg-zinc-200 text-white dark:text-slate-900 border-slate-800 dark:border-zinc-200 shadow-sm'
|
||||
: 'bg-white dark:bg-[#121215] border-slate-200 dark:border-zinc-800/80 text-slate-500 dark:text-zinc-500 hover:bg-slate-50 dark:hover:bg-zinc-800'">
|
||||
{{ provider }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-5 border-t border-slate-100 dark:border-zinc-800/50">
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-2">Target URL</label>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mb-3">
|
||||
<span v-if="settings.webhookType === 'gotify'">Enter your Gotify server URL and append the App Token (e.g., <code>https://gotify.domain.com/message?token=XYZ</code>).</span>
|
||||
<span v-else-if="settings.webhookType === 'ntfy'">Enter your self-hosted or public Ntfy topic URL (e.g., <code>https://ntfy.sh/my_alerts</code>).</span>
|
||||
<span v-else>Paste the incoming Webhook URL provided by {{ settings.webhookType === 'discord' ? 'Discord' : 'Slack' }}.</span>
|
||||
</p>
|
||||
<input type="url" v-model="settings.webhookUrl" placeholder="https://..."
|
||||
class="w-full max-w-xl px-4 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800/80 text-sm mono text-slate-800 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 shadow-inner transition-colors placeholder:text-slate-400 dark:placeholder:text-zinc-600">
|
||||
</div>
|
||||
<div class="pt-5 border-t border-slate-100 dark:border-zinc-800/50">
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-3">Trigger Severities</label>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<button v-for="sev in ['critical', 'high', 'medium', 'low', 'info']" :key="sev"
|
||||
@click="toggleSeverity(sev)"
|
||||
class="px-3.5 py-1.5 rounded-md text-[11px] font-bold uppercase tracking-wider transition-all border outline-none select-none"
|
||||
:class="getSeverityPillClass(sev, settings.webhookEvents.includes(sev))">
|
||||
{{ sev }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'security'" class="space-y-6">
|
||||
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-lg p-5 md:p-6 shadow-sm transition-colors">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-zinc-100 mb-4">Authentication</h3>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-400 mb-4 max-w-2xl">Update the master password used to access this dashboard. You will be logged out immediately upon changing this.</p>
|
||||
<button @click="pwdData = {current:'', new:'', confirmNew:''}; pwdError = ''; showPasswordModal = true"
|
||||
class="px-4 py-2 rounded-md bg-white dark:bg-[#1f1f22] border border-slate-200 dark:border-zinc-700 text-slate-700 dark:text-zinc-300 text-sm font-bold hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors shadow-sm">
|
||||
Change Master Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-rose-50 dark:bg-[#150a0a] border border-rose-200 dark:border-rose-900/30 rounded-lg p-5 md:p-6 shadow-sm transition-colors">
|
||||
<h3 class="text-base font-bold text-rose-600 dark:text-rose-500 mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||
Danger Zone
|
||||
</h3>
|
||||
<div class="space-y-4 mt-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white dark:bg-zinc-900 border border-rose-200 dark:border-rose-900/30 p-5 rounded-lg shadow-sm transition-colors">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-slate-900 dark:text-zinc-100">Factory Reset</h4>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 mt-1 max-w-xl">Wipes all configuration, logs, and authentication keys. The application will restart in setup mode.</p>
|
||||
</div>
|
||||
<button @click="resetPassword = ''; resetError = ''; showResetModal = true"
|
||||
class="shrink-0 px-4 py-2 rounded-md bg-rose-600 hover:bg-rose-700 text-white text-xs font-bold uppercase tracking-wider transition-colors shadow-sm">
|
||||
Reset System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="showPasswordModal" class="fixed inset-0 z-50 flex justify-center items-center p-4 bg-slate-900/60 dark:bg-black/60 backdrop-blur-sm" @click.self="showPasswordModal = false">
|
||||
<div class="bg-white dark:bg-zinc-900 w-full max-w-sm rounded-lg shadow-2xl border border-slate-200 dark:border-zinc-800/80 p-6 transform transition-all">
|
||||
|
||||
<div class="flex items-center gap-3 mb-5 text-slate-900 dark:text-zinc-100">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"></path></svg>
|
||||
<h3 class="text-lg font-bold">Update Password</h3>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitPasswordChange" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-1.5">Current Password</label>
|
||||
<input type="password" v-model="pwdData.current" class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-800 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 shadow-inner transition-all" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-1.5">New Password</label>
|
||||
<input type="password" v-model="pwdData.new" class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-800 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 shadow-inner transition-all" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-500 dark:text-zinc-500 uppercase tracking-wider mb-1.5">Confirm New Password</label>
|
||||
<input type="password" v-model="pwdData.confirmNew" class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-800 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 shadow-inner transition-all" required>
|
||||
</div>
|
||||
|
||||
<div v-if="pwdError" class="text-xs font-bold text-rose-500 bg-rose-50 dark:bg-rose-950/30 p-2.5 rounded-md border border-rose-100 dark:border-rose-900/50">{{ pwdError }}</div>
|
||||
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="showPasswordModal = false" class="px-4 py-2 text-sm font-medium text-slate-600 dark:text-zinc-400 hover:text-slate-900 dark:hover:text-zinc-200 transition-colors">Cancel</button>
|
||||
<button type="submit" :disabled="pwdLoading" class="px-4 py-2 rounded-md bg-slate-900 dark:bg-zinc-100 text-white dark:text-slate-900 text-sm font-bold shadow-sm hover:opacity-90 disabled:opacity-50 transition-all active:scale-95">
|
||||
{{ pwdLoading ? 'Updating...' : 'Update Password' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="showResetModal" class="fixed inset-0 z-50 flex justify-center items-center p-4 bg-slate-900/60 dark:bg-black/60 backdrop-blur-sm" @click.self="showResetModal = false">
|
||||
<div class="bg-white dark:bg-zinc-900 w-full max-w-sm rounded-lg shadow-2xl border border-rose-200 dark:border-rose-900/50 p-6 transform transition-all">
|
||||
<div class="flex items-center gap-3 mb-4 text-rose-600 dark:text-rose-500">
|
||||
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||
<h3 class="text-lg font-bold">Confirm Factory Reset</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 dark:text-zinc-400 mb-5">This action is irreversible. All events, sensors, and configurations will be permanently deleted. Enter your master password to confirm.</p>
|
||||
|
||||
<form @submit.prevent="submitFactoryReset" class="space-y-4">
|
||||
<div>
|
||||
<input type="password" v-model="resetPassword" placeholder="Master Password" class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-rose-400 focus:ring-rose-400/50 dark:focus:border-rose-600 dark:focus:ring-rose-600/50 shadow-inner transition-all" required autofocus>
|
||||
</div>
|
||||
|
||||
<div v-if="resetError" class="text-xs font-bold text-rose-500 bg-rose-50 dark:bg-rose-950/30 p-2.5 rounded-md border border-rose-100 dark:border-rose-900/50 text-center">{{ resetError }}</div>
|
||||
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="showResetModal = false" class="px-4 py-2 text-sm font-medium text-slate-600 dark:text-zinc-400 hover:text-slate-900 dark:hover:text-zinc-200 transition-colors">Cancel</button>
|
||||
<button type="submit" :disabled="resetLoading" class="px-4 py-2 rounded-md bg-rose-600 text-white text-sm font-bold shadow-sm hover:bg-rose-700 disabled:opacity-50 transition-colors active:scale-95">
|
||||
{{ resetLoading ? 'Wiping...' : 'Destroy Data' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hide-arrows::-webkit-outer-spin-button,
|
||||
.hide-arrows::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.hide-arrows[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits(['setup-complete', 'toggle-theme'])
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const hubEndpoint = ref('')
|
||||
const hubKey = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const generateKey = () => {
|
||||
// True cryptographic randomness for the sensor key
|
||||
const array = new Uint8Array(16)
|
||||
crypto.getRandomValues(array)
|
||||
hubKey.value = 'hw_sk_' + Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
hubEndpoint.value = window.location.origin
|
||||
generateKey()
|
||||
})
|
||||
|
||||
const doSetup = async () => {
|
||||
if (!password.value || !confirmPassword.value) {
|
||||
error.value = "Password fields cannot be empty."
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = "Passwords do not match."
|
||||
return
|
||||
}
|
||||
if (!hubEndpoint.value || !hubKey.value) {
|
||||
error.value = "Endpoint and Key are required."
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/setup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
password: password.value,
|
||||
hub_endpoint: hubEndpoint.value,
|
||||
hub_key: hubKey.value
|
||||
})
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
emit('setup-complete')
|
||||
} else {
|
||||
const errData = await res.text()
|
||||
error.value = errData || "Setup failed. Check server console."
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = "Network error. Hub unreachable."
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-[#0a0a0c] transition-colors duration-300 p-6 relative py-12 overflow-y-auto custom-scroll">
|
||||
|
||||
<div class="absolute top-6 right-6">
|
||||
<button @click="$emit('toggle-theme')"
|
||||
class="w-10 h-10 rounded-md bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors flex items-center justify-center group overflow-hidden shadow-sm">
|
||||
<svg class="w-4 h-4 transition-transform duration-300 ease-out group-hover:rotate-45 group-hover:scale-110 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4 transition-transform duration-300 ease-out group-hover:-rotate-12 group-hover:scale-110 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xl space-y-8 z-10 my-auto">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-16 h-16 flex items-center justify-center rounded-lg bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 shadow-sm mb-5 p-3">
|
||||
<svg class="w-full h-full text-slate-900 dark:text-white fill-current shrink-0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M511.995 377.74q0-.166-.004-.333c-.189-68.109-26.791-132.112-74.972-180.292-48.352-48.352-112.638-74.98-181.019-74.98-68.38 0-132.667 26.628-181.02 74.981C26.628 245.468 0 309.754 0 378.135c0 5.632 4.566 10.199 10.199 10.199 1.864 0 3.606-.509 5.11-1.382l125.089-83.831 109.438 84.656a10.14 10.14 0 0 0 6.164 2.088c2.181 0 5.404-.696 7.261-2.133l109.361-84.611 121.315 81.509c2.254 1.919 5.719 3.958 8.883 3.958 5.632 0 9.179-4.822 9.179-10.454.001-.131-.004-.262-.004-.394M21.212 358.623c3.517-42.685 18.459-82.176 41.757-115.408l60.428 46.753zM131.57 270.5l-56.183-43.468a237.4 237.4 0 0 1 56.183-48.87zm20.398-103.713a233.8 233.8 0 0 1 83.541-23.352l-83.541 115.904zm93.833 192.092-75.958-58.768h75.958zm20.398.788v-59.556h76.977zm0-79.954v-57.116c0-5.632-4.567-10.199-10.199-10.199-5.633 0-10.199 4.567-10.199 10.199v57.116h-83.372l94.082-130.528 94.082 130.528zm94.853-20.375-83.471-115.806a233.8 233.8 0 0 1 83.471 23.762zm20.398-80.539a237.5 237.5 0 0 1 55.567 48.71L381.45 270.5zm8.173 111.169 59.764-46.239c22.96 32.935 37.728 71.98 41.335 114.166z"/><path d="M234.5 172.5 229 196l6 6.5c6 7.5 7 8.5 4.5 7l-8-4.5-7-3.5-3.5-18q.5-2.5-1.5-2-4.5.5-3 3l3 16.5.5 3.5 3 1.5 19 10.5-11 5-13 6.5-2 1-.5 12.5v12h2l2.5.5.5-11v-11l21-9.5-5.5 5.5-7.5 7.5-2 2.5 4 15 5 15.5q0 1 2 .5l2-1.5-4-14.5-3.5-14 7.5-8q11-12 6.5-5.5c-2 2.5-2.5 4.5-5 17v2l7 5.5 7.5 5.5 1.5-1 12.5-10c1-.5 1-1-.5-8-1.5-8.5-2-9-4-11l-1-2 8 7.5 7.5 7.5-1 3.5-6.5 26 2 .5q2 1 2-.5c2.5-6.5 8.5-29.5 8.5-30l-4.5-5q-9.5-10-9-10.5l10 5 10 4.5v21.5h1l2.5.5h1v-25l-11.5-5.5-13-6.5-1.5-1.5 10.5-5 11-6 .5-2.5 3.5-19.5-2.5-.5-2-.5-1.5 9-2 10q0 1.5-6.5 4L271 210l2.5-4 9-10.5L280 185l-5-17.5h-2.5q-2 .5-1.5 1.5l3.5 13 3 12.5-2.5 2.5-8 9.5v-2q1-1-1-3.5c-2.5-2.5-2.5-2.5-1-5l1-1.5-2.5-3.5q-2-4-3-4-2 1-1 4 .5 4.5-1.5 4.5h-3.5q-3 .5-2.5-1.5v-3.5q1-2-.5-3l-1-1-1.5 2-2 3.5q-2 2.5-.5 4 2.5 2.5-.5 5-2.5 1.5-1.5 4v2l-1-1-8.5-10.5-1-1 1-4.5 5.5-21.5-3.5-1.5zm25.5 30q2.5 2 1.5 3c-.5 3.5-3 8-4.5 9.5l-1.5 1-1.5-1-3-5c-2-5-2-6 0-7.5q4.5-4.5 9 0m2 26.5 3 13c0 .5-8.5 7.5-9.5 7.5-.5 0-9-7-9-8l3-13 3.5-4 3-2.5 2.5 3 3 4"/></svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white tracking-tight">Initialize Sentinel</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-400 mt-2">Configure your master authentication and sensor connection details.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-lg shadow-xl shadow-slate-200/50 dark:shadow-black/50 p-6 sm:p-8 relative overflow-hidden transition-colors duration-300">
|
||||
<form @submit.prevent="doSetup" class="space-y-6">
|
||||
|
||||
<div class="space-y-4 border-b border-slate-100 dark:border-zinc-800/50 pb-6">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-zinc-300 mb-2">1. Master Authentication</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] uppercase tracking-wider font-bold text-slate-500 dark:text-zinc-500">Master Password</label>
|
||||
<input type="password" v-model="password" placeholder="Password"
|
||||
class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 transition-all placeholder:text-slate-400 dark:placeholder:text-zinc-700 shadow-inner" required>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] uppercase tracking-wider font-bold text-slate-500 dark:text-zinc-500">Confirm Password</label>
|
||||
<input type="password" v-model="confirmPassword" placeholder="Repeat password"
|
||||
class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 transition-all placeholder:text-slate-400 dark:placeholder:text-zinc-700 shadow-inner" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-zinc-300 mb-2">2. Sensor Connectivity</h3>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] uppercase tracking-wider font-bold text-slate-500 dark:text-zinc-500">Hub Endpoint URL</label>
|
||||
<input type="text" v-model="hubEndpoint" placeholder="http://yourip:8080"
|
||||
class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 transition-all placeholder:text-slate-400 dark:placeholder:text-zinc-700 shadow-inner" required>
|
||||
<p class="text-[10px] text-slate-500 dark:text-zinc-500">The publicly accessible URL or IP where sensors will send their telemetry.</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] uppercase tracking-wider font-bold text-slate-500 dark:text-zinc-500">Sensor Secret Key</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" v-model="hubKey" placeholder="Secure API Key"
|
||||
class="flex-1 px-3 py-2 rounded-md bg-slate-50 dark:bg-[#121215] border border-slate-200 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-1 focus:border-slate-400 focus:ring-slate-400/50 dark:focus:border-zinc-600 dark:focus:ring-zinc-600/50 transition-all placeholder:text-slate-400 dark:placeholder:text-zinc-700 shadow-inner" required>
|
||||
<button type="button" @click="generateKey"
|
||||
class="px-4 py-2 rounded-md bg-white dark:bg-[#1f1f22] border border-slate-200 dark:border-zinc-700 text-slate-700 dark:text-zinc-300 text-[10px] font-bold uppercase tracking-wider hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors shadow-sm">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full py-2.5 rounded-md text-sm font-bold uppercase tracking-wider transition-all shadow-sm active:scale-[0.98] border"
|
||||
:class="loading ? 'bg-slate-100 dark:bg-zinc-800 text-slate-400 dark:text-zinc-600 border-transparent cursor-not-allowed' : 'bg-slate-900 dark:bg-zinc-300 text-white dark:text-slate-900 border-slate-600 dark:border-zinc-100 hover:bg-slate-700 dark:hover:bg-white'">
|
||||
{{ loading ? 'Initializing...' : 'Initialize Hub' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="error" class="mt-6 p-2.5 rounded-md bg-rose-50 dark:bg-rose-950/30 border border-rose-100 dark:border-rose-900/50 text-center">
|
||||
<p class="text-xs font-bold text-rose-500">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,623 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useConfig } from '../api/useConfig'
|
||||
|
||||
// Pull the global reactive config state
|
||||
const { config } = useConfig()
|
||||
|
||||
const selectedSensor = ref(null)
|
||||
const activeTab = ref('readme')
|
||||
const editableCompose = ref('')
|
||||
|
||||
const openSensor = (sensor) => {
|
||||
selectedSensor.value = sensor
|
||||
activeTab.value = 'readme'
|
||||
// This now dynamically grabs the DB settings the moment you click the card
|
||||
editableCompose.value = getComposeFile(sensor.compose)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const closeSensor = () => {
|
||||
selectedSensor.value = null
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const getComposeFile = (composeString) => {
|
||||
// Read from the backend config FIRST, fallback to the browser URL if empty
|
||||
const endpoint = config.hubEndpoint || window.location.origin
|
||||
const key = config.hubKey || '<YOUR_HW_HUB_KEY>'
|
||||
|
||||
return composeString
|
||||
.replace(/__HUB_ENDPOINT__/g, endpoint)
|
||||
.replace(/__HUB_KEY__/g, key)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!selectedSensor.value) return
|
||||
navigator.clipboard.writeText(editableCompose.value)
|
||||
|
||||
const btn = document.getElementById('copy-btn')
|
||||
const originalText = btn.innerHTML
|
||||
btn.innerHTML = 'Copied!'
|
||||
setTimeout(() => { btn.innerHTML = originalText }, 2000)
|
||||
}
|
||||
|
||||
const sensors = [
|
||||
{
|
||||
id: 'file-canary',
|
||||
name: 'File Canary (FIM)',
|
||||
osi: 'Host Level',
|
||||
shortDesc: 'Honeypot and File Integrity Monitor. Watches files/directories for unauthorized modifications or drops.',
|
||||
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
compose: `services:
|
||||
# 1. THE FIM SETTER: Only runs if the path exists. Simply grants the read-only ACL pass.
|
||||
permission-fixer:
|
||||
image: alpine:latest
|
||||
command: sh -c "apk add --no-cache acl && setfacl -R -m u:65532:rx /honey_dir"
|
||||
volumes:
|
||||
# LONG SYNTAX: This forces Docker to throw an error if \${TRAP_PATH} does not exist!
|
||||
# Define TRAP_PATH in your .env file (e.g., TRAP_PATH=/opt/fake_secrets)
|
||||
- type: bind
|
||||
source: \${TRAP_PATH}
|
||||
target: /honey_dir
|
||||
|
||||
# 2. THE WATCHER
|
||||
file-canary:
|
||||
image: ghcr.io/andreicscs/honeywire-filecanary:latest
|
||||
container_name: hw-file-canary
|
||||
restart: unless-stopped
|
||||
|
||||
# Ensures uniform communication with a locally hosted Hub
|
||||
network_mode: "host"
|
||||
|
||||
depends_on:
|
||||
permission-fixer:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# --- SECURITY SANDBOX ---
|
||||
user: "65532:65532"
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# ------------------------
|
||||
|
||||
environment:
|
||||
- HW_HUB_ENDPOINT=__HUB_ENDPOINT__
|
||||
- HW_HUB_KEY=__HUB_KEY__
|
||||
- HW_SENSOR_ID=\${HW_SENSOR_ID:-file-canary-01}
|
||||
- HW_TEST_MODE=false
|
||||
- HW_HONEY_DIR=/honey_dir
|
||||
- HW_SEVERITY=\${HW_SEVERITY:-critical}
|
||||
|
||||
volumes:
|
||||
# LONG SYNTAX + READ ONLY
|
||||
- type: bind
|
||||
source: \${TRAP_PATH}
|
||||
target: /honey_dir
|
||||
read_only: true`,
|
||||
readme: `
|
||||
<p>The File Canary acts as both a Honeypot and a File Integrity Monitor (FIM). It watches a specified directory or file on the host machine. If an attacker modifies, deletes, or drops a file into the watched area, the sensor immediately fires an alert to the HoneyWire Hub.</p>
|
||||
|
||||
<h3>Features</h3>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Zero-Setup SDK Integration:</strong> Natively built on the HoneyWire Go SDK.</li>
|
||||
<li><strong>Dual-Mode Operation:</strong> Can monitor highly sensitive, real system files (FIM) or act as a standalone honeypot directory (Trap).</li>
|
||||
<li><strong>Safe Permissions Handling:</strong> Uses Access Control Lists (ACLs) to securely read target directories without altering their original host ownership.</li>
|
||||
<li><strong>Failsafe Mounts:</strong> Designed to halt deployment if the target directory doesn't exist, preventing false-positive monitoring.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
<p class="mb-2">Configuration is managed through an <code>.env</code> file located in the same directory as the <code>docker-compose.yml</code>.</p>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Core Ecosystem Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Example</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_ENDPOINT</td><td class="p-3">The URL of your central HoneyWire Hub.</td><td class="p-3 mono text-xs">http://127.0.0.1:8080</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_KEY</td><td class="p-3">The shared secret API key to authenticate with the Hub.</td><td class="p-3 mono text-xs">super_secret_key_123</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SENSOR_ID</td><td class="p-3">A unique identifier for this specific trap.</td><td class="p-3 mono text-xs">file-canary-01</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SEVERITY</td><td class="p-3">Alert severity sent to the Hub.</td><td class="p-3 mono text-xs">critical</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Sensor-Specific Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Default</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">TRAP_PATH</td><td class="p-3">The physical path on the host machine to monitor.</td><td class="p-3 mono text-xs">./trap_directory</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Security Architecture</h3>
|
||||
<p>This sensor is architected for extreme resilience against exploits. By utilizing a minimal attack surface and enforcing strict container sandboxing, it ensures the host filesystem remains protected.</p>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Unprivileged Execution:</strong> Runs entirely as a non-root user (<code>UID 65532</code>), preventing system-level modifications even in the event of a container breach.</li>
|
||||
<li><strong>Read-Only Mounts:</strong> The target directory is mounted with strict <code>read_only: true</code> flags, ensuring the container cannot write to or modify the host files.</li>
|
||||
<li><strong>ACL Integration:</strong> Instead of changing host file ownership, a temporary initialization container uses <code>setfacl</code> to grant the non-root user specific, read-only traverse rights, keeping your original host permissions completely intact.</li>
|
||||
<li><strong>Kernel Capability Stripping:</strong> Drops all default Linux kernel capabilities (<code>cap_drop: ALL</code>) via the Docker Compose configuration, neutralizing advanced kernel exploitation techniques.</li>
|
||||
<li><strong>Distroless Isolation:</strong> Built on a statically-linked Distroless image. It completely lacks a shell (<code>/bin/sh</code>), package managers, or standard Linux utilities, leaving attackers with zero tools to pivot to the host.</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'icmp-canary',
|
||||
name: 'ICMP Canary (Ping)',
|
||||
osi: 'L3 Network',
|
||||
shortDesc: 'A network tripwire. Listens for ICMP Echo Requests directed at isolated IPs or unused subnets.',
|
||||
icon: 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
compose: `services:
|
||||
icmp-canary:
|
||||
image: ghcr.io/andreicscs/honeywire-icmpcanary:latest
|
||||
container_name: hw-icmp-canary
|
||||
restart: unless-stopped
|
||||
|
||||
# Preserves the real Source IP of the attacker.
|
||||
network_mode: "host"
|
||||
# Root user is required by the Linux kernel to utilize NET_RAW for ICMP
|
||||
user: "0:0"
|
||||
|
||||
# --- SECURITY SANDBOX ---
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
cap_add: ["NET_RAW"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# ------------------------
|
||||
|
||||
environment:
|
||||
- HW_HUB_ENDPOINT=__HUB_ENDPOINT__
|
||||
- HW_HUB_KEY=__HUB_KEY__
|
||||
- HW_SENSOR_ID=\${HW_SENSOR_ID:-ping-canary-01}
|
||||
- HW_SEVERITY=\${HW_SEVERITY:-high}
|
||||
- HW_TEST_MODE=false`,
|
||||
readme: `
|
||||
<p>The ICMP Canary (Ping Canary) is a simple, highly effective network tripwire. It listens for ICMP Echo Requests (pings) directed at the host machine. It is best deployed on isolated IPs, darknets, or unused subnets where any inbound ICMP traffic is inherently suspicious.</p>
|
||||
|
||||
<h3>Features</h3>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Zero-Setup SDK Integration:</strong> Natively built on the HoneyWire Go SDK.</li>
|
||||
<li><strong>Raw Socket Listening:</strong> Uses pure Go to listen directly for protocol 1 (ICMP) packets without external C-dependencies.</li>
|
||||
<li><strong>Low Overhead:</strong> Requires minimal CPU and RAM to operate, making it ideal for widespread deployment.</li>
|
||||
<li><strong>Distroless Container:</strong> Compiled as a statically-linked binary running inside a minimal Docker image.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
<p class="mb-2">Configuration is managed through Environment Variables.</p>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Core Ecosystem Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Example</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_ENDPOINT</td><td class="p-3">The URL of your central HoneyWire Hub.</td><td class="p-3 mono text-xs">http://127.0.0.1:8080</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_KEY</td><td class="p-3">The shared secret API key to authenticate with the Hub.</td><td class="p-3 mono text-xs">super_secret_key_123</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SENSOR_ID</td><td class="p-3">A unique identifier for this specific trap.</td><td class="p-3 mono text-xs">ping-canary-01</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SEVERITY</td><td class="p-3">Alert severity sent to the Hub.</td><td class="p-3 mono text-xs">high</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Security Architecture</h3>
|
||||
<p>This sensor is architected for extreme resilience against exploits. By utilizing a minimal attack surface and enforcing strict container sandboxing, it safely intercepts raw ICMP traffic.</p>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Raw Socket Isolation:</strong> Bypasses heavy NIDS frameworks by interacting directly with network packets in pure Go, eliminating external C-library vulnerabilities.</li>
|
||||
<li><strong>Least Privilege Execution:</strong> Runs as container root strictly to bind the raw socket, relying on container boundaries to limit system access.</li>
|
||||
<li><strong>Kernel Capability Stripping:</strong> Drops all default Linux kernel capabilities (<code>cap_drop: ALL</code>) and only adds back <code>NET_RAW</code>, ensuring the sensor can intercept pings but cannot modify the host filesystem or OS.</li>
|
||||
<li><strong>Distroless Isolation:</strong> Built on a statically-linked Distroless image. It completely lacks a shell (<code>/bin/sh</code>), package managers, or standard Linux utilities, leaving attackers with zero tools to execute secondary payloads.</li>
|
||||
<li><strong>In-Memory Operation:</strong> Processes all packet data exclusively in memory, ensuring zero malicious disk I/O operations occur on the host system.</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'scan-detector',
|
||||
name: 'Network Scan Detector',
|
||||
osi: 'L4 Transport',
|
||||
shortDesc: 'Low-overhead network sensor to silently detect horizontal port scans via raw SYN packets.',
|
||||
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z M10 7v3m0 0v3m0-3h3m-3 0H7',
|
||||
compose: `services:
|
||||
scan-detector:
|
||||
image: ghcr.io/andreicscs/honeywire-networkscandetector:latest
|
||||
container_name: hw-scan-detector
|
||||
restart: unless-stopped
|
||||
|
||||
# Preserves the real Source IP of the attacker.
|
||||
network_mode: "host"
|
||||
# Root user is required by the kernel to utilize NET_RAW
|
||||
user: "0:0"
|
||||
|
||||
# --- SECURITY SANDBOX ---
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
cap_add: ["NET_RAW"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# ------------------------
|
||||
|
||||
environment:
|
||||
- HW_HUB_ENDPOINT=__HUB_ENDPOINT__
|
||||
- HW_HUB_KEY=__HUB_KEY__
|
||||
- HW_SENSOR_ID=\${HW_SENSOR_ID:-scan-detector-01}
|
||||
- HW_SEVERITY=\${HW_SEVERITY:-critical}
|
||||
- HW_TEST_MODE=false
|
||||
|
||||
# Scan Detector Configuration
|
||||
- HW_SCAN_THRESHOLD=5
|
||||
- HW_SCAN_WINDOW=5
|
||||
- HW_IGNORE_PORTS=80,443`,
|
||||
readme: `
|
||||
<p>The Network Scan Detector is a low-overhead network sensor designed to silently detect horizontal port scans. By monitoring raw SYN packets directly on the network interface, it identifies scanning activity aimed at closed or unused ports before it ever reaches a firewall or application log.</p>
|
||||
|
||||
<h3>Features</h3>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Zero-Setup SDK Integration:</strong> Natively built on the HoneyWire Go SDK.</li>
|
||||
<li><strong>In-Memory Parsing:</strong> Analyzes raw TCP headers directly in memory.</li>
|
||||
<li><strong>Configurable Thresholds:</strong> Easily adjust how many unique ports must be hit within a specific time window to trigger an alert.</li>
|
||||
<li><strong>Distroless Container:</strong> Compiled as a statically-linked binary running inside a minimal Docker image.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
<p class="mb-2">All configuration is handled via Environment Variables.</p>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Core Ecosystem Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Example</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_ENDPOINT</td><td class="p-3">The URL of your central HoneyWire Hub.</td><td class="p-3 mono text-xs">http://127.0.0.1:8080</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_KEY</td><td class="p-3">The shared secret API key to authenticate with the Hub.</td><td class="p-3 mono text-xs">super_secret_key_123</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SENSOR_ID</td><td class="p-3">A unique identifier for this specific trap.</td><td class="p-3 mono text-xs">scan-detector-01</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SEVERITY</td><td class="p-3">Alert severity sent to the Hub.</td><td class="p-3 mono text-xs">critical</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Sensor-Specific Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Default</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_SCAN_THRESHOLD</td><td class="p-3">Number of unique ports that must be hit to trigger an alert.</td><td class="p-3 mono text-xs">5</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SCAN_WINDOW</td><td class="p-3">The time window (in seconds) to track the threshold.</td><td class="p-3 mono text-xs">5</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_IGNORE_PORTS</td><td class="p-3">Comma-separated ports to ignore (e.g., actual open services).</td><td class="p-3 mono text-xs">80,443</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Security Architecture</h3>
|
||||
<p>This sensor is architected for extreme resilience against exploits. By utilizing a minimal attack surface and enforcing strict container sandboxing, it safely handles raw network traffic.</p>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Raw Socket Isolation:</strong> Bypasses heavy NIDS frameworks by interacting directly with network packets in pure Go, eliminating external C-library vulnerabilities.</li>
|
||||
<li><strong>Least Privilege Execution:</strong> Runs as container root strictly to bind the raw socket, but relies on capability dropping to prevent privilege escalation.</li>
|
||||
<li><strong>Kernel Capability Stripping:</strong> Drops all default Linux kernel capabilities (<code>cap_drop: ALL</code>) and only adds back <code>NET_RAW</code>, ensuring the sensor can read packets but cannot modify the system.</li>
|
||||
<li><strong>Distroless Isolation:</strong> Built on a statically-linked Distroless image. It completely lacks a shell (<code>/bin/sh</code>), package managers, or standard Linux utilities, leaving attackers with zero tools to pivot to the host network.</li>
|
||||
<li><strong>In-Memory Operation:</strong> Processes all payload data exclusively in memory, ensuring zero malicious disk I/O operations occur on the host system.</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'tcp-tarpit',
|
||||
name: 'TCP Tarpit',
|
||||
osi: 'L4 Transport',
|
||||
shortDesc: 'Binds to decoy ports and intentionally stalls attackers to waste their time while extracting payloads.',
|
||||
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
compose: `services:
|
||||
tcp-tarpit:
|
||||
image: ghcr.io/andreicscs/honeywire-tcptarpit:latest
|
||||
container_name: hw-tcp-tarpit
|
||||
restart: unless-stopped
|
||||
|
||||
# Preserves the real Source IP of the attacker.
|
||||
network_mode: "host"
|
||||
# Required to bind to low ports
|
||||
user: "0:0"
|
||||
|
||||
# --- SECURITY Root Sandbox ---
|
||||
cap_drop: ["ALL"]
|
||||
cap_add: ["NET_BIND_SERVICE"]
|
||||
read_only: true
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# -----------------------------
|
||||
|
||||
environment:
|
||||
- HW_HUB_ENDPOINT=__HUB_ENDPOINT__
|
||||
- HW_HUB_KEY=__HUB_KEY__
|
||||
- HW_SENSOR_ID=\${HW_SENSOR_ID:-tcp-tarpit-01}
|
||||
- HW_SEVERITY=\${HW_SEVERITY:-high}
|
||||
- HW_TEST_MODE=false
|
||||
|
||||
# Tarpit Configuration
|
||||
- HW_DECOY_PORTS=2222,3306
|
||||
- HW_TARPIT_MODE=hold
|
||||
- HW_TARPIT_BANNER=SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4\\r\\n`,
|
||||
readme: `
|
||||
<p>The TCP Tarpit is a high-fidelity, low-interaction honeypot designed to detect network reconnaissance and brute-force attempts. It can act as a "Tarpit," binding to decoy ports and intentionally stalling attackers to waste their time while silently extracting their IP and payload data to report to the HoneyWire Hub, or instantly close the connection and report the IP to the Hub.</p>
|
||||
|
||||
<h3>Features</h3>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Zero-Setup SDK Integration:</strong> Natively built on the HoneyWire Go SDK.</li>
|
||||
<li><strong>Massive Concurrency:</strong> Powered by Go routines and channels, capable of trapping thousands of automated bots simultaneously with microscopic memory overhead.</li>
|
||||
<li><strong>Tarpit Modes:</strong> Supports <code>hold</code> (silent stall), <code>echo</code> (repeat data back), or <code>close</code> (immediate drop).</li>
|
||||
<li><strong>Forensic Capture:</strong> Safely buffers up to 10 lines of payload data without risking memory exhaustion.</li>
|
||||
<li><strong>Distroless Container:</strong> Compiled as a statically-linked binary running inside a hardened, unprivileged <code>:nonroot</code> Distroless Docker image to prevent container breakouts.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
<p class="mb-2">All configuration is handled via Environment Variables.</p>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Core Ecosystem Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Example</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_ENDPOINT</td><td class="p-3">The URL of your central HoneyWire Hub.</td><td class="p-3 mono text-xs">http://127.0.0.1:8080</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_KEY</td><td class="p-3">The shared secret API key to authenticate with the Hub.</td><td class="p-3 mono text-xs">super_secret_key_123</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SENSOR_ID</td><td class="p-3">A unique identifier for this specific trap.</td><td class="p-3 mono text-xs">ssh-tarpit-01</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SEVERITY</td><td class="p-3">Alert severity sent to the Hub.</td><td class="p-3 mono text-xs">high</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Sensor-Specific Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Default</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_DECOY_PORTS</td><td class="p-3">Comma-separated list of TCP ports to monitor.</td><td class="p-3 mono text-xs">2222,3306</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_TARPIT_MODE</td><td class="p-3">The behavior of the trap: <code>hold</code>, <code>echo</code>, or <code>close</code>.</td><td class="p-3 mono text-xs">hold</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_TARPIT_BANNER</td><td class="p-3">(Optional) A fake service banner to send on connect.</td><td class="p-3 mono text-xs">SSH-2.0-OpenSSH_8.2p1\\r\\n</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Tarpit Modes Explained</h3>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong><code>hold</code> (Default):</strong> The sensor accepts the connection but sends nothing. It holds the TCP socket open as long as possible (up to 1 hour), dripping empty bytes to drain the attacker's resources and slow down automated scanners like Nmap or brute-force tools.</li>
|
||||
<li><strong><code>echo</code>:</strong> Acts as an echo server, repeating whatever the attacker sends back to them. Useful for confusing automated scripts.</li>
|
||||
<li><strong><code>close</code>:</strong> Logs the connection, captures the initial payload, and forcefully closes the socket.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Security Architecture</h3>
|
||||
<p>This sensor is architected for extreme resilience against exploitation. By adhering to the principle of least privilege and enforcing strict resource limits.</p>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Kernel Capability Stripping:</strong> Drops all Linux kernel capabilities (<code>cap_drop: ALL</code>) via the Docker Compose configuration, neutralizing advanced kernel exploitation techniques.</li>
|
||||
<li><strong>Distroless Isolation:</strong> Built on a statically-linked Distroless image. It completely lacks a shell (<code>/bin/sh</code>), package managers, or common Linux utilities (like <code>curl</code> or <code>wget</code>), leaving attackers with zero tools to pivot if they achieve Remote Code Execution.</li>
|
||||
<li><strong>Concurrency Capping:</strong> Utilizes a native Go buffered channel (semaphore) to strictly cap concurrent connections at <code>1000</code>. This prevents attackers from launching a Denial of Service (DoS) attack designed to exhaust the host machine's File Descriptors or RAM.</li>
|
||||
<li><strong>In-Memory Operation:</strong> Processes all payload data exclusively in memory, ensuring zero malicious disk I/O operations occur on the host system.</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'web-decoy',
|
||||
name: 'Web Router Decoy',
|
||||
osi: 'L7 Application',
|
||||
shortDesc: 'Serves a deceptive router login page. Captures IP, user agent, and attempted credentials.',
|
||||
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
|
||||
compose: `services:
|
||||
web-decoy:
|
||||
image: ghcr.io/andreicscs/honeywire-webrouterdecoy:latest
|
||||
container_name: hw-web-decoy
|
||||
restart: unless-stopped
|
||||
|
||||
# Preserves the real Source IP of the attacker.
|
||||
network_mode: "host"
|
||||
# Required to bind to low ports
|
||||
user: "0:0"
|
||||
|
||||
# --- SECURITY SANDBOX ---
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# ------------------------
|
||||
|
||||
environment:
|
||||
- HW_HUB_ENDPOINT=__HUB_ENDPOINT__
|
||||
- HW_HUB_KEY=__HUB_KEY__
|
||||
- HW_SENSOR_ID=\${HW_SENSOR_ID:-web-decoy-01}
|
||||
- HW_SEVERITY=\${HW_SEVERITY:-critical}
|
||||
- HW_TEST_MODE=false
|
||||
|
||||
# Web Honeypot Configuration
|
||||
- HW_BIND_PORT=8081
|
||||
- HW_ROUTER_BRAND=Cisco`,
|
||||
readme: `
|
||||
<p>The Web Router Decoy is a web honeypot designed to detect credential stuffing, automated web scanners, and targeted administrative panel attacks. It serves a deceptive, router login page. When an attacker attempts to log in, the sensor captures their IP, user agent, and attempted credentials, silently reports them to the HoneyWire Hub, and safely returns a "401 Unauthorized" response to keep them guessing.</p>
|
||||
|
||||
<h3>Features</h3>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Zero-Setup SDK Integration:</strong> Natively built on the HoneyWire Go SDK.</li>
|
||||
<li><strong>Dynamic Brand Variable:</strong> Automatically injects the specified router brand (e.g., Cisco, Netgear, ASUS) directly into the HTML template to make the trap more convincing.</li>
|
||||
<li><strong>Distroless Container:</strong> Compiled as a statically-linked binary running inside a hardened, unprivileged <code>:nonroot</code> Distroless Docker image to prevent container breakouts.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
<p class="mb-2">All configuration is handled via Environment Variables.</p>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Core Ecosystem Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Example</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_ENDPOINT</td><td class="p-3">The URL of your central HoneyWire Hub.</td><td class="p-3 mono text-xs">http://127.0.0.1:8080</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_HUB_KEY</td><td class="p-3">The shared secret API key to authenticate with the Hub.</td><td class="p-3 mono text-xs">super_secret_key_123</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SENSOR_ID</td><td class="p-3">A unique identifier for this specific trap.</td><td class="p-3 mono text-xs">web-decoy-01</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_SEVERITY</td><td class="p-3">Alert severity sent to the Hub.</td><td class="p-3 mono text-xs">critical</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="font-bold text-slate-700 dark:text-zinc-300 mt-4 mb-2">Sensor-Specific Variables</h4>
|
||||
<div class="overflow-x-auto mb-6 border border-slate-200 dark:border-zinc-800 rounded-lg">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-[#121215] text-slate-500 dark:text-zinc-400">
|
||||
<tr><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Variable</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Description</th><th class="p-3 border-b border-slate-200 dark:border-zinc-800">Default</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800">
|
||||
<tr><td class="p-3 mono text-xs">HW_BIND_PORT</td><td class="p-3">The TCP port the fake web server will listen on.</td><td class="p-3 mono text-xs">8080</td></tr>
|
||||
<tr><td class="p-3 mono text-xs">HW_ROUTER_BRAND</td><td class="p-3">The brand name injected into the fake login page.</td><td class="p-3 mono text-xs">Netgear</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Security Architecture</h3>
|
||||
<p>This sensor is architected for extreme resilience against web-based exploits. By utilizing a minimal attack surface and enforcing strict container sandboxing.</p>
|
||||
<ul class="list-disc pl-5 mb-6 space-y-1">
|
||||
<li><strong>Framework-Free Execution:</strong> Built purely on Go's native <code>net/http</code> library, eliminating the massive attack surface and supply-chain risks associated with heavy third-party web frameworks (like FastAPI, Flask, or Express).</li>
|
||||
<li><strong>Unprivileged Execution:</strong> Runs entirely as a non-root user (<code>UID 65532</code>), preventing system-level modifications even in the event of a container breach.</li>
|
||||
<li><strong>Kernel Capability Stripping:</strong> Drops all Linux kernel capabilities (<code>cap_drop: ALL</code>) via the Docker Compose configuration, neutralizing advanced kernel exploitation techniques.</li>
|
||||
<li><strong>Distroless Isolation:</strong> Built on a statically-linked Distroless image. It completely lacks a shell (<code>/bin/sh</code>), package managers, or standard Linux utilities (like <code>curl</code> or <code>wget</code>), leaving attackers with zero tools to download secondary payloads or pivot to the host network.</li>
|
||||
<li><strong>In-Memory Operation:</strong> Processes all payload data exclusively in memory, ensuring zero malicious disk I/O operations occur on the host system.</li>
|
||||
</ul>
|
||||
`
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col max-w-[1600px] w-full mx-auto px-2 sm:px-4 lg:px-6">
|
||||
|
||||
<div class="mb-6 shrink-0 mt-4 sm:mt-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Sensor Store</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-400 mt-1 max-w-3xl">Deploy new HoneyWire nodes across your infrastructure. Click on a sensor to view documentation and deployment configurations.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-10">
|
||||
<div v-for="s in sensors" :key="s.id"
|
||||
@click="openSensor(s)"
|
||||
class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-lg p-5 shadow-sm hover:border-blue-500 dark:hover:border-zinc-300/20 hover:shadow-md cursor-pointer transition-all group flex flex-col">
|
||||
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="w-12 h-12 rounded-md bg-slate-50 dark:bg-[#151518] border border-slate-200 dark:border-zinc-800/80 text-blue-600 dark:text-zinc-300 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" :d="s.icon"></path></svg>
|
||||
</div>
|
||||
<span class="px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wider bg-slate-100 dark:bg-zinc-800 text-slate-500 dark:text-zinc-400 border border-slate-200 dark:border-zinc-700">
|
||||
{{ s.osi }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-zinc-100 mb-1">{{ s.name }}</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-zinc-400 leading-relaxed line-clamp-2">{{ s.shortDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="selectedSensor" class="fixed inset-0 z-50 flex justify-center items-center p-4 sm:p-6 bg-slate-900/60 dark:bg-black/60 backdrop-blur-sm" @click.self="closeSensor">
|
||||
|
||||
<div class="bg-white dark:bg-[#0a0a0c] w-full max-w-4xl h-full max-h-[85vh] rounded-lg shadow-2xl flex flex-col overflow-hidden border border-slate-200 dark:border-zinc-800/80 transform transition-all">
|
||||
|
||||
<div class="px-6 py-5 border-b border-slate-100 dark:border-zinc-800/80 flex justify-between items-start bg-slate-50/50 dark:bg-[#0c0c0e] shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-md bg-white dark:bg-[#151518] border border-slate-200 dark:border-zinc-800/80 text-blue-600 dark:text-zinc-300 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" :d="selectedSensor.icon"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold text-slate-900 dark:text-zinc-100">{{ selectedSensor.name }}</h2>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-slate-200 dark:bg-zinc-800 text-slate-600 dark:text-zinc-400 border border-slate-300 dark:border-zinc-700 hidden sm:block">
|
||||
{{ selectedSensor.osi }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-400 mt-0.5">{{ selectedSensor.shortDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeSensor" class="p-2 -mr-2 text-slate-400 hover:text-slate-600 dark:text-zinc-500 dark:hover:text-zinc-300 transition-colors rounded-full hover:bg-slate-100 dark:hover:bg-zinc-800/50">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex border-b border-slate-200 dark:border-zinc-800/80 px-6 shrink-0 bg-white dark:bg-[#0a0a0c]">
|
||||
<button @click="activeTab = 'readme'"
|
||||
class="py-3 px-2 mr-6 text-xs font-bold uppercase tracking-wider border-b-2 transition-colors focus:outline-none"
|
||||
:class="activeTab === 'readme' ? 'border-blue-500 text-blue-600 dark:border-zinc-300 dark:text-zinc-300' : 'border-transparent text-slate-500 dark:text-zinc-500 hover:text-slate-700 dark:hover:text-zinc-300'">
|
||||
Overview
|
||||
</button>
|
||||
<button @click="activeTab = 'compose'"
|
||||
class="py-3 px-2 text-xs font-bold uppercase tracking-wider border-b-2 transition-colors focus:outline-none"
|
||||
:class="activeTab === 'compose' ? 'border-blue-500 text-blue-600 dark:border-zinc-300 dark:text-zinc-300' : 'border-transparent text-slate-500 dark:text-zinc-500 hover:text-slate-700 dark:hover:text-zinc-300'">
|
||||
Deployment Script
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scroll bg-white dark:bg-[#0a0a0c]">
|
||||
|
||||
<div v-show="activeTab === 'readme'" class="p-6 md:p-8 readme-container text-slate-700 dark:text-zinc-300 text-sm">
|
||||
<div v-html="selectedSensor.readme"></div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'compose'" class="p-6 md:p-8 relative h-full flex flex-col">
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-slate-600 dark:text-zinc-400">Review and modify the configuration below. Once ready, save it as <code>docker-compose.yml</code> on your target server and deploy using <code class="bg-slate-100 dark:bg-zinc-800 px-1 py-0.5 rounded-md text-blue-600 dark:text-slate-300">docker compose up -d</code>.</p>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-[350px]">
|
||||
<textarea v-model="editableCompose"
|
||||
spellcheck="false"
|
||||
class="absolute inset-0 w-full h-full bg-slate-50 dark:bg-[#121215] text-slate-800 dark:text-zinc-300 p-5 rounded-md text-[13px] mono custom-scroll border border-slate-200 dark:border-zinc-800/80 leading-relaxed shadow-inner resize-none focus:outline-none focus:ring-1 focus:ring-blue-500/50 dark:focus:ring-zinc-500/50"
|
||||
></textarea>
|
||||
<button id="copy-btn" @click="copyToClipboard"
|
||||
class="absolute top-4 right-6 px-3 py-1.5 rounded-md bg-white dark:bg-[#1f1f22] hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:border-blue-500/50 text-slate-600 dark:text-zinc-300 text-[11px] font-bold uppercase tracking-wider transition-colors border border-slate-200 dark:border-zinc-700 shadow-sm active:scale-95 z-10">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.readme-container :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.dark .readme-container :deep(h3) {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
.readme-container :deep(h4) {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.readme-container :deep(p) {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.readme-container :deep(code) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.dark .readme-container :deep(code) {
|
||||
background-color: #27272a;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
</style>
|
||||
+29
-23
@@ -1,29 +1,35 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
'/login': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/logout': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env variables (falls back to 8080 if HW_PORT isn't set locally)
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const backendUrl = `http://localhost:${env.HW_PORT || '8080'}`
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
'/login': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/logout': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
## 📋 Table of Contents
|
||||
- [Overview](#honeywire)
|
||||
@@ -16,11 +15,13 @@
|
||||
---
|
||||
# HoneyWire
|
||||
|
||||
**HoneyWire Sentinel** is a lightweight, Distributed High-Signal Security Early-Warning System, designed for internal networks. It replaces the "magnifying glass" approach of traditional SIEMs, which often drown analysts in false positives by surveilling legitimate traffic, with a High-Fidelity Tripwire model. Place a sensor that does what you need exactly where you want, for example:
|
||||
- Production Tripwires: Sound the alarm when active services are being poked in ways they shouldn't be. By placing a sensor on a sensitive file that should never be read or a service port that should never be accessed, you identify intruders by their deviation from the "authorized path."
|
||||
- Synthetic Deception: Deploy lures like the [ICMP Canary](./Sensors/official/IcmpCanary/) or [Network Scan Detector](./Sensors/official/NetworkScanDetector/) to act as decoys. Since these sensors provide no legitimate business value, 100% of their traffic is actionable intelligence.
|
||||
**HoneyWire Sentinel** is a lightweight, Distributed High-Signal Security Early-Warning System, designed for internal networks. It replaces the "magnifying glass" approach of traditional SIEMs, which often drown analysts in false positives by surveilling legitimate traffic, with a High-Fidelity Tripwire model.
|
||||
|
||||
If it is tripped, something is wrong, set up multiple and you start to have a pretty clear idea of the lateral movement of an intruder. No tuning, no noise, just instant forensics.
|
||||
Place a sensor exactly where you want it. If it trips, you have an intruder.
|
||||
- **Production Tripwires**: Sound the alarm when active services are being poked in ways they shouldn't be. By placing a sensor on a sensitive file that should never be read or a service port that should never be accessed, you identify intruders by their deviation from the "authorized path."
|
||||
- **Synthetic Deception**: Deploy lures like the [ICMP Canary](./Sensors/official/IcmpCanary/) or [Network Scan Detector](./Sensors/official/NetworkScanDetector/) to act as decoys. Since these sensors provide no legitimate business value, 100% of their traffic is actionable intelligence.
|
||||
|
||||
Set up multiple and you start to have a pretty clear idea of the lateral movement of an intruder. No tuning, no noise, just instant forensics.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,11 +65,11 @@ Whether it is a **Deep Packet Inspection (DPI)** engine, a **DNS sinkhole**, a *
|
||||
|
||||
*The Hub's frontend automatically translates arrays into syntax-highlighted code blocks and primitive values into clean detail tags.*
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **The Sentinel Hub UI:** A fully responsive dashboard featuring Dark/Light mode, real-time Chart.js threat distribution, and dynamic forensic payload inspection.
|
||||
- **The Sentinel Hub UI:** A fully responsive, Vue 3-powered dashboard featuring Dark/Light mode, live WebSocket event streaming, and dynamic forensic payload inspection.
|
||||
- **In-Browser Configuration:** Manage Master Passwords, Hub API Keys, Data Retention policies, and Webhooks directly from the UI. No need to touch `.env` files or restart containers to update alert targets.
|
||||
- **Universal Push Notifications:** Native, zero-dependency integration for routing critical alerts to **Discord, Slack, Ntfy, and Gotify**.
|
||||
- **Suite of Official Sensors:** Includes native [TCP Tarpit](./Sensors/official/TcpTarpit/), [Web Router Decoy](./Sensors/official/WebRouterDecoy/), [File Canary (FIM)](./Sensors/official/FileCanary/), [ICMP Canary](./Sensors/official/IcmpCanary/), and [Network Scan Detector](./Sensors/official/NetworkScanDetector/).
|
||||
|
||||
---
|
||||
@@ -77,7 +78,7 @@ Whether it is a **Deep Packet Inspection (DPI)** engine, a **DNS sinkhole**, a *
|
||||
|
||||
HoneyWire is split into three independent microservices:
|
||||
|
||||
1. `/Hub`: The central brain. A pure Go binary running an embedded SQLite database and the web dashboard. It runs as a non-root user inside a Distroless container, safely mounting data to a dedicated volume.
|
||||
1. `/Hub`: The central brain. A pure Go binary running an embedded SQLite database and the Vue.js dashboard. It runs as a non-root user inside a Distroless container, safely mounting data to a dedicated volume.
|
||||
2. `/Sensors`: The decoy nodes. Statically-linked Go binaries that listen on vulnerable ports, trap attackers, and securely POST intrusion data back to the Hub.
|
||||
3. `/SDKs`: Official libraries (like `sdk-go`) that handle secure Hub communication so community developers can easily build new sensors.
|
||||
|
||||
@@ -85,14 +86,12 @@ HoneyWire is split into three independent microservices:
|
||||
|
||||
## 🚀 Quick Start Guide
|
||||
|
||||
Deploying HoneyWire takes less than 60 seconds using our pre-built GitHub Container images. No compiling is required.
|
||||
Deploying the HoneyWire Hub takes less than 60 seconds using our pre-built GitHub Container images. No compiling is required.
|
||||
|
||||
Create a new directory on your server, and create two files: `docker-compose.yml` and `.env`.
|
||||
### 1. Deploy the Hub
|
||||
Create a new directory on your server, create a `docker-compose.yml` file, and paste the following:
|
||||
|
||||
### 1. The `docker-compose.yml`
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 1. THE PERMISSION FIXER: Runs once to ensure the Hub can write to the data volume
|
||||
permission-fixer:
|
||||
@@ -107,81 +106,47 @@ services:
|
||||
container_name: honeywire-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HW_PORT:-8080}:${HW_PORT:-8080}"
|
||||
# Change 8080 to whatever port you prefer
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./honeywire_data:/data
|
||||
depends_on:
|
||||
permission-fixer:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# Strict Security Sandbox
|
||||
user: "65532:65532"
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# 3. EXAMPLE SENSOR: The TCP Tarpit (See /Sensors for more)
|
||||
tcp-tarpit:
|
||||
image: ghcr.io/andreicscs/honeywire-tcptarpit:latest
|
||||
container_name: hw-tcp-tarpit
|
||||
restart: unless-stopped
|
||||
network_mode: "host" # Required to capture true source IPs
|
||||
user: "0:0" # Required to bind to low ports
|
||||
# Security hardening
|
||||
cap_drop: ["ALL"]
|
||||
cap_add: ["NET_BIND_SERVICE"]
|
||||
read_only: true
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
- HW_PORT=8080
|
||||
# Optional: Hardcode the dashboard password (disables the UI password reset feature)
|
||||
# - HW_DASHBOARD_PASSWORD=admin
|
||||
```
|
||||
|
||||
### 2. The `.env` Configuration
|
||||
```ini
|
||||
# ==========================================
|
||||
# HUB CONFIGURATION
|
||||
# ==========================================
|
||||
# Secret key used by sensors to authenticate with the Hub
|
||||
HW_HUB_KEY=change_this_to_a_secure_random_string
|
||||
|
||||
# Optional: Protect the Web UI (Leave blank for no password)
|
||||
HW_DASHBOARD_PASSWORD=admin
|
||||
|
||||
# Optional: Push Notifications
|
||||
HW_NTFY_URL=https://ntfy.sh/your_private_topic
|
||||
# HW_GOTIFY_URL=https://gotify.example.com/message
|
||||
# HW_GOTIFY_TOKEN=your_token
|
||||
|
||||
# ==========================================
|
||||
# SENSOR EXAMPLE: TCP TARPIT
|
||||
# ==========================================
|
||||
# Point this to your Hub's IP and Port
|
||||
HW_HUB_ENDPOINT=http://127.0.0.1:8080
|
||||
HW_SENSOR_ID=tarpit-01
|
||||
|
||||
# Ports to monitor, behavior mode, and fake service banner
|
||||
HW_DECOY_PORTS=22,2222,3306
|
||||
HW_TARPIT_MODE=hold
|
||||
HW_SEVERITY=high
|
||||
HW_TARPIT_BANNER=SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.1\r\n
|
||||
```
|
||||
|
||||
### 3. Start the Trap
|
||||
Run the following command to pull the images and start the honeypot:
|
||||
Start the Hub:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Access the dashboard at `http://localhost:8080` (or your server's IP).
|
||||
|
||||
---
|
||||
### 2. Initialize the System
|
||||
Navigate to `http://<your-server-ip>:8080` in your browser. You will be greeted by the **Initialize Sentinel** screen.
|
||||
1. Create your Master Password.
|
||||
2. Verify your Hub Endpoint URL (the IP/URL where sensors will reach the Hub).
|
||||
3. Generate your secure Sensor Secret Key.
|
||||
4. Click "Initialize Hub".
|
||||
|
||||
### 3. Deploy Sensors
|
||||
Inside the Dashboard, navigate to the **Sensor Store**. Click on any sensor (e.g., the TCP Tarpit) to view its documentation. The Hub will automatically generate a ready-to-use `docker-compose.yml` script pre-filled with your Hub's IP and API Key. Copy that script, drop it on your target machine, and run `docker compose up -d`!
|
||||
|
||||
|
||||
### 4. Testing the Trap
|
||||
|
||||
Once your containers are up, the Tarpit sensor should appear as `ONLINE` in the **Fleet Health** section of the dashboard within 30 seconds.
|
||||
|
||||
To verify the detection loop, use `netcat` from a different machine (or a different terminal) to trigger the decoy:
|
||||
If you deployed the TcpTarpit sensor, to verify the detection loop, use `netcat` from a different machine (or a different terminal) to trigger the decoy:
|
||||
|
||||
```bash
|
||||
# Connect to your decoy port (e.g., 2222) at localhost (or your server's IP).
|
||||
@@ -199,20 +164,17 @@ nc localhost 2222
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
* **API Secret:** Ensure your `HW_HUB_KEY` is strong and identical on both the Hub and the Sensors. The Hub will reject any payloads with mismatched keys. We will eventually implement automatic API key generation from the Hub for each sensor.
|
||||
* **API Secret:** Ensure your `HW_HUB_KEY` is strong and identical on both the Hub and the Sensors. The Hub will reject any payloads with mismatched keys.
|
||||
* **System Arming:** You can toggle the "System Armed" button in the Hub UI to temporarily disable push notifications while doing internal network maintenance or vulnerability scanning.
|
||||
* **Container Hardening:** HoneyWire utilizes `gcr.io/distroless/static-debian12:nonroot`. We follow the principle of least privelege to make sure that if a container is compromised, the blast is contained.
|
||||
* **Container Hardening:** HoneyWire utilizes `gcr.io/distroless/static-debian12:nonroot`. We follow the principle of least privilege to make sure that if a container is compromised, the blast is contained.
|
||||
* **Distributed Deployment:** It is highly recommended to run the Hub and its Sensors on separate physical or virtual machines. If an attacker compromises a sensor node, they should not have immediate local access to the centralized Hub.
|
||||
* ! **Encryption (HTTPS):** We **do not** yet implement HTTPS as this project is a work in progress.
|
||||
It is important to Always serve the Hub Web GUI and API over HTTPS using a reverse proxy (like Nginx, Caddy, or Traefik). Failure to do so exposes your `HW_HUB_KEY` and `HW_DASHBOARD_PASSWORD` to network sniffing.
|
||||
|
||||
* ⚠️ **Encryption (HTTPS):** Always serve the Hub Web GUI and API over HTTPS using a reverse proxy (like Nginx, Caddy, or Traefik) in production. Failure to do so exposes your `HW_HUB_KEY` and Dashboard password to network sniffing.
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
* **Backend:** Go 1.25, `net/http` (Standard Library), SQLite (Pure Go Driver)
|
||||
* **Frontend:** HTML5, TailwindCSS, Alpine.js, Chart.js
|
||||
* **Backend:** Go 1.25, `net/http` (Standard Library), SQLite (ModernC Pure Go Driver)
|
||||
* **Frontend:** Vue 3 (Composition API), TailwindCSS, Chart.js
|
||||
* **Infrastructure:** Docker, Docker Compose, Distroless Linux Sandbox
|
||||
|
||||
---
|
||||
|
||||
## Versioning and API Reference
|
||||
@@ -226,6 +188,7 @@ It is important to Always serve the Hub Web GUI and API over HTTPS using a rever
|
||||
---
|
||||
|
||||
## Operational Checklist
|
||||
- [x] Set `HW_HUB_KEY` for all components.
|
||||
- [x] Set optional `HW_DASHBOARD_PASSWORD`.
|
||||
- [x] Rebuild/redeploy containers after any version bump in `VERSION` or environment variable changes.
|
||||
- [x] Complete the Web UI Initial Setup to set the Master Password.
|
||||
- [x] Retrieve the generated `HW_HUB_KEY` from Settings and apply it to your sensors.
|
||||
- [x] Configure your push notification webhooks via the Settings UI.
|
||||
- [x] Rebuild/redeploy containers after any version bump in `VERSION` or environment variable changes.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** To ensure the safety of the community and prevent premature disclosure of exploits, we utilize **GitHub Private Vulnerability Reporting**.
|
||||
|
||||
### How to report:
|
||||
1. Go to the **Security tab** of this repository.
|
||||
2. Click **Report a vulnerability** under the Advisories section.
|
||||
3. Provide a clear description of the vulnerability, including:
|
||||
- The affected component (Hub, specific Sensor, or SDK).
|
||||
- Steps to reproduce the exploit.
|
||||
- The potential impact (e.g., DoS, Remote Code Execution, Authentication Bypass).
|
||||
- Any potential mitigation or fix you might suggest.
|
||||
|
||||
As a solo maintainer, I will do my best to acknowledge receipt of your vulnerability report within 48-72 hours and will work with you to patch the issue before a public CVE or advisory is published.
|
||||
|
||||
Thank you for helping keep HoneyWire secure!
|
||||
@@ -1,3 +1,5 @@
|
||||
[](../../LICENSE)
|
||||
|
||||
# Community Sensor Lab
|
||||
|
||||
Welcome to the **HoneyWire Community Sensor Lab**. While our official sensors provide the core foundation, this directory is where the decentralized deception ecosystem truly thrives.
|
||||
@@ -38,5 +40,3 @@ To protect our users, every sensor submitted here undergoes a rigorous automated
|
||||
**Join us in building a smarter, faster, and more resilient distributed defense.** 🐝
|
||||
|
||||
---
|
||||
[](../../LICENSE)
|
||||
[]()
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 1. THE FIM SETTER: Only runs if the path exists. Simply grants the read-only ACL pass.
|
||||
permission-fixer:
|
||||
|
||||
Reference in New Issue
Block a user