Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ jobs:
context: proxy
- component: mimir
context: services/mimir
image_description: Mimir alertmanager backend for My Nethesis

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -170,7 +171,9 @@ jobs:
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: |
${{ steps.meta.outputs.labels }}
${{ matrix.image_description != '' && format('org.opencontainers.image.description={0}', matrix.image_description) || '' }}
cache-from: type=gha,scope=${{ matrix.component }}
cache-to: type=gha,mode=max,scope=${{ matrix.component }}
build-args: |
Expand Down
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ REDIS_URL=redis://localhost:6379
# System configuration
#SYSTEM_TYPES=ns8,nsec

# ===========================================
# MIMIR CONFIGURATION (Optional)
# ===========================================
#MIMIR_URL=http://localhost:9009

# ===========================================
# AUTO-DERIVED URLS (DO NOT SET MANUALLY)
# ===========================================
Expand Down
4 changes: 2 additions & 2 deletions backend/.render-build-trigger
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# This file is used to force Docker service rebuilds in PR previews
# Modify LAST_UPDATE to trigger rebuilds

LAST_UPDATE=2026-02-10T12:10:22Z
LAST_UPDATE=2026-02-26T15:22:50Z

# Instructions:
# 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-10T12:10:22Z
# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-26T15:22:50Z
# 2. Commit and push changes to trigger Docker rebuilds
9 changes: 9 additions & 0 deletions backend/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type Configuration struct {
SMTPFrom string `json:"smtp_from"`
SMTPFromName string `json:"smtp_from_name"`
SMTPTLS bool `json:"smtp_tls"`
// Mimir configuration
MimirURL string `json:"mimir_url"`
}

var Config = Configuration{}
Expand Down Expand Up @@ -196,6 +198,13 @@ func Init() {
}
Config.SMTPTLS = parseBoolWithDefault("SMTP_TLS", true)

// Mimir configuration
if mimirURL := os.Getenv("MIMIR_URL"); mimirURL != "" {
Config.MimirURL = mimirURL
} else {
Config.MimirURL = "http://localhost:9009"
}

// Log successful configuration load
logger.LogConfigLoad("env", "configuration", true, nil)
}
Expand Down
11 changes: 11 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,17 @@ func main() {
systemsGroup.GET("/:id/inventory/diffs/latest", methods.GetSystemLatestInventoryDiff) // Get latest diff
}

// ===========================================
// ALERTING - manage alert routing configuration via Mimir
// ===========================================
alertingGroup := customAuthWithAudit.Group("/alerting", middleware.RequirePermission("manage:systems"))
{
alertingGroup.POST("/config", methods.ConfigureAlerts)
alertingGroup.DELETE("/config", methods.DisableAlerts)
alertingGroup.GET("/config", methods.GetAlertingConfig)
alertingGroup.GET("/alerts", methods.GetAlerts)
}

// ===========================================
// FILTERS - For UI dropdowns
// ===========================================
Expand Down
219 changes: 219 additions & 0 deletions backend/methods/alerting.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/*
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: AGPL-3.0-or-later
*/

package methods

import (
"encoding/json"
"net/http"
"strings"

"github.com/gin-gonic/gin"

"github.com/nethesis/my/backend/configuration"
"github.com/nethesis/my/backend/helpers"
"github.com/nethesis/my/backend/models"
"github.com/nethesis/my/backend/response"
"github.com/nethesis/my/backend/services/alerting"
"github.com/nethesis/my/backend/services/local"
)

// resolveOrgID extracts the target organization ID.
// Owner/Distributor/Reseller must pass organization_id query param.
// Customer uses their own organization from JWT.
func resolveOrgID(c *gin.Context, user *models.User) (string, bool) {
orgID := c.Query("organization_id")
orgRole := strings.ToLower(user.OrgRole)

if orgRole == "customer" {
// Customer always uses their own organization
return user.OrganizationID, true
}

// Owner, Distributor, Reseller must provide organization_id
if orgID == "" {
c.JSON(http.StatusBadRequest, response.BadRequest("organization_id query parameter is required", nil))
return "", false
}

// Validate hierarchical access to the target organization
userService := local.NewUserService()
if !userService.IsOrganizationInHierarchy(orgRole, user.OrganizationID, orgID) {
c.JSON(http.StatusForbidden, response.Forbidden("access denied: organization not in your hierarchy", nil))
return "", false
}

return orgID, true
}

// ConfigureAlerts handles POST /api/alerting/config
func ConfigureAlerts(c *gin.Context) {
user, ok := helpers.GetUserFromContext(c)
if !ok {
return
}

orgID, ok := resolveOrgID(c, user)
if !ok {
return
}

var req models.AlertingConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, response.BadRequest("invalid request body: "+err.Error(), nil))
return
}

// Validate severity keys
validSeverities := map[string]bool{"critical": true, "warning": true, "info": true}
for key := range req {
if !validSeverities[key] {
c.JSON(http.StatusBadRequest, response.BadRequest("invalid severity level: "+key+". allowed: critical, warning, info", nil))
return
}
}

cfg := configuration.Config
yamlConfig, err := alerting.RenderConfig(
cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPFrom, cfg.SMTPTLS,
req,
)
if err != nil {
c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to render alertmanager config: "+err.Error(), nil))
return
}

if err := alerting.PushConfig(orgID, yamlConfig); err != nil {
c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to push config to mimir: "+err.Error(), nil))
return
}

c.JSON(http.StatusOK, response.OK("alerting configuration updated successfully", nil))
}

// DisableAlerts handles DELETE /api/alerting/config
func DisableAlerts(c *gin.Context) {
user, ok := helpers.GetUserFromContext(c)
if !ok {
return
}

orgID, ok := resolveOrgID(c, user)
if !ok {
return
}

cfg := configuration.Config
yamlConfig, err := alerting.RenderConfig(
cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPFrom, cfg.SMTPTLS,
nil,
)
if err != nil {
c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to render blackhole config: "+err.Error(), nil))
return
}

if err := alerting.PushConfig(orgID, yamlConfig); err != nil {
c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to push config to mimir: "+err.Error(), nil))
return
}

c.JSON(http.StatusOK, response.OK("all alerts disabled successfully", nil))
}

// GetAlerts handles GET /api/alerting/alerts
func GetAlerts(c *gin.Context) {
user, ok := helpers.GetUserFromContext(c)
if !ok {
return
}

orgID, ok := resolveOrgID(c, user)
if !ok {
return
}

body, err := alerting.GetAlerts(orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to fetch alerts from mimir: "+err.Error(), nil))
return
}

// Parse alerts for optional filtering
var alerts []map[string]interface{}
if err := json.Unmarshal(body, &alerts); err != nil {
// Return raw response if parsing fails
c.Data(http.StatusOK, "application/json", body)
return
}

var params models.AlertQueryParams
if err := c.ShouldBindQuery(&params); err == nil {
alerts = filterAlerts(alerts, params)
}

c.JSON(http.StatusOK, response.OK("alerts retrieved successfully", gin.H{
"alerts": alerts,
}))
}

// GetAlertingConfig handles GET /api/alerting/config
func GetAlertingConfig(c *gin.Context) {
user, ok := helpers.GetUserFromContext(c)
if !ok {
return
}

orgID, ok := resolveOrgID(c, user)
if !ok {
return
}

body, err := alerting.GetConfig(orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to fetch alerting config from mimir: "+err.Error(), nil))
return
}

c.JSON(http.StatusOK, response.OK("alerting configuration retrieved successfully", gin.H{
"config": string(body),
}))
}

// filterAlerts applies optional query filters to the alerts list
func filterAlerts(alerts []map[string]interface{}, params models.AlertQueryParams) []map[string]interface{} {
if params.State == "" && params.Severity == "" && params.SystemKey == "" {
return alerts
}

filtered := make([]map[string]interface{}, 0, len(alerts))
for _, alert := range alerts {
if params.State != "" {
if status, ok := alert["status"].(map[string]interface{}); ok {
if state, ok := status["state"].(string); ok && state != params.State {
continue
}
}
}

labels, _ := alert["labels"].(map[string]interface{})

if params.Severity != "" {
if sev, ok := labels["severity"].(string); ok && sev != params.Severity {
continue
}
}

if params.SystemKey != "" {
if sk, ok := labels["system_key"].(string); ok && sk != params.SystemKey {
continue
}
}

filtered = append(filtered, alert)
}

return filtered
}
31 changes: 31 additions & 0 deletions backend/models/alerting.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: AGPL-3.0-or-later
*/

package models

// WebhookConfig represents a named webhook receiver
type WebhookConfig struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required,url"`
}

// SeverityConfig defines email and webhook receivers for a specific severity level,
// plus optional system_key exceptions that should be excluded from notifications
type SeverityConfig struct {
Emails []string `json:"emails" binding:"required,min=1,dive,email"`
Webhooks []WebhookConfig `json:"webhooks,omitempty"`
Exceptions []string `json:"exceptions,omitempty"`
}

// AlertingConfigRequest is the JSON body for POST /api/alerting/config.
// Keys are severity levels: "critical", "warning", "info".
type AlertingConfigRequest map[string]SeverityConfig

// AlertQueryParams holds optional query filters for GET /api/alerting/alerts
type AlertQueryParams struct {
State string `form:"state"` // e.g. "firing", "pending"
Severity string `form:"severity"` // e.g. "critical", "warning", "info"
SystemKey string `form:"system_key"` // filter by system_key label
}
Loading