From 4276ffbda5a038ce0518f98d237371ffbe82a442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 17 Feb 2026 20:32:10 +0100 Subject: [PATCH] feat: add optional basic auth support for Loki client Some Loki deployments (e.g., behind a reverse proxy or Grafana Cloud) require HTTP Basic Authentication. This adds optional --loki-username and --loki-password flags (and corresponding env vars) to the lab-monitoring server, along with NixOS module options for secure credential management via systemd LoadCredential. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + README.md | 8 +++- cmd/lab-monitoring/main.go | 30 ++++++++++-- internal/mcp/server.go | 2 +- internal/monitoring/handlers_test.go | 2 +- internal/monitoring/loki.go | 19 +++++++- internal/monitoring/loki_test.go | 69 ++++++++++++++++++++++++++-- nix/lab-monitoring-module.nix | 21 ++++++++- 8 files changed, 137 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc70202..3e165cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs. Unlike other serve - 8 core tools: list/get alerts, search metrics, get metadata, PromQL query, list targets, list/create silences - 3 optional Loki tools (when `LOKI_URL` is set): query_logs, list_labels, list_label_values - Configurable Prometheus, Alertmanager, and Loki URLs via flags or environment variables +- Optional basic auth for Loki (`LOKI_USERNAME`/`LOKI_PASSWORD`) ### Git Explorer (`git-explorer`) Read-only access to git repository information. Designed for deployment verification. diff --git a/README.md b/README.md index 5d2e253..46a6d60 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,9 @@ Configure in your MCP client (e.g., Claude Desktop): "env": { "PROMETHEUS_URL": "http://prometheus.example.com:9090", "ALERTMANAGER_URL": "http://alertmanager.example.com:9093", - "LOKI_URL": "http://loki.example.com:3100" + "LOKI_URL": "http://loki.example.com:3100", + "LOKI_USERNAME": "optional-username", + "LOKI_PASSWORD": "optional-password" } }, "git-explorer": { @@ -351,6 +353,8 @@ hm-options delete release-23.11 | `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` | | `ALERTMANAGER_URL` | Alertmanager base URL for lab-monitoring | `http://localhost:9093` | | `LOKI_URL` | Loki base URL for lab-monitoring (optional, enables log tools) | *(none)* | +| `LOKI_USERNAME` | Username for Loki basic auth (optional) | *(none)* | +| `LOKI_PASSWORD` | Password for Loki basic auth (optional) | *(none)* | ### Database Connection Strings @@ -605,6 +609,8 @@ Both `options.http` and `packages.http` also support: | `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL | | `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL | | `lokiUrl` | nullOr string | `null` | Loki base URL (enables log query tools when set) | +| `lokiUsername` | nullOr string | `null` | Username for Loki basic authentication | +| `lokiPasswordFile` | nullOr path | `null` | Path to file containing Loki password (uses systemd `LoadCredential`) | | `enableSilences` | bool | `false` | Enable the create_silence tool (write operation) | | `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address | | `http.endpoint` | string | `"/mcp"` | HTTP endpoint path | diff --git a/cmd/lab-monitoring/main.go b/cmd/lab-monitoring/main.go index 7e38270..f17cfc1 100644 --- a/cmd/lab-monitoring/main.go +++ b/cmd/lab-monitoring/main.go @@ -15,7 +15,7 @@ import ( "git.t-juice.club/torjus/labmcp/internal/monitoring" ) -const version = "0.3.0" +const version = "0.3.1" func main() { app := &cli.App{ @@ -40,6 +40,16 @@ func main() { Usage: "Loki base URL (optional, enables log query tools)", EnvVars: []string{"LOKI_URL"}, }, + &cli.StringFlag{ + Name: "loki-username", + Usage: "Username for Loki basic auth", + EnvVars: []string{"LOKI_USERNAME"}, + }, + &cli.StringFlag{ + Name: "loki-password", + Usage: "Password for Loki basic auth", + EnvVars: []string{"LOKI_PASSWORD"}, + }, }, Commands: []*cli.Command{ serveCommand(), @@ -189,7 +199,11 @@ func runServe(c *cli.Context) error { var loki *monitoring.LokiClient if lokiURL := c.String("loki-url"); lokiURL != "" { - loki = monitoring.NewLokiClient(lokiURL) + loki = monitoring.NewLokiClient(monitoring.LokiClientOptions{ + BaseURL: lokiURL, + Username: c.String("loki-username"), + Password: c.String("loki-password"), + }) } config.InstructionsFunc = func() string { @@ -432,7 +446,11 @@ func runLogs(c *cli.Context, logql string) error { } ctx := context.Background() - loki := monitoring.NewLokiClient(lokiURL) + loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{ + BaseURL: lokiURL, + Username: c.String("loki-username"), + Password: c.String("loki-password"), + }) now := time.Now() start, err := parseCLITime(c.String("start"), now.Add(-time.Hour)) @@ -487,7 +505,11 @@ func runLabels(c *cli.Context) error { } ctx := context.Background() - loki := monitoring.NewLokiClient(lokiURL) + loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{ + BaseURL: lokiURL, + Username: c.String("loki-username"), + Password: c.String("loki-password"), + }) if label := c.String("values"); label != "" { values, err := loki.LabelValues(ctx, label) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8ff2b72..4d27cff 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -87,7 +87,7 @@ This ensures package information matches the nixpkgs version the project actuall func DefaultMonitoringConfig() ServerConfig { return ServerConfig{ Name: "lab-monitoring", - Version: "0.3.0", + Version: "0.3.1", Mode: ModeCustom, Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts. diff --git a/internal/monitoring/handlers_test.go b/internal/monitoring/handlers_test.go index f361d3b..4883111 100644 --- a/internal/monitoring/handlers_test.go +++ b/internal/monitoring/handlers_test.go @@ -31,7 +31,7 @@ func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, loki var lokiSrv *httptest.Server if len(lokiHandler) > 0 && lokiHandler[0] != nil { lokiSrv = httptest.NewServer(lokiHandler[0]) - loki = NewLokiClient(lokiSrv.URL) + loki = NewLokiClient(LokiClientOptions{BaseURL: lokiSrv.URL}) } RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true}) diff --git a/internal/monitoring/loki.go b/internal/monitoring/loki.go index 4ea4bbf..1fad86d 100644 --- a/internal/monitoring/loki.go +++ b/internal/monitoring/loki.go @@ -11,16 +11,27 @@ import ( "time" ) +// LokiClientOptions configures the Loki client. +type LokiClientOptions struct { + BaseURL string + Username string + Password string +} + // LokiClient is an HTTP client for the Loki API. type LokiClient struct { baseURL string + username string + password string httpClient *http.Client } // NewLokiClient creates a new Loki API client. -func NewLokiClient(baseURL string) *LokiClient { +func NewLokiClient(opts LokiClientOptions) *LokiClient { return &LokiClient{ - baseURL: strings.TrimRight(baseURL, "/"), + baseURL: strings.TrimRight(opts.BaseURL, "/"), + username: opts.Username, + password: opts.Password, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -94,6 +105,10 @@ func (c *LokiClient) get(ctx context.Context, path string, params url.Values) (j return nil, fmt.Errorf("failed to create request: %w", err) } + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) diff --git a/internal/monitoring/loki_test.go b/internal/monitoring/loki_test.go index 995c321..fe98220 100644 --- a/internal/monitoring/loki_test.go +++ b/internal/monitoring/loki_test.go @@ -42,7 +42,7 @@ func TestLokiClient_QueryRange(t *testing.T) { })) defer srv.Close() - client := NewLokiClient(srv.URL) + client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL}) start := time.Unix(0, 1234567890000000000) end := time.Unix(0, 1234567899000000000) data, err := client.QueryRange(context.Background(), `{job="varlogs"}`, start, end, 10, "backward") @@ -78,7 +78,7 @@ func TestLokiClient_QueryRangeError(t *testing.T) { })) defer srv.Close() - client := NewLokiClient(srv.URL) + client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL}) _, err := client.QueryRange(context.Background(), "invalid{", time.Now().Add(-time.Hour), time.Now(), 100, "backward") if err == nil { t.Fatal("expected error, got nil") @@ -102,7 +102,7 @@ func TestLokiClient_Labels(t *testing.T) { })) defer srv.Close() - client := NewLokiClient(srv.URL) + client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL}) labels, err := client.Labels(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -130,7 +130,7 @@ func TestLokiClient_LabelValues(t *testing.T) { })) defer srv.Close() - client := NewLokiClient(srv.URL) + client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL}) values, err := client.LabelValues(context.Background(), "job") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -144,6 +144,65 @@ func TestLokiClient_LabelValues(t *testing.T) { } } +func TestLokiClient_BasicAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + t.Error("expected basic auth to be set") + } + if user != "myuser" { + t.Errorf("expected username=myuser, got %s", user) + } + if pass != "mypass" { + t.Errorf("expected password=mypass, got %s", pass) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "status": "success", + "data": ["job"] + }`)) + })) + defer srv.Close() + + client := NewLokiClient(LokiClientOptions{ + BaseURL: srv.URL, + Username: "myuser", + Password: "mypass", + }) + labels, err := client.Labels(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(labels) != 1 || labels[0] != "job" { + t.Errorf("unexpected labels: %v", labels) + } +} + +func TestLokiClient_NoAuthWhenNoCredentials(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := r.BasicAuth(); ok { + t.Error("expected no basic auth header, but it was set") + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "status": "success", + "data": ["job"] + }`)) + })) + defer srv.Close() + + client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL}) + labels, err := client.Labels(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(labels) != 1 || labels[0] != "job" { + t.Errorf("unexpected labels: %v", labels) + } +} + func TestLokiClient_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -151,7 +210,7 @@ func TestLokiClient_HTTPError(t *testing.T) { })) defer srv.Close() - client := NewLokiClient(srv.URL) + client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL}) _, err := client.QueryRange(context.Background(), `{job="test"}`, time.Now().Add(-time.Hour), time.Now(), 100, "backward") if err == nil { t.Fatal("expected error, got nil") diff --git a/nix/lab-monitoring-module.nix b/nix/lab-monitoring-module.nix index 3e3e8ee..23dd575 100644 --- a/nix/lab-monitoring-module.nix +++ b/nix/lab-monitoring-module.nix @@ -39,6 +39,18 @@ in description = "Loki base URL. When set, enables log query tools (query_logs, list_labels, list_label_values)."; }; + lokiUsername = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Username for Loki basic authentication."; + }; + + lokiPasswordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to a file containing the password for Loki basic authentication. Recommended over storing secrets in the Nix store."; + }; + enableSilences = lib.mkOption { type = lib.types.bool; default = false; @@ -112,12 +124,17 @@ in ALERTMANAGER_URL = cfg.alertmanagerUrl; } // lib.optionalAttrs (cfg.lokiUrl != null) { LOKI_URL = cfg.lokiUrl; + } // lib.optionalAttrs (cfg.lokiUsername != null) { + LOKI_USERNAME = cfg.lokiUsername; }; script = let httpFlags = mkHttpFlags cfg.http; silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences"; in '' + ${lib.optionalString (cfg.lokiPasswordFile != null) '' + export LOKI_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/loki-password")" + ''} exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag} ''; @@ -126,7 +143,9 @@ in DynamicUser = true; Restart = "on-failure"; RestartSec = "5s"; - + } // lib.optionalAttrs (cfg.lokiPasswordFile != null) { + LoadCredential = [ "loki-password:${cfg.lokiPasswordFile}" ]; + } // { # Hardening NoNewPrivileges = true; ProtectSystem = "strict";