From 895310e7e3da4721943c9214cf102d9455f8c9ad Mon Sep 17 00:00:00 2001 From: Tommaso Bailetti Date: Mon, 20 Jul 2026 15:11:41 +0200 Subject: [PATCH 1/2] feat: added reverse proxy of monitoring endpoints --- packages/ns-api-server/Makefile | 2 +- packages/ns-api-server/README.md | 26 +++ .../ns-api-server/files/ns-api-server.initd | 25 ++- .../files/src/configuration/configuration.go | 15 ++ .../src/configuration/configuration_test.go | 54 +++++ packages/ns-api-server/files/src/main.go | 9 +- .../ns-api-server/files/src/methods/proxy.go | 58 ++++++ .../files/src/methods/proxy_test.go | 188 ++++++++++++++++++ .../files/src/response/response.go | 6 + 9 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 packages/ns-api-server/files/src/methods/proxy.go create mode 100644 packages/ns-api-server/files/src/methods/proxy_test.go diff --git a/packages/ns-api-server/Makefile b/packages/ns-api-server/Makefile index 7d96051fb..e5c74255b 100644 --- a/packages/ns-api-server/Makefile +++ b/packages/ns-api-server/Makefile @@ -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 diff --git a/packages/ns-api-server/README.md b/packages/ns-api-server/README.md index cf0e4fede..ed5440841 100644 --- a/packages/ns-api-server/README.md +++ b/packages/ns-api-server/README.md @@ -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=&start=&end=&step= +GET /api/alerts/alerts +``` + ## Rate limiting The server applies a generous global per-client-IP rate limit as a coarse safety net across diff --git a/packages/ns-api-server/files/ns-api-server.initd b/packages/ns-api-server/files/ns-api-server.initd index 54a888ae4..9376dc73a 100644 --- a/packages/ns-api-server/files/ns-api-server.initd +++ b/packages/ns-api-server/files/ns-api-server.initd @@ -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}" \ @@ -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} @@ -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 } diff --git a/packages/ns-api-server/files/src/configuration/configuration.go b/packages/ns-api-server/files/src/configuration/configuration.go index 482f575fc..8087f366c 100644 --- a/packages/ns-api-server/files/src/configuration/configuration.go +++ b/packages/ns-api-server/files/src/configuration/configuration.go @@ -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"` @@ -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 { diff --git a/packages/ns-api-server/files/src/configuration/configuration_test.go b/packages/ns-api-server/files/src/configuration/configuration_test.go index 8b74f86b8..adc338680 100644 --- a/packages/ns-api-server/files/src/configuration/configuration_test.go +++ b/packages/ns-api-server/files/src/configuration/configuration_test.go @@ -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") diff --git a/packages/ns-api-server/files/src/main.go b/packages/ns-api-server/files/src/main.go index 8f2195652..c8851c4d2 100644 --- a/packages/ns-api-server/files/src/main.go +++ b/packages/ns-api-server/files/src/main.go @@ -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" @@ -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{ diff --git a/packages/ns-api-server/files/src/methods/proxy.go b/packages/ns-api-server/files/src/methods/proxy.go new file mode 100644 index 000000000..c94af2694 --- /dev/null +++ b/packages/ns-api-server/files/src/methods/proxy.go @@ -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) + } +} diff --git a/packages/ns-api-server/files/src/methods/proxy_test.go b/packages/ns-api-server/files/src/methods/proxy_test.go new file mode 100644 index 000000000..d973ab70b --- /dev/null +++ b/packages/ns-api-server/files/src/methods/proxy_test.go @@ -0,0 +1,188 @@ +/* +Copyright (C) 2026 Nethesis S.r.l. +SPDX-License-Identifier: GPL-2.0-only +*/ + +package methods + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/NethServer/nethsecurity-api/logs" + "github.com/gin-gonic/gin" +) + +// proxy.go logs backend errors; logs.Logs is otherwise only set up in main(). +func TestMain(m *testing.M) { + logs.Logs = log.New(os.Stderr, "test ", 0) + os.Exit(m.Run()) +} + +// httputil.ReverseProxy needs a real ResponseWriter (CloseNotifier/Flusher), +// which httptest.NewRecorder doesn't implement. +func startTestServer(t *testing.T, r *gin.Engine) *httptest.Server { + t.Helper() + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + return srv +} + +func TestProxyToForwardsRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath, gotQuery, gotAuth, gotCookie string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + gotCookie = r.Header.Get("Cookie") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"success"}`)) + })) + defer backend.Close() + + r := gin.New() + r.GET("/x", ProxyTo(NewReverseProxy(backend.URL), "/api/v1/query")) + srv := startTestServer(t, r) + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/x?query=up", nil) + req.Header.Set("Authorization", "Bearer secret-jwt") + req.Header.Set("Cookie", "session=abc") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if string(body) != `{"status":"success"}` { + t.Fatalf("body = %q, want backend body passed through", string(body)) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", resp.Header.Get("Content-Type")) + } + if gotPath != "/api/v1/query" { + t.Fatalf("backend received path = %q, want /api/v1/query", gotPath) + } + if gotQuery != "query=up" { + t.Fatalf("backend received query = %q, want query=up", gotQuery) + } + if gotAuth != "" { + t.Fatalf("backend received Authorization header %q, want it stripped", gotAuth) + } + if gotCookie != "" { + t.Fatalf("backend received Cookie header %q, want it stripped", gotCookie) + } +} + +func TestProxyToBackendUnreachable(t *testing.T) { + gin.SetMode(gin.TestMode) + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + backendURL := backend.URL + backend.Close() // nothing is listening on this address anymore + + r := gin.New() + r.GET("/x", ProxyTo(NewReverseProxy(backendURL), "/api/v1/query")) + srv := startTestServer(t, r) + + resp, err := http.Get(srv.URL + "/x") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadGateway) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", resp.Header.Get("Content-Type")) + } +} + +func TestNewReverseProxyResponseHeaderTimeout(t *testing.T) { + proxy := NewReverseProxy("http://127.0.0.1:8428") + + transport, ok := proxy.Transport.(*http.Transport) + if !ok { + t.Fatalf("Transport = %T, want *http.Transport", proxy.Transport) + } + if transport.ResponseHeaderTimeout != 10*time.Second { + t.Fatalf("ResponseHeaderTimeout = %s, want 10s", transport.ResponseHeaderTimeout) + } +} + +// Mirrors main.go's route table: any method is forwarded on the three registered +// paths, everything else 404s regardless of method. +func TestMinimalRouteTableRejectsEverythingElse(t *testing.T) { + gin.SetMode(gin.TestMode) + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + victoriaMetricsProxy := NewReverseProxy(backend.URL) + r := gin.New() + r.Any("/metrics/query", ProxyTo(victoriaMetricsProxy, "/api/v1/query")) + r.Any("/metrics/query_range", ProxyTo(victoriaMetricsProxy, "/api/v1/query_range")) + r.Any("/alerts/alerts", ProxyTo(NewReverseProxy(backend.URL), "/api/v1/alerts")) + srv := startTestServer(t, r) + + allowed := []struct { + method string + path string + }{ + {http.MethodGet, "/metrics/query"}, + {http.MethodPost, "/metrics/query"}, + {http.MethodPut, "/metrics/query"}, + {http.MethodGet, "/metrics/query_range"}, + {http.MethodPost, "/metrics/query_range"}, + {http.MethodGet, "/alerts/alerts"}, + {http.MethodPost, "/alerts/alerts"}, + } + for _, tc := range allowed { + req, _ := http.NewRequest(tc.method, srv.URL+tc.path, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: request failed: %v", tc.method, tc.path, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, resp.StatusCode, http.StatusOK) + } + } + + rejected := []struct { + method string + path string + }{ + {http.MethodGet, "/metrics/admin/tsdb/delete_series"}, + {http.MethodGet, "/metrics/admin/tsdb/snapshot"}, + {http.MethodPost, "/metrics/admin/tsdb/delete_series"}, + {http.MethodGet, "/alerts/-/reload"}, + {http.MethodGet, "/bogus"}, + {http.MethodPost, "/bogus"}, + } + for _, tc := range rejected { + req, _ := http.NewRequest(tc.method, srv.URL+tc.path, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: request failed: %v", tc.method, tc.path, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, resp.StatusCode, http.StatusNotFound) + } + } +} diff --git a/packages/ns-api-server/files/src/response/response.go b/packages/ns-api-server/files/src/response/response.go index 7289bd4f4..4757e6b62 100644 --- a/packages/ns-api-server/files/src/response/response.go +++ b/packages/ns-api-server/files/src/response/response.go @@ -85,3 +85,9 @@ type StatusServiceUnavailable struct { Message string `json:"message" example:"Service unavailable" structs:"message"` Data interface{} `json:"data" structs:"data"` } + +type StatusBadGateway struct { + Code int `json:"code" example:"502" structs:"code"` + Message string `json:"message" example:"Bad gateway" structs:"message"` + Data interface{} `json:"data" structs:"data"` +} From 919d0c472f6f009fd0dc719e7f3fe710340fe88a Mon Sep 17 00:00:00 2001 From: Tommaso Bailetti Date: Mon, 20 Jul 2026 15:20:57 +0200 Subject: [PATCH 2/2] docs: updated --- docs/design/api.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/design/api.md b/docs/design/api.md index 6f2a9c00d..6ac8c6dfe 100644 --- a/docs/design/api.md +++ b/docs/design/api.md @@ -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 ' -k 'https://localhost/api/metrics/query?query=up' +``` + ## Conventions APIs are invoked using the [api-server](../packages/ns-api-server).