Skip to content
Closed
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,linux/arm64
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
127 changes: 126 additions & 1 deletion backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ tags:
description: Collect service system management and inventory collection
- name: Collect - Rebranding
description: Collect service rebranding endpoints for systems

- name: Collect - Metrics
description: Collect service metrics proxy to Mimir (Prometheus remote_write and query)
security:
- BearerAuth: []

Expand Down Expand Up @@ -8231,3 +8232,127 @@ paths:
format: binary
'404':
$ref: '#/components/responses/NotFound'

# ===========================================
# METRICS ENDPOINTS (Collect - Mimir Proxy)
# ===========================================

/api/services/mimir/{path}:
parameters:
- name: path
in: path
required: true
schema:
type: string
description: Wildcard path forwarded to Mimir (e.g. `api/v1/push`, `prometheus/api/v1/query`)
get:
operationId: mimirProxyGet
tags:
- Collect - Metrics
summary: Proxy GET request to Mimir
description: |
Authenticates the system using HTTP Basic Auth (`system_key` as username,
`system_secret` as password), injects the `X-Scope-OrgID` header with the
system's `organization_id` for multi-tenant isolation, and reverse-proxies
the request to Mimir. Typical use: Grafana PromQL queries via
`GET /api/services/mimir/prometheus/api/v1/query`.
security:
- BasicAuth: []
responses:
'200':
description: Proxied response from Mimir
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
post:
operationId: mimirProxyPost
tags:
- Collect - Metrics
summary: Proxy POST request to Mimir
description: |
Authenticates the system using HTTP Basic Auth (`system_key` as username,
`system_secret` as password), injects the `X-Scope-OrgID` header with the
system's `organization_id` for multi-tenant isolation, and reverse-proxies
the request to Mimir. Primary use case: Prometheus `remote_write` ingestion
via `POST /api/services/mimir/api/v1/push` from NethServer systems.
security:
- BasicAuth: []
requestBody:
description: Request body forwarded as-is to Mimir (e.g. Prometheus remote_write protobuf payload)
required: false
content:
application/x-protobuf:
schema:
type: string
format: binary
application/json:
schema:
type: object
responses:
'200':
description: Proxied response from Mimir
'204':
description: No content β€” Mimir acknowledged the write with no response body
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
put:
operationId: mimirProxyPut
tags:
- Collect - Metrics
summary: Proxy PUT request to Mimir
description: |
Authenticates the system using HTTP Basic Auth (`system_key` as username,
`system_secret` as password), injects the `X-Scope-OrgID` header with the
system's `organization_id` for multi-tenant isolation, and reverse-proxies
the request to Mimir.
security:
- BasicAuth: []
requestBody:
description: Request body forwarded as-is to Mimir
required: false
content:
application/json:
schema:
type: object
responses:
'200':
description: Proxied response from Mimir
'204':
description: No content
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
operationId: mimirProxyDelete
tags:
- Collect - Metrics
summary: Proxy DELETE request to Mimir
description: |
Authenticates the system using HTTP Basic Auth (`system_key` as username,
`system_secret` as password), injects the `X-Scope-OrgID` header with the
system's `organization_id` for multi-tenant isolation, and reverse-proxies
the request to Mimir.
security:
- BasicAuth: []
responses:
'200':
description: Proxied response from Mimir
'204':
description: No content
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'

3 changes: 3 additions & 0 deletions collect/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ REDIS_URL=redis://localhost:6379
#CIRCUIT_BREAKER_THRESHOLD=10
#CIRCUIT_BREAKER_TIMEOUT=60s

# Mimir metrics storage
#MIMIR_URL=http://localhost:9009

# Logging configuration
#LOG_LEVEL=info
#LOG_FORMAT=json
Expand Down
10 changes: 10 additions & 0 deletions collect/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ type Configuration struct {

// Heartbeat monitoring configuration
HeartbeatTimeoutMinutes int `json:"heartbeat_timeout_minutes"`

// Mimir configuration
MimirURL string `json:"mimir_url"`
}

var Config = Configuration{}
Expand Down Expand Up @@ -161,6 +164,13 @@ func Init() {
// Heartbeat monitoring configuration
Config.HeartbeatTimeoutMinutes = parseIntWithDefault("HEARTBEAT_TIMEOUT_MINUTES", 10)

// 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
16 changes: 14 additions & 2 deletions collect/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@ func main() {
// Add security monitoring middleware
router.Use(logger.SecurityMiddleware())

// Add compression
router.Use(gzip.Gzip(gzip.DefaultCompression))
// Add compression (excluding Mimir proxy endpoints to avoid double-compression)
router.Use(gzip.Gzip(
gzip.DefaultCompression,
gzip.WithExcludedPathsRegexs([]string{"^/api/services/mimir"}),
))

// CORS configuration in debug mode
if gin.Mode() == gin.DebugMode {
Expand Down Expand Up @@ -158,6 +161,15 @@ func main() {
systemsGroup.GET("/rebranding/:product_id/:asset", methods.GetSystemRebrandingAsset)
}

// ===========================================
// EXTERNAL SERVICES PROXY
// ===========================================
servicesGroup := api.Group("/services", middleware.BasicAuthMiddleware())
{
mimirProxy := servicesGroup.Group("/mimir")
mimirProxy.Any("/*path", methods.ProxyMimir)
}

// Handle missing endpoints
router.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, response.NotFound("api not found", nil))
Expand Down
108 changes: 108 additions & 0 deletions collect/methods/mimir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: AGPL-3.0-or-later
*/

package methods

import (
"bytes"
"database/sql"
"fmt"
"io"
"net/http"

"github.com/gin-gonic/gin"

"github.com/nethesis/my/collect/configuration"
"github.com/nethesis/my/collect/database"
"github.com/nethesis/my/collect/logger"
"github.com/nethesis/my/collect/response"
)

// ProxyMimir handles ANY /api/services/mimir/* β€” the BasicAuthMiddleware has
// already validated system credentials and placed system_id in the context.
// This handler resolves the organization_id, sets X-Scope-OrgID, and
// reverse-proxies the request to Mimir with HA support across multiple instances.
func ProxyMimir(c *gin.Context) {
// Step 1: Get system_id from context (set by BasicAuthMiddleware)
systemID, ok := getAuthenticatedSystemID(c)
if !ok {
logger.Warn().Str("reason", "missing system_id in context").Msg("mimir proxy auth failed")
c.JSON(http.StatusUnauthorized, response.Unauthorized("unauthorized", nil))
return
}

// Step 2: Query organization_id for this system
var organizationID string
err := database.DB.QueryRow(
`SELECT organization_id FROM systems WHERE id = $1`,
systemID,
).Scan(&organizationID)

if err == sql.ErrNoRows {
logger.Warn().Str("system_id", systemID).Str("reason", "system not found").Msg("mimir proxy: system lookup failed")
c.JSON(http.StatusUnauthorized, response.Unauthorized("unauthorized", nil))
return
}
if err != nil {
logger.Error().Err(err).Str("system_id", systemID).Msg("mimir proxy: db query failed")
c.JSON(http.StatusInternalServerError, response.InternalServerError("internal server error", nil))
return
}

// Step 3: Buffer request body once so it can be replayed across retry attempts
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.Error().Err(err).Msg("mimir proxy: failed to read request body")
c.JSON(http.StatusInternalServerError, response.InternalServerError("internal server error", nil))
return
}

subPath := c.Param("path")
rawQuery := c.Request.URL.RawQuery

// Step 4: Forward request to Mimir
targetURL := fmt.Sprintf("%s%s", configuration.Config.MimirURL, subPath)
if rawQuery != "" {
targetURL += "?" + rawQuery
}

logger.Info().Str("target", targetURL).Msg("mimir proxy: forwarding request")

req, err := http.NewRequest(c.Request.Method, targetURL, bytes.NewReader(bodyBytes))
if err != nil {
logger.Error().Err(err).Str("target", targetURL).Msg("mimir proxy: failed to create upstream request")
c.JSON(http.StatusInternalServerError, response.InternalServerError("internal server error", nil))
return
}

for _, header := range []string{"Content-Type", "Content-Encoding", "Accept", "User-Agent"} {
if val := c.GetHeader(header); val != "" {
req.Header.Set(header, val)
}
}
// Remove Accept-Encoding so Mimir sends plain JSON, not gzip
req.Header.Del("Accept-Encoding")
req.Header.Set("X-Scope-OrgID", organizationID)

resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Error().Err(err).Str("target", targetURL).Msg("mimir proxy: network error")
c.JSON(http.StatusBadGateway, response.InternalServerError("mimir is unavailable", nil))
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Error().Err(err).Msg("mimir proxy: failed to close upstream response body")
}
}()

if ct := resp.Header.Get("Content-Type"); ct != "" {
c.Header("Content-Type", ct)
}
c.Status(resp.StatusCode)
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
logger.Error().Err(err).Msg("mimir proxy: error streaming response body")
}
}
Loading