diff --git a/Docs/API.md b/Docs/API.md
index 817dc70..388de48 100644
--- a/Docs/API.md
+++ b/Docs/API.md
@@ -104,6 +104,13 @@ Common message types:
Fetches the sensor manifest catalog from `RegistryURL` or the default public manifest registry.
+### GET /api/v1/manifests/{sensorId}/versions
+
+Fetches a specific historical version schema of a sensor manifest from the registry.
+
+**Query parameters:**
+- `version` — The exact version string to fetch (e.g. `2.0.6`).
+
### POST /api/v1/compose/generate
Generates a `docker-compose.yml` preview from the selected sensor manifests and UI-provided environment values.
@@ -193,6 +200,10 @@ Updates a node's alias, tags, public IP, or private IP.
}
```
+#### POST /api/v1/nodes/{nodeId}/upgrade
+
+Triggers a fleet-wide sync command over WebSockets to force the node to pull the newest compose definitions and automatically pull the latest manifest versions for all of its installed sensors.
+
#### DELETE /api/v1/nodes/{nodeId}
Deletes a node and cascades to remove its sensors, event history, and heartbeat records.
@@ -220,6 +231,10 @@ Updates a sensor's custom name and config values and marks the parent node pendi
Removes a sensor from the node and marks the node pending sync.
+#### POST /api/v1/nodes/{nodeId}/sensors/{sensorId}/upgrade
+
+Upgrades a specific sensor to the latest available manifest version listed in the registry, and marks the node pending sync.
+
#### PATCH /api/v1/nodes/{nodeId}/sensors/{sensorId}/silence
Toggles a sensor's silence state. Silenced sensors still log events but suppress push notifications.
diff --git a/Docs/architecture/README.md b/Docs/architecture/README.md
index 7e99b46..a59d927 100644
--- a/Docs/architecture/README.md
+++ b/Docs/architecture/README.md
@@ -27,7 +27,7 @@ HoneyWire
### Hub
The central orchestrator and data aggregator.
-*For deep-dives into the Hub, see the [Backend Architecture](./hub/backend/overview.md) and [Frontend Architecture](./hub/frontend/overview.md).*
+*For deep-dives into the Hub, see the [Backend Architecture](./hub/backend/overview.md), and [Frontend Architecture](./hub/frontend/overview.md).*
**Responsibilities:**
- Stores events
@@ -219,6 +219,7 @@ A high-level view of internal subsystem boundaries:
**Hub**
├── API Layer
+├── Compose Compiler
├── Services
├── Store
├── Projections
diff --git a/Docs/architecture/hub/backend/compose.md b/Docs/architecture/hub/backend/compose.md
new file mode 100644
index 0000000..8e0bedd
--- /dev/null
+++ b/Docs/architecture/hub/backend/compose.md
@@ -0,0 +1,44 @@
+# Hub Secure Compose Compiler Architecture
+
+The `internal/compose` package is responsible for compiling deterministic, hardened `honeywire-compose.yml` configurations served to remote edge nodes.
+
+To prevent privilege escalation or container escape vulnerabilities, the compiler operates on the principle of **Secure Defaults by Inversion**. Instead of attempt-filtering bad configurations out of an arbitrary map, the compiler explicitly maps specific allowed schema primitives into a locked-down, pre-configured Compose base.
+
+---
+
+## The Compilation Pipeline
+
+```mermaid
+graph TD
+ A[Raw Manifest JSON / Preview Payload] -->|Strict Ingestion: DisallowUnknownFields| B(Ingestion & Validation)
+ B -->|Normalizes paths & cleans capabilities| C(Validator: security/validate.go)
+ C -->|BuildEnv overrides client & injects system variables| D(Secure Env Composition: env.go)
+ D -->|Lazy-loads & caches historical tags| E(Dual-Manifest Version Resolution)
+ E -->|Forces Read-Only, drops all capabilities, sorts arrays| F(The Builder: builder.go)
+ F -->|Outputs bit-for-bit deterministic file| G(honeywire-compose.yml)
+```
+
+### 1. Strict Ingestion
+All API endpoints receiving compose preview payloads, as well as catalog fetch operations, ingest JSON using `json.NewDecoder` configured with `.DisallowUnknownFields()`. This establishes a hard boundary at the API layer: if a manifest or override payload contains attributes outside the schema, the request is immediately rejected before any compilation logic executes.
+
+### 2. The Validator (`internal/compose/security/validate.go`)
+The validation module acts as the security gateway for all manifests and user-provided configuration overrides. It performs:
+* **Capability Restriction:** Enforces a strict allowlist of Linux capabilities (e.g. `NET_RAW`, `NET_BIND_SERVICE`, `NET_ADMIN`). Any other capabilities are blocked.
+* **Volume Path Normalization:** Cleans host and container path structures (`filepath.Clean()`) and rejects paths matching a forbidden directory denylist (e.g., `/proc`, `/sys`, `/var/run/docker.sock`).
+* **Safe Interpolation Checking:** Rejects common interpolation patterns (such as `${` or `{{`) on raw fields to block injection vectors, relying instead on structural builder templates.
+
+### 3. Secure Environment Composition (`internal/compose/env.go`)
+The environment generation pipeline merges variables with a strict priority order:
+1. **Manifest Defaults:** Base variables defined in the sensor manifest.
+2. **User/Node Overrides:** Safe environment variables requested via the UI or Wizard (ignoring any attempting to override forbidden system environment fields).
+3. **System-Injected Constants:** Hardcoded, system-injected values (such as `HW_HUB_KEY`, `HW_SENSOR_ID`) that are unconditionally set by the Hub.
+
+### 4. Versioning and Dual-Manifest Resolution
+To support manual upgrades and historical rollbacks, nodes often run versions of a sensor other than the absolute latest catalog version. To prevent compiling incompatible or broken configurations, the compiler uses dual-manifest resolution:
+* **Latest Catalog:** Warmed into cache on startup via the central `index.json`.
+* **Historical Schemas:** Lazy-loaded and permanently cached via `FetchSpecificManifest` from the configured registry when a legacy node requests its deployed version. If a node is deployed with a legacy tag, the compose preview will render configurations specific to that historical manifest tag rather than defaulting to the newest catalog entry.
+
+### 5. The Builder (`internal/compose/builder.go`)
+The builder converts the strictly validated model inputs into a `ComposeFile` struct:
+* **Immutable Sandboxing:** Unconditionally forces secure runtime defaults, including `ReadOnly: true`, `CapDrop: ["ALL"]`, and `SecurityOpt: ["no-new-privileges:true"]`.
+* **Bit-for-Bit Determinism:** Output arrays (environment pairs, volume mount paths, initialization chains) are explicitly alphabetically sorted. This ensures that the generated YAML string is perfectly deterministic and predictable.
diff --git a/Docs/architecture/hub/backend/overview.md b/Docs/architecture/hub/backend/overview.md
new file mode 100644
index 0000000..ecc0305
--- /dev/null
+++ b/Docs/architecture/hub/backend/overview.md
@@ -0,0 +1,80 @@
+# HoneyWire Hub Backend Overview
+
+The HoneyWire Hub backend is the central orchestrator of the system, written entirely in Go. It strictly adheres to a **Domain Service Pattern** (a lightweight form of Clean Architecture / Domain-Driven Design). This design ensures high testability, predictable state management, and clear separation of concerns.
+
+## Technology Stack
+
+Go (Golang) was chosen as the primary language for the Hub backend for several strategic reasons:
+- **Concurrency & WebSockets:** Go's lightweight goroutines make it exceptionally well-suited for handling thousands of simultaneous, long-lived WebSocket connections without the overhead of traditional threading models.
+- **Single Static Binary:** Go compiles to a single, statically linked executable. This allows the Hub to be deployed in a highly secure, minimal Distroless container sandbox with no runtime dependencies.
+- **Performance:** As a distributed security system, the Hub must ingest high-velocity telemetry from sensors across the network without dropping packets. Go's performance characteristics ensure low latency and high throughput.
+- **Standard Library:** Go's robust `net/http` standard library allowed us to build the entire API and router without relying on heavy third-party web frameworks, reducing the attack surface.
+- **Embedded Database Compatibility:** Go's ability to seamlessly integrate with CGO-free, pure-Go SQLite drivers enables a zero-configuration, single-node persistence layer perfectly suited for edge deployments.
+
+## Core Principles
+
+The backend architecture is built upon five foundational principles:
+
+1. **Thin Transport Layer:** HTTP handlers in the `api` package contain **zero** business logic. They are strictly responsible for parsing JSON, reading context, and formatting HTTP responses.
+2. **Isolated Domain Services:** All business rules, side effects (like WebSockets or Push Notifications), and complex transactions live exclusively in the `services` package.
+3. **Interface Segregation:** Services define their own narrow interfaces (e.g., `Store` and `Broadcaster`). They do not depend on concrete implementations like `SQLiteStore` or `WebSocketService`.
+4. **Explicit Composition:** All dependencies are instantiated and wired together precisely once in the Composition Root (`cmd/hub/main.go`). Global state is strictly prohibited.
+5. **Contextual Authentication:** Middleware authenticates requests and injects the verified identity (e.g., `NodeID`) into the standard `context.Context` to be used by the layers below.
+
+## Directory Structure
+
+The backend source code is structured as follows:
+
+```text
+internal/
+├── api/ # Transport Layer (HTTP Handlers, Middleware, Router)
+├── compose/ # Secure Compose Compiler & Validation Engine
+├── models/ # Core Domain Entities (Structs, JSON tags)
+├── projections/ # CQRS Read-Models (Analytics, Dashboards)
+├── services/ # Domain Layer (Business Logic, Orchestration)
+└── store/ # Persistence Layer (SQLite implementation)
+```
+
+## Core Subsystems & Layers
+
+### 1. Transport Layer (`internal/api`)
+The entry point for all network requests.
+- **Handlers:** Bound to domain-specific struct receivers. They handle JSON marshaling/unmarshaling, input validation, and HTTP status codes. They **never** interact with the database directly.
+- **Middleware:** Handles Authentication, extracts credentials, validates them, and attaches identity to the request context.
+- **Router:** Maps HTTP routes to specific Handlers and applies Middleware groups.
+ - **SPA Routing & Frontend Embedding:** The router securely serves the embedded Vue 3 frontend. It enforces strict API protection (returning pure 404 JSON for unmatched `/api/` routes) while providing a transparent SPA fallback (serving `index.html`) for unrecognized paths, enabling seamless frontend History API navigation (e.g., `/dashboard`) without reload errors.
+
+### 2. Secure Compose Compiler (`internal/compose`)
+Responsible for compiling deterministic, hardened `honeywire-compose.yml` configurations served to remote edge nodes. See the [Compose Compiler Architecture](./compose.md) for more details.
+- **Secure Defaults by Inversion:** Explicitly maps specific allowed schema primitives (e.g. strict Linux capabilities, normalized volume paths) into a locked-down Compose base instead of attempting to filter arbitrary config maps.
+- **Validation Engine:** Sanitizes configurations, normalizes directory paths via `filepath.Clean()`, enforces a capability allowlist, and rejects potentially unsafe interpolation patterns.
+- **Dual-Manifest Version Resolution:** Supports manual updates and historical rollbacks. When a node requests its configuration, the compiler resolves the specific deployed version:
+ - If the node is running the latest version, it pulls from the in-memory registry cache.
+ - If the node is running an older/historical version, the compiler lazy-loads and caches that specific tagged manifest from the registry (e.g., `hw-sensor-tarpit-v1.0.0.json`) to perform compilation matching the node's deployed state.
+
+### 3. Domain Service Layer (`internal/services`)
+The brain of the application. Everything in `internal/services/*` is framework-agnostic.
+- Contains the actual "verbs" of the system (e.g., `ProcessHeartbeat`, `CreateNode`).
+- Handles all side effects.
+- Relies on Dependency Injection.
+
+### 4. Persistence Layer (`internal/store`)
+The data access layer.
+- **SQLite Store:** Implements the narrow interfaces required by the Services.
+- Responsible for SQL queries, transactions, and JSON marshaling into Go structs.
+- Operates in Write-Ahead Log (WAL) mode with connection pooling.
+- Contains **no** business logic.
+
+### 5. Read/Analytics Layer (`internal/projections`)
+A specialized CQRS (Command Query Responsibility Segregation) pattern used for heavy dashboard analytics.
+- Used for high-volume aggregations like Threat Velocity and Threat Severity Distributions.
+- Returns flat **DTOs** (Data Transfer Objects) derived via pure functions, preventing the frontend from traversing large data arrays.
+
+
+## Authentication Strategies
+
+HoneyWire employs a multi-tiered authentication strategy depending on the actor:
+
+1. **UI Dashboard (Humans):** Secured via short-lived Sessions and HTTP-Only, Secure, SameSite `hw_auth` Cookies. Handled via `UIAuthMiddleware`.
+2. **Sensors/Agents (Machines):** Secured via statically generated, cryptographically random API Keys (`hw_key_...`). Passed via `Authorization: Bearer` or `X-Api-Key` headers and validated by `AgentAuthMiddleware`. These are aggressively cached in memory to prevent database bottlenecks during heartbeat storms.
+3. **Dual-Auth Endpoints:** Shared endpoints (like `/api/v1/manifests`) are protected by `DualAuthMiddleware`, which attempts Bearer authentication first before falling back to Cookie validation.
diff --git a/Docs/development/maintainer-workflow.md b/Docs/development/maintainer-workflow.md
index b326922..dfa92ad 100644
--- a/Docs/development/maintainer-workflow.md
+++ b/Docs/development/maintainer-workflow.md
@@ -112,6 +112,9 @@ git tag sensor/file-canary/v1.2.0
git push origin sensor/file-canary/v1.2.0
```
+> [!WARNING]
+> **Immutability Rule:** In standard GitOps workflows, release tags are immutable. If you push `v1.2.0`, realize it's broken, and rollback your fleet, you **must not** overwrite the `v1.2.0` tag with a fix. Doing so causes cache poisoning in the Hub backend and Docker registries. Always bump the version (e.g. `v1.2.1`) and leave the rolled-back tag untouched.
+
### Step 3: CI Handles the Rest
The `publish-sensor-registry` Gitea Action will:
1. Read `Sensors/official/FileCanary/file-canary.json` at the tagged commit
@@ -125,5 +128,7 @@ Check the `registry-pages` branch to confirm:
- `index.json` lists the new version
- The `latest` field points to the new version
-### Step 5: Dashboard Sync
-Refresh your HoneyWire dashboard or wait for the automatic UI sync. The Hub's event-driven catalog hook will instantly detect the registry mutation, generate the new cryptographic hashes for your nodes, lock them into 'Pending Sync' state, and immediately broadcast the update to all active UI sessions without latency. You do not need to wait for a 5-minute background loop.
+### Step 5: Dashboard Sync & Manual Upgrades
+Refresh your HoneyWire dashboard or wait for the automatic UI sync. The Hub's event-driven catalog hook will instantly detect the registry mutation and compare it against deployed sensors.
+
+Instead of forcefully upgrading production edge nodes automatically, the Hub will flag nodes with an **"Update Available"** indicator. Users must manually trigger the `/api/v1/nodes/{id}/upgrade` endpoint (via the UI) to instruct the node to pull the new version schema and execute a compose restart.
diff --git a/Hub/internal/api/nodes.go b/Hub/internal/api/nodes.go
index 081701d..f7a4c2a 100644
--- a/Hub/internal/api/nodes.go
+++ b/Hub/internal/api/nodes.go
@@ -168,6 +168,19 @@ func (h *NodeHandler) EditNodeSensor(w http.ResponseWriter, r *http.Request) {
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
}
+// UpgradeNodeSensor handles POST /api/v1/nodes/{id}/sensors/{sensorId}/upgrade
+func (h *NodeHandler) UpgradeNodeSensor(w http.ResponseWriter, r *http.Request) {
+ nodeID := chi.URLParam(r, "nodeId")
+ sensorID := chi.URLParam(r, "sensorId")
+
+ if err := h.service.UpgradeSensor(nodeID, sensorID); err != nil {
+ RespondError(w, "Failed to upgrade sensor: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ SendJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "Sensor upgraded, node pending sync"})
+}
+
// DeleteNodeSensor handles DELETE /api/v1/nodes/{id}/sensors/{sensorId}
func (h *NodeHandler) DeleteNodeSensor(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "nodeId")
@@ -193,6 +206,18 @@ func (h *NodeHandler) DeleteNode(w http.ResponseWriter, r *http.Request) {
SendJSON(w, http.StatusOK, map[string]string{"status": "success"})
}
+// UpgradeNode handles POST /api/v1/nodes/{id}/upgrade
+func (h *NodeHandler) UpgradeNode(w http.ResponseWriter, r *http.Request) {
+ nodeID := chi.URLParam(r, "nodeId")
+
+ if err := h.service.UpgradeNode(nodeID); err != nil {
+ RespondError(w, "Failed to upgrade node: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ SendJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "Node upgraded, pending sync"})
+}
+
// GetCurrentNode handles GET /api/v1/nodes/me
// Used by wizard agents authenticated via Bearer token
func (h *NodeHandler) GetCurrentNode(w http.ResponseWriter, r *http.Request) {
diff --git a/Hub/internal/api/router.go b/Hub/internal/api/router.go
index 3b504ab..2dbb511 100644
--- a/Hub/internal/api/router.go
+++ b/Hub/internal/api/router.go
@@ -70,11 +70,13 @@ func SetupRouter(cfg RouterConfig) (*chi.Mux, error) {
r.Get("/api/v1/nodes", cfg.Nodes.GetNodes)
r.Get("/api/v1/nodes/{nodeId}", cfg.Nodes.GetNodeDetails)
r.Patch("/api/v1/nodes/{nodeId}", cfg.Nodes.UpdateNode)
+ r.Post("/api/v1/nodes/{nodeId}/upgrade", cfg.Nodes.UpgradeNode)
r.Delete("/api/v1/nodes/{nodeId}", cfg.Nodes.DeleteNode)
// --- Sensor Management ---
r.Post("/api/v1/nodes/{nodeId}/sensors", cfg.Nodes.AddNodeSensor)
r.Put("/api/v1/nodes/{nodeId}/sensors/{sensorId}", cfg.Nodes.EditNodeSensor)
+ r.Post("/api/v1/nodes/{nodeId}/sensors/{sensorId}/upgrade", cfg.Nodes.UpgradeNodeSensor)
r.Delete("/api/v1/nodes/{nodeId}/sensors/{sensorId}", cfg.Nodes.DeleteNodeSensor)
r.Patch("/api/v1/nodes/{nodeId}/sensors/{sensorId}/silence", cfg.Sensors.ToggleSilence)
@@ -112,7 +114,10 @@ func SetupRouter(cfg RouterConfig) (*chi.Mux, error) {
r.Post("/api/v1/event", cfg.Events.ReceiveEvent)
})
- r.With(DualAuthMiddleware(cfg.SessionValidator, cfg.NodeAuthenticator, rateLimiter)).Get("/api/v1/manifests", cfg.Sensors.GetManifests)
+ r.With(DualAuthMiddleware(cfg.SessionValidator, cfg.NodeAuthenticator, rateLimiter)).Group(func(r chi.Router) {
+ r.Get("/api/v1/manifests", cfg.Sensors.GetManifests)
+ r.Get("/api/v1/manifests/{sensorId}/versions", cfg.Sensors.GetSpecificManifest)
+ })
// --- Serve the Vue Frontend ---
distFS, err := fs.Sub(ui.StaticFiles, "dist")
diff --git a/Hub/internal/api/sensors.go b/Hub/internal/api/sensors.go
index f9cd0da..25befdd 100644
--- a/Hub/internal/api/sensors.go
+++ b/Hub/internal/api/sensors.go
@@ -120,3 +120,22 @@ func (h *SensorHandler) GetManifests(w http.ResponseWriter, r *http.Request) {
// nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter
w.Write(body)
}
+
+// GetSpecificManifest fetches the exact historical manifest for a specific version.
+func (h *SensorHandler) GetSpecificManifest(w http.ResponseWriter, r *http.Request) {
+ sensorID := chi.URLParam(r, "sensorId")
+ version := r.URL.Query().Get("version")
+
+ if sensorID == "" || version == "" {
+ RespondError(w, "sensorId and version are required", http.StatusBadRequest)
+ return
+ }
+
+ manifest, err := h.composeService.FetchSpecificManifest(sensorID, version)
+ if err != nil {
+ RespondError(w, "Failed to fetch manifest version: "+err.Error(), http.StatusNotFound)
+ return
+ }
+
+ SendJSON(w, http.StatusOK, manifest)
+}
diff --git a/Hub/internal/compose/COMPOSE_ARCHITECTURE.md b/Hub/internal/compose/COMPOSE_ARCHITECTURE.md
index bb37b7e..0bd5746 100644
--- a/Hub/internal/compose/COMPOSE_ARCHITECTURE.md
+++ b/Hub/internal/compose/COMPOSE_ARCHITECTURE.md
@@ -17,7 +17,11 @@ The architecture is driven by the principle of **Secure Defaults by Inversion**.
- Blocks untrusted interpolation patterns in fields that are not executed through a safe templating engine.
3. **Secure Environment Composition (`env.go`)**
The `BuildEnv` pipeline ensures isolated priority overrides. Manifest defaults are loaded, overridden safely by user vars (dropping any attempting to modify forbidden environment fields), and ultimately superseded by statically defined, system-injected constants (e.g., `HW_HUB_KEY`, `HW_SENSOR_ID`).
-4. **The Builder (`builder.go`)**
+4. **Versioning and Dual-Manifest Resolution**
+ Because nodes now support manual rather than automatic upgrades, a node may run a deprecated legacy version of a sensor. To prevent breaking changes during compose regeneration, the compiler relies on dual-manifest resolution:
+ - **Latest Catalog:** Warmed into cache on startup via `index.json`.
+ - **Historical Schemas:** Lazy-loaded and permanently cached via `FetchSpecificManifest` (e.g. `hw-sensor-tarpit-v1.0.0.json`) when requested by a legacy node.
+5. **The Builder (`builder.go`)**
The `BuildService` routine converts the strictly-validated models into `ComposeFile` structs.
- **Immutable Sandboxing**: Unconditionally forces `ReadOnly: true`, `CapDrop: ["ALL"]`, and `SecurityOpt: ["no-new-privileges:true"]`.
- Structural templating handles file-bind expansions inherently rather than executing string replacement interpolations on manifest strings.
diff --git a/Hub/internal/services/compose/service.go b/Hub/internal/services/compose/service.go
index 71f86ed..d01a666 100644
--- a/Hub/internal/services/compose/service.go
+++ b/Hub/internal/services/compose/service.go
@@ -69,7 +69,7 @@ func (s *Service) FetchManifestBytes(currentHubVersion string) ([]byte, error) {
func (s *Service) fetchStrictCatalogManifests(currentHubVersion string) ([]models.SensorManifest, error) {
registryURL, err := s.store.GetConfigValue("registry_url")
if err != nil || registryURL == "" {
- registryURL = "https://raw.githubusercontent.com/andreicscs/HoneyWire/registry-pages"
+ return nil, fmt.Errorf("registry_url is not configured in database")
}
if err := s.catalog.RefreshIndex(); err != nil {
@@ -131,6 +131,51 @@ func (s *Service) fetchStrictCatalogManifests(currentHubVersion string) ([]model
return result, nil
}
+func (s *Service) FetchSpecificManifest(sensorID, targetVersion string) (*models.SensorManifest, error) {
+ cleanVersion := strings.TrimPrefix(targetVersion, "v")
+
+ registryURL, err := s.store.GetConfigValue("registry_url")
+ if err != nil || registryURL == "" {
+ return nil, fmt.Errorf("registry_url is not configured in database")
+ }
+
+ sensorName := strings.TrimPrefix(sensorID, "hw-sensor-")
+ manifestURL := fmt.Sprintf("%s/%s-v%s.json", strings.TrimRight(registryURL, "/"), sensorName, cleanVersion)
+
+ cacheKey := sensorID + "-v" + cleanVersion
+ s.mu.RLock()
+ cached, ok := s.cache[cacheKey]
+ s.mu.RUnlock()
+
+ if ok {
+ return &cached, nil
+ }
+
+ mResp, fetchErr := http.Get(manifestURL)
+ if fetchErr != nil {
+ log.Printf("[ERROR] FetchSpecificManifest network error: %v", fetchErr)
+ return nil, fetchErr
+ }
+ defer mResp.Body.Close()
+
+ if mResp.StatusCode != 200 {
+ log.Printf("[ERROR] FetchSpecificManifest received HTTP %d from %s", mResp.StatusCode, manifestURL)
+ return nil, fmt.Errorf("unexpected status %d", mResp.StatusCode)
+ }
+
+ var specific models.SensorManifest
+ if dErr := json.NewDecoder(mResp.Body).Decode(&specific); dErr != nil {
+ log.Printf("[ERROR] FetchSpecificManifest failed to decode JSON: %v", dErr)
+ return nil, dErr
+ }
+
+ s.mu.Lock()
+ s.cache[cacheKey] = specific
+ s.mu.Unlock()
+
+ return &specific, nil
+}
+
// --- GENERATION LOGIC ---
func (s *Service) GetNodeCompose(token, hostFallback string, currentHubVersion string) ([]byte, error) {
@@ -161,25 +206,20 @@ func (s *Service) GetNodeCompose(token, hostFallback string, currentHubVersion s
_ = s.store.ApplyNodeRevision(nodeID, effectiveRevision)
}
- manifests, fetchErr := s.fetchStrictCatalogManifests(currentHubVersion)
- if fetchErr != nil {
- log.Printf("[ERROR] fetchStrictCatalogManifests failed: %v", fetchErr)
- return nil, fmt.Errorf("failed_to_fetch")
- }
-
- manifestByID := make(map[string]models.SensorManifest)
- for _, m := range manifests {
- manifestByID[m.ID] = m
- }
-
var finalCompose composeEngine.ComposeFile
for _, sensor := range nodeDetails.InstalledSensors {
- manifest, ok := manifestByID[sensor.ID]
- if !ok {
- log.Printf("[WARNING] Manifest for sensor %s not found in catalog, skipping.", sensor.ID)
+ targetVersion := sensor.DeployedVersion
+ if targetVersion == "" {
+ targetVersion, _ = s.catalog.GetLatestCompatibleVersion(sensor.ID, currentHubVersion)
+ }
+
+ manifestPtr, err := s.FetchSpecificManifest(sensor.ID, targetVersion)
+ if err != nil || manifestPtr == nil {
+ log.Printf("[WARNING] Manifest for sensor %s (v%s) not found in catalog, skipping.", sensor.ID, targetVersion)
continue
}
+ manifest := *manifestPtr
// The Hub will strictly wait for evaluateNodeSyncState to verify the new hash before considering this version deployed!
if valErr := security.ValidateManifest(manifest); valErr != nil {
diff --git a/Hub/internal/services/node/service.go b/Hub/internal/services/node/service.go
index a359561..9debb38 100644
--- a/Hub/internal/services/node/service.go
+++ b/Hub/internal/services/node/service.go
@@ -8,6 +8,7 @@ import (
"log"
"sort"
"time"
+ "fmt"
"github.com/honeywire/hub/internal/catalog"
"github.com/honeywire/hub/internal/models"
@@ -127,11 +128,6 @@ func (s *Service) GetNodes() ([]models.Node, error) {
if err == nil && latest != "" {
deployedVer := sensor.DeployedVersion
if deployedVer != latest {
- newHash := GenerateRevisionHash(nodes[i].InstalledSensors, s.catalog, models.HubVersion)
- if nodes[i].ActiveRevision != "" && nodes[i].ActiveRevision == newHash {
- _ = s.store.SetNodeSensorDeployedVersion(nodes[i].ID, sensor.ID, latest)
- continue
- }
nodes[i].HasUpdateAvailable = true
break
}
@@ -156,12 +152,6 @@ func (s *Service) GetNodeDetails(nodeID string) (*models.Node, error) {
if err == nil && latest != "" {
deployedVer := sensor.DeployedVersion
if deployedVer != latest {
- newHash := GenerateRevisionHash(node.InstalledSensors, s.catalog, models.HubVersion)
- if node.ActiveRevision != "" && node.ActiveRevision == newHash {
- _ = s.store.SetNodeSensorDeployedVersion(node.ID, sensor.ID, latest)
- node.InstalledSensors[i].DeployedVersion = latest
- continue
- }
node.InstalledSensors[i].UpdateAvailable = true
node.HasUpdateAvailable = true
}
@@ -188,6 +178,46 @@ func (s *Service) EditSensor(nodeID, sensorID, customName string, configValues m
return nil
}
+func (s *Service) UpgradeSensor(nodeID, sensorID string) error {
+ if s.catalog == nil {
+ return fmt.Errorf("catalog unavailable")
+ }
+ latest, err := s.catalog.GetLatestCompatibleVersion(sensorID, models.HubVersion)
+ if err != nil || latest == "" {
+ return fmt.Errorf("no update available")
+ }
+ if err := s.store.SetNodeSensorDeployedVersion(nodeID, sensorID, latest); err != nil {
+ return err
+ }
+ s.evaluateNodeSyncState(nodeID)
+ return nil
+}
+
+func (s *Service) UpgradeNode(nodeID string) error {
+ if s.catalog == nil {
+ return fmt.Errorf("catalog unavailable")
+ }
+ nodeDetails, err := s.store.GetNodeDetails(nodeID)
+ if err != nil {
+ return err
+ }
+
+ updatedAny := false
+ for _, sensor := range nodeDetails.InstalledSensors {
+ latest, err := s.catalog.GetLatestCompatibleVersion(sensor.ID, models.HubVersion)
+ if err == nil && latest != "" && sensor.DeployedVersion != latest {
+ _ = s.store.SetNodeSensorDeployedVersion(nodeID, sensor.ID, latest)
+ updatedAny = true
+ }
+ }
+
+ if updatedAny {
+ s.evaluateNodeSyncState(nodeID)
+ }
+
+ return nil
+}
+
func (s *Service) DeleteSensor(nodeID, sensorID string) error {
if err := s.store.RemoveNodeSensor(nodeID, sensorID); err != nil {
return err
@@ -213,9 +243,11 @@ func (s *Service) evaluateNodeSyncState(nodeID string) {
if s.catalog != nil {
for _, sensor := range nodeDetails.InstalledSensors {
- latest, _ := s.catalog.GetLatestCompatibleVersion(sensor.ID, models.HubVersion)
- if latest != "" && sensor.DeployedVersion != latest {
- _ = s.store.SetNodeSensorDeployedVersion(nodeID, sensor.ID, latest)
+ if sensor.DeployedVersion == "" {
+ latest, _ := s.catalog.GetLatestCompatibleVersion(sensor.ID, models.HubVersion)
+ if latest != "" {
+ _ = s.store.SetNodeSensorDeployedVersion(nodeID, sensor.ID, latest)
+ }
}
}
}
@@ -236,11 +268,11 @@ func GenerateRevisionHash(sensors []models.NodeSensor, catSvc *catalog.Service,
}
var configs []sensorConfig
for _, s := range sensors {
- latestVersion := ""
- if catSvc != nil {
- latestVersion, _ = catSvc.GetLatestCompatibleVersion(s.ID, currentHubVersion)
+ targetVersion := s.DeployedVersion
+ if targetVersion == "" && catSvc != nil {
+ targetVersion, _ = catSvc.GetLatestCompatibleVersion(s.ID, currentHubVersion)
}
- configs = append(configs, sensorConfig{ID: s.ID, Version: latestVersion, EnvVars: s.EnvVars})
+ configs = append(configs, sensorConfig{ID: s.ID, Version: targetVersion, EnvVars: s.EnvVars})
}
sort.Slice(configs, func(i, j int) bool { return configs[i].ID < configs[j].ID })
b, _ := json.Marshal(configs)
diff --git a/Hub/internal/store/sqlite.go b/Hub/internal/store/sqlite.go
index 0329066..5b7d7a3 100644
--- a/Hub/internal/store/sqlite.go
+++ b/Hub/internal/store/sqlite.go
@@ -136,6 +136,7 @@ func initializeDefaultConfigTx(tx *sql.Tx) error {
"auto_purge_days": "0",
"siem_address": "",
"siem_protocol": "tcp",
+ // TODO: Add "registry_url": "https://raw.githubusercontent.com/andreicscs/HoneyWire/registry-pages" default
}
stmt, err := tx.Prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)")
diff --git a/Hub/ui/src/components/fleetmanagement/FleetNodeWidget.vue b/Hub/ui/src/components/fleetmanagement/FleetNodeWidget.vue
index 0184f62..d2a89b1 100644
--- a/Hub/ui/src/components/fleetmanagement/FleetNodeWidget.vue
+++ b/Hub/ui/src/components/fleetmanagement/FleetNodeWidget.vue
@@ -15,7 +15,8 @@ const emit = defineEmits<{
(e: 'update', updates: any): void,
(e: 'silence'): void,
(e: 'delete'): void,
- (e: 'openDetail'): void
+ (e: 'openDetail'): void,
+ (e: 'upgradeAll'): void
}>()
const { copiedStates, handleCopy } = useClipboard() as any
@@ -114,6 +115,10 @@ const removeTag = (index: number | string) => {
{{ node.isSilenced ? 'Unsilence Node' : 'Silence Node' }}
+