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
24 changes: 24 additions & 0 deletions docs/design/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,30 @@ hosts=$(echo '{"service": "hosts"}' | /usr/bin/api-cli ns.dashboard counter --da
echo "Known hosts: $hosts"
```

## Metrics and alerts proxies

The API server exposes reverse proxies to the local VictoriaMetrics and vmalert APIs, for use by
the authenticated UI. Routes live inside the JWT-protected group: authenticated, rate-limited,
with `Authorization`/`Cookie` headers stripped before forwarding to the backend. Registered
routes accept any HTTP method; unregistered paths return 404. If the backend is unreachable or
slow to respond, the proxy returns `502`.

| Route | Backend |
|---|---|
| `/api/metrics/query` | VictoriaMetrics `/api/v1/query` |
| `/api/metrics/query_range` | VictoriaMetrics `/api/v1/query_range` |
| `/api/alerts/alerts` | vmalert `/api/v1/alerts` |

Backend addresses are configured via `VICTORIA_METRICS_URL`/`VMALERT_URL` environment variables
in `ns-api-server.initd`, read from the `victoria-metrics.main.http_listen_addr`/
`vmalert.main.http_listen_addr` UCI options (default `http://127.0.0.1:8428`/
`http://127.0.0.1:8082`). The service restarts on `victoria-metrics`/`vmalert` config changes.

Example:
```
curl -s -H 'Authorization: Bearer <jwt_token>' -k 'https://localhost/api/metrics/query?query=up'
```

## Conventions

APIs are invoked using the [api-server](../packages/ns-api-server).
Expand Down
2 changes: 1 addition & 1 deletion packages/ns-api-server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ define Package/ns-api-server
CATEGORY:=NethSecurity
TITLE:=NethSecurity REST API server
URL:=https://github.com/NethServer/nethsecurity-api
DEPENDS:=$(GO_ARCH_DEPENDS)
DEPENDS:=$(GO_ARCH_DEPENDS) +victoria-metrics
endef

define Package/ns-api-server/description
Expand Down
26 changes: 26 additions & 0 deletions packages/ns-api-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ NS API server, see [source code](https://github.com/NethServer/nethsecurity-api)

The server is configured to listen on `127.0.0.1:8090`.

## Metrics and alerts proxies

Reverse proxies to the local VictoriaMetrics and vmalert APIs, for the authenticated UI. Live
inside the JWT-protected group: authenticated, rate-limited, `Authorization`/`Cookie` headers
stripped before forwarding. Registered routes accept any HTTP method; unregistered paths 404.
Backend unreachable or slow → `502`.

| Route | Backend |
|---|---|
| `/api/metrics/query` | VictoriaMetrics `/api/v1/query` |
| `/api/metrics/query_range` | VictoriaMetrics `/api/v1/query_range` |
| `/api/alerts/alerts` | vmalert `/api/v1/alerts` |

Backend addresses: `VICTORIA_METRICS_URL` / `VMALERT_URL` env vars in `ns-api-server.initd`,
read from `victoria-metrics.main.http_listen_addr` / `vmalert.main.http_listen_addr` (default
`http://127.0.0.1:8428` / `http://127.0.0.1:8082`). Restarts on `victoria-metrics`/`vmalert`
config change (`service_triggers`).

Example:

```
GET /api/metrics/query?query=up
GET /api/metrics/query_range?query=<promql>&start=<ts>&end=<ts>&step=<dur>
GET /api/alerts/alerts
```

## Rate limiting

The server applies a generous global per-client-IP rate limit as a coarse safety net across
Expand Down
25 changes: 24 additions & 1 deletion packages/ns-api-server/files/ns-api-server.initd
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ start_service() {
echo "$SECRET_JWT" > ${WORK_DIR}/secret_jwt
fi

# read the config address for proxy fallback
config_load victoria-metrics
local vm_http_listen_addr
config_get vm_http_listen_addr main http_listen_addr "127.0.0.1:8428"
# a host-less "addr" (e.g. ":8428") binds all interfaces; reach it via loopback
case "$vm_http_listen_addr" in
:*) vm_http_listen_addr="127.0.0.1${vm_http_listen_addr}" ;;
esac

config_load vmalert
local vmalert_http_listen_addr
config_get vmalert_http_listen_addr main http_listen_addr "127.0.0.1:8082"
case "$vmalert_http_listen_addr" in
:*) vmalert_http_listen_addr="127.0.0.1${vmalert_http_listen_addr}" ;;
esac

procd_set_param env GIN_MODE=release \
LISTEN_ADDRESS=127.0.0.1:8090 \
SECRET_JWT="${SECRET_JWT}" \
Expand All @@ -47,6 +63,8 @@ start_service() {
TOKENS_DIR=${TOKENS_DIR} \
UPLOAD_FILE_PATH=${UPLOAD_FILE_PATH} \
UPLOAD_FILE_MAX_SIZE=${UPLOAD_FILE_MAX_SIZE} \
VICTORIA_METRICS_URL="http://${vm_http_listen_addr}" \
VMALERT_URL="http://${vmalert_http_listen_addr}" \
GLOBAL_RATE_LIMIT_AVERAGE=${GLOBAL_RATE_LIMIT_AVERAGE} \
GLOBAL_RATE_LIMIT_BURST=${GLOBAL_RATE_LIMIT_BURST}

Expand All @@ -57,6 +75,11 @@ start_service() {
procd_close_instance
}

service_triggers() {
procd_add_reload_trigger victoria-metrics vmalert
}

reload_service() {
procd_send_signal ns-api-server '*' USR1
stop
start
}
15 changes: 15 additions & 0 deletions packages/ns-api-server/files/src/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type Configuration struct {
UploadFilePath string `json:"upload_file_path"`
DownloadFilePath string `json:"download_file_path"`

VictoriaMetricsURL string `json:"victoria_metrics_url"`
VMAlertURL string `json:"vmalert_url"`

// Generous global per-IP rate limit applied to every API route as a coarse
// safety net; 0 disables it
GlobalRateLimitAverage int `json:"global_rate_limit_average"`
Expand Down Expand Up @@ -97,6 +100,18 @@ func Init() {
Config.UploadFileMaxSize = 32
}

if os.Getenv("VICTORIA_METRICS_URL") != "" {
Config.VictoriaMetricsURL = os.Getenv("VICTORIA_METRICS_URL")
} else {
Config.VictoriaMetricsURL = "http://127.0.0.1:8428"
}

if os.Getenv("VMALERT_URL") != "" {
Config.VMAlertURL = os.Getenv("VMALERT_URL")
} else {
Config.VMAlertURL = "http://127.0.0.1:8082"
}

if v, err := strconv.Atoi(os.Getenv("GLOBAL_RATE_LIMIT_AVERAGE")); err == nil {
Config.GlobalRateLimitAverage = v
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,60 @@ import (
"testing"
)

func TestInitVictoriaMetricsURLDefault(t *testing.T) {
os.Unsetenv("VICTORIA_METRICS_URL")
os.Setenv("SECRET_JWT", "test-secret")
os.Setenv("SECRETS_DIR", "/tmp/secrets")
os.Setenv("TOKENS_DIR", "/tmp/tokens")

Init()

if Config.VictoriaMetricsURL != "http://127.0.0.1:8428" {
t.Fatalf("VictoriaMetricsURL = %q, want %q", Config.VictoriaMetricsURL, "http://127.0.0.1:8428")
}
}

func TestInitVictoriaMetricsURLFromEnv(t *testing.T) {
os.Setenv("SECRET_JWT", "test-secret")
os.Setenv("SECRETS_DIR", "/tmp/secrets")
os.Setenv("TOKENS_DIR", "/tmp/tokens")
os.Setenv("VICTORIA_METRICS_URL", "http://127.0.0.1:9428")
defer os.Unsetenv("VICTORIA_METRICS_URL")

Init()

if Config.VictoriaMetricsURL != "http://127.0.0.1:9428" {
t.Fatalf("VictoriaMetricsURL = %q, want %q", Config.VictoriaMetricsURL, "http://127.0.0.1:9428")
}
}

func TestInitVMAlertURLDefault(t *testing.T) {
os.Unsetenv("VMALERT_URL")
os.Setenv("SECRET_JWT", "test-secret")
os.Setenv("SECRETS_DIR", "/tmp/secrets")
os.Setenv("TOKENS_DIR", "/tmp/tokens")

Init()

if Config.VMAlertURL != "http://127.0.0.1:8082" {
t.Fatalf("VMAlertURL = %q, want %q", Config.VMAlertURL, "http://127.0.0.1:8082")
}
}

func TestInitVMAlertURLFromEnv(t *testing.T) {
os.Setenv("SECRET_JWT", "test-secret")
os.Setenv("SECRETS_DIR", "/tmp/secrets")
os.Setenv("TOKENS_DIR", "/tmp/tokens")
os.Setenv("VMALERT_URL", "http://127.0.0.1:9082")
defer os.Unsetenv("VMALERT_URL")

Init()

if Config.VMAlertURL != "http://127.0.0.1:9082" {
t.Fatalf("VMAlertURL = %q, want %q", Config.VMAlertURL, "http://127.0.0.1:9082")
}
}

func TestInitGlobalRateLimitDefaults(t *testing.T) {
os.Unsetenv("GLOBAL_RATE_LIMIT_AVERAGE")
os.Unsetenv("GLOBAL_RATE_LIMIT_BURST")
Expand Down
9 changes: 8 additions & 1 deletion packages/ns-api-server/files/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
package main

import (
"github.com/NethServer/nethsecurity-api/sudo"
"io"
"net/http"

"github.com/NethServer/nethsecurity-api/sudo"

"github.com/fatih/structs"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/gzip"
Expand Down Expand Up @@ -115,6 +116,12 @@ func main() {
authGroup.POST("/files", methods.UploadFile)
authGroup.DELETE("/files/:filename", methods.DeleteFile)

// reverse proxies to VictoriaMetrics/vmalert
victoriaMetricsProxy := methods.NewReverseProxy(configuration.Config.VictoriaMetricsURL)
authGroup.Any("/metrics/query", methods.ProxyTo(victoriaMetricsProxy, "/api/v1/query"))
authGroup.Any("/metrics/query_range", methods.ProxyTo(victoriaMetricsProxy, "/api/v1/query_range"))
authGroup.Any("/alerts/alerts", methods.ProxyTo(methods.NewReverseProxy(configuration.Config.VMAlertURL), "/api/v1/alerts"))

// handle missing endpoint
router.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, structs.Map(response.StatusNotFound{
Expand Down
58 changes: 58 additions & 0 deletions packages/ns-api-server/files/src/methods/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: GPL-2.0-only
*/

package methods

import (
"encoding/json"
"net/http"
"net/http/httputil"
"net/url"
"time"

"github.com/NethServer/nethsecurity-api/logs"
"github.com/NethServer/nethsecurity-api/response"
"github.com/fatih/structs"
"github.com/gin-gonic/gin"
)

// NewReverseProxy builds a reverse proxy to a local, unauthenticated backend
func NewReverseProxy(rawBaseURL string) *httputil.ReverseProxy {
target, err := url.Parse(rawBaseURL)
if err != nil {
logs.Logs.Println("[CRITICAL][PROXY] invalid backend URL:", rawBaseURL, err.Error())
}

proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = &http.Transport{
ResponseHeaderTimeout: 10 * time.Second,
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
logs.Logs.Println("[ERROR][PROXY] backend unreachable:", err.Error())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(structs.Map(response.StatusBadGateway{
Code: 502,
Message: "bad gateway",
Data: nil,
}))
}

return proxy
}

// ProxyTo returns a handler that forwards the request to proxy at the given
// fixed backendPath, passing the query string through unchanged. It is meant
// to be registered against a single hardcoded path (e.g. authGroup.GET
// ("/metrics/query", ProxyTo(...))) - it does not accept caller-controlled
// path segments.
func ProxyTo(proxy *httputil.ReverseProxy, backendPath string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Header.Del("Authorization")
c.Request.Header.Del("Cookie")
c.Request.URL.Path = backendPath
proxy.ServeHTTP(c.Writer, c.Request)
}
}
Loading
Loading