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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- 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
|
- 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
|
- Configurable Prometheus, Alertmanager, and Loki URLs via flags or environment variables
|
||||||
|
- Optional basic auth for Loki (`LOKI_USERNAME`/`LOKI_PASSWORD`)
|
||||||
|
|
||||||
### Git Explorer (`git-explorer`)
|
### Git Explorer (`git-explorer`)
|
||||||
Read-only access to git repository information. Designed for deployment verification.
|
Read-only access to git repository information. Designed for deployment verification.
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ Configure in your MCP client (e.g., Claude Desktop):
|
|||||||
"env": {
|
"env": {
|
||||||
"PROMETHEUS_URL": "http://prometheus.example.com:9090",
|
"PROMETHEUS_URL": "http://prometheus.example.com:9090",
|
||||||
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
|
"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": {
|
"git-explorer": {
|
||||||
@@ -351,6 +353,8 @@ hm-options delete release-23.11
|
|||||||
| `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` |
|
| `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` |
|
||||||
| `ALERTMANAGER_URL` | Alertmanager base URL for lab-monitoring | `http://localhost:9093` |
|
| `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_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
|
### Database Connection Strings
|
||||||
|
|
||||||
@@ -605,6 +609,8 @@ Both `options.http` and `packages.http` also support:
|
|||||||
| `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL |
|
| `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL |
|
||||||
| `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL |
|
| `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL |
|
||||||
| `lokiUrl` | nullOr string | `null` | Loki base URL (enables log query tools when set) |
|
| `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) |
|
| `enableSilences` | bool | `false` | Enable the create_silence tool (write operation) |
|
||||||
| `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address |
|
| `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address |
|
||||||
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
|
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/labmcp/internal/monitoring"
|
"git.t-juice.club/torjus/labmcp/internal/monitoring"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.3.0"
|
const version = "0.3.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
@@ -40,6 +40,16 @@ func main() {
|
|||||||
Usage: "Loki base URL (optional, enables log query tools)",
|
Usage: "Loki base URL (optional, enables log query tools)",
|
||||||
EnvVars: []string{"LOKI_URL"},
|
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{
|
Commands: []*cli.Command{
|
||||||
serveCommand(),
|
serveCommand(),
|
||||||
@@ -189,7 +199,11 @@ func runServe(c *cli.Context) error {
|
|||||||
|
|
||||||
var loki *monitoring.LokiClient
|
var loki *monitoring.LokiClient
|
||||||
if lokiURL := c.String("loki-url"); lokiURL != "" {
|
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 {
|
config.InstructionsFunc = func() string {
|
||||||
@@ -432,7 +446,11 @@ func runLogs(c *cli.Context, logql string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
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()
|
now := time.Now()
|
||||||
start, err := parseCLITime(c.String("start"), now.Add(-time.Hour))
|
start, err := parseCLITime(c.String("start"), now.Add(-time.Hour))
|
||||||
@@ -487,7 +505,11 @@ func runLabels(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
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 != "" {
|
if label := c.String("values"); label != "" {
|
||||||
values, err := loki.LabelValues(ctx, label)
|
values, err := loki.LabelValues(ctx, label)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ This ensures package information matches the nixpkgs version the project actuall
|
|||||||
func DefaultMonitoringConfig() ServerConfig {
|
func DefaultMonitoringConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "lab-monitoring",
|
Name: "lab-monitoring",
|
||||||
Version: "0.3.0",
|
Version: "0.3.1",
|
||||||
Mode: ModeCustom,
|
Mode: ModeCustom,
|
||||||
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
|
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, loki
|
|||||||
var lokiSrv *httptest.Server
|
var lokiSrv *httptest.Server
|
||||||
if len(lokiHandler) > 0 && lokiHandler[0] != nil {
|
if len(lokiHandler) > 0 && lokiHandler[0] != nil {
|
||||||
lokiSrv = httptest.NewServer(lokiHandler[0])
|
lokiSrv = httptest.NewServer(lokiHandler[0])
|
||||||
loki = NewLokiClient(lokiSrv.URL)
|
loki = NewLokiClient(LokiClientOptions{BaseURL: lokiSrv.URL})
|
||||||
}
|
}
|
||||||
|
|
||||||
RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true})
|
RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true})
|
||||||
|
|||||||
@@ -11,16 +11,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LokiClientOptions configures the Loki client.
|
||||||
|
type LokiClientOptions struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
// LokiClient is an HTTP client for the Loki API.
|
// LokiClient is an HTTP client for the Loki API.
|
||||||
type LokiClient struct {
|
type LokiClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLokiClient creates a new Loki API client.
|
// NewLokiClient creates a new Loki API client.
|
||||||
func NewLokiClient(baseURL string) *LokiClient {
|
func NewLokiClient(opts LokiClientOptions) *LokiClient {
|
||||||
return &LokiClient{
|
return &LokiClient{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||||
|
username: opts.Username,
|
||||||
|
password: opts.Password,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
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)
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestLokiClient_QueryRange(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewLokiClient(srv.URL)
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
start := time.Unix(0, 1234567890000000000)
|
start := time.Unix(0, 1234567890000000000)
|
||||||
end := time.Unix(0, 1234567899000000000)
|
end := time.Unix(0, 1234567899000000000)
|
||||||
data, err := client.QueryRange(context.Background(), `{job="varlogs"}`, start, end, 10, "backward")
|
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()
|
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")
|
_, err := client.QueryRange(context.Background(), "invalid{", time.Now().Add(-time.Hour), time.Now(), 100, "backward")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
@@ -102,7 +102,7 @@ func TestLokiClient_Labels(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewLokiClient(srv.URL)
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
labels, err := client.Labels(context.Background())
|
labels, err := client.Labels(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -130,7 +130,7 @@ func TestLokiClient_LabelValues(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewLokiClient(srv.URL)
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
values, err := client.LabelValues(context.Background(), "job")
|
values, err := client.LabelValues(context.Background(), "job")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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) {
|
func TestLokiClient_HTTPError(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
@@ -151,7 +210,7 @@ func TestLokiClient_HTTPError(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
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")
|
_, err := client.QueryRange(context.Background(), `{job="test"}`, time.Now().Add(-time.Hour), time.Now(), 100, "backward")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ in
|
|||||||
description = "Loki base URL. When set, enables log query tools (query_logs, list_labels, list_label_values).";
|
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 {
|
enableSilences = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
@@ -112,12 +124,17 @@ in
|
|||||||
ALERTMANAGER_URL = cfg.alertmanagerUrl;
|
ALERTMANAGER_URL = cfg.alertmanagerUrl;
|
||||||
} // lib.optionalAttrs (cfg.lokiUrl != null) {
|
} // lib.optionalAttrs (cfg.lokiUrl != null) {
|
||||||
LOKI_URL = cfg.lokiUrl;
|
LOKI_URL = cfg.lokiUrl;
|
||||||
|
} // lib.optionalAttrs (cfg.lokiUsername != null) {
|
||||||
|
LOKI_USERNAME = cfg.lokiUsername;
|
||||||
};
|
};
|
||||||
|
|
||||||
script = let
|
script = let
|
||||||
httpFlags = mkHttpFlags cfg.http;
|
httpFlags = mkHttpFlags cfg.http;
|
||||||
silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences";
|
silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences";
|
||||||
in ''
|
in ''
|
||||||
|
${lib.optionalString (cfg.lokiPasswordFile != null) ''
|
||||||
|
export LOKI_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/loki-password")"
|
||||||
|
''}
|
||||||
exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag}
|
exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -126,7 +143,9 @@ in
|
|||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
|
} // lib.optionalAttrs (cfg.lokiPasswordFile != null) {
|
||||||
|
LoadCredential = [ "loki-password:${cfg.lokiPasswordFile}" ];
|
||||||
|
} // {
|
||||||
# Hardening
|
# Hardening
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
|
|||||||
Reference in New Issue
Block a user