Skip to content
Merged
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
25 changes: 19 additions & 6 deletions Api/LancacheManager/Middleware/MetricsAuthenticationMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
using System.Security.Claims;
using LancacheManager.Core.Interfaces;
using LancacheManager.Security;

namespace LancacheManager.Middleware;

/// <summary>
/// Middleware to optionally require API key authentication for Prometheus metrics endpoint (/metrics)
/// Middleware to optionally require API key authentication for Prometheus metrics endpoint (/metrics).
///
/// This middleware MUST be registered before UseAuthorization() in Program.cs so it can set
/// context.User before the FallbackPolicy (RequireAuthenticatedUser) is evaluated.
///
/// Configuration Priority:
/// 1. UI Toggle (StateService) - if set via UI, takes precedence
/// 2. Environment Variable / appsettings.json (Security:RequireAuthForMetrics) - default fallback
///
/// Values:
/// - false (default): Metrics are PUBLIC - no authentication required
/// - true: Metrics require API Key in X-Api-Key header
/// - false (default): Metrics are PUBLIC - a synthetic principal is set so the FallbackPolicy
/// is satisfied without requiring any credentials from the scraper.
/// - true: Metrics require an API key via X-Api-Key header or Authorization: Bearer &lt;key&gt;.
///
/// Use Cases:
/// - false: Prometheus/Grafana can scrape metrics without authentication (common setup)
/// - false: Prometheus/Grafana can scrape metrics without authentication (common LAN setup)
/// - true: Secure metrics endpoint with API key (for internet-exposed instances)
/// </summary>
public class MetricsAuthenticationMiddleware
Expand Down Expand Up @@ -51,15 +56,18 @@ public async Task InvokeAsync(HttpContext context, AuthenticationHelper authHelp

if (!requireAuth)
{
// Metrics are public - allow Prometheus/Grafana to scrape without authentication
// Metrics are public — set a synthetic authenticated principal so the
// FallbackPolicy (RequireAuthenticatedUser) does not reject the request.
context.User = CreateMetricsPrincipal();
await _next(context);
return;
}

// Metrics require authentication - check for API key
// Metrics require authentication — validate API key (X-Api-Key or Authorization: Bearer).
var result = authHelper.ValidateApiKey(context);
if (result.IsAuthenticated)
{
context.User = CreateMetricsPrincipal();
await _next(context);
return;
}
Expand All @@ -70,4 +78,9 @@ public async Task InvokeAsync(HttpContext context, AuthenticationHelper authHelp
await AuthenticationHelper.WriteErrorResponseAsync(
context, result.StatusCode, result.ErrorMessage ?? "API key required for metrics");
}

private static ClaimsPrincipal CreateMetricsPrincipal() =>
new(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "prometheus-scraper")],
authenticationType: "Metrics"));
}
7 changes: 5 additions & 2 deletions Api/LancacheManager/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -780,11 +780,14 @@
// ASP.NET Core authentication & authorization pipeline
// SessionAuthenticationHandler populates HttpContext.Items["Session"] for backward compatibility.
app.UseAuthentication();
app.UseAuthorization();

// Add Metrics Authentication Middleware (optional API key for /metrics)
// MetricsAuthenticationMiddleware MUST run before UseAuthorization so it can set context.User
// before the FallbackPolicy (RequireAuthenticatedUser) is evaluated. When RequireAuthForMetrics
// is false it sets a synthetic principal; when true it validates the API key first.
app.UseMiddleware<MetricsAuthenticationMiddleware>();

app.UseAuthorization();

// Swagger authentication middleware (requires API key when Security:ProtectSwagger=true)
app.UseMiddleware<SwaggerAuthenticationMiddleware>();

Expand Down
13 changes: 12 additions & 1 deletion Api/LancacheManager/Security/AuthenticationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,21 @@ public bool IsAuthenticated(HttpContext context)

/// <summary>
/// Gets the API key from request headers.
/// Accepts X-Api-Key header (primary) or Authorization: Bearer &lt;key&gt; (Prometheus convention).
/// </summary>
public static string? GetApiKeyFromHeader(HttpContext context)
{
return context.Request.Headers["X-Api-Key"].FirstOrDefault();
var apiKey = context.Request.Headers["X-Api-Key"].FirstOrDefault();
if (apiKey != null)
return apiKey;

// Support Authorization: Bearer <key> so Prometheus scrape_config can use the
// standard `authorization: { type: Bearer, credentials: <key> }` block.
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
if (authHeader?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true)
return authHeader["Bearer ".Length..].Trim();

return null;
}

/// <summary>
Expand Down
42 changes: 41 additions & 1 deletion README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ A web UI for [LANCache](https://lancache.net/). Watch downloads as they happen,
- [Reverse Proxy (Nginx)](#nginx-reverse-proxy)
- [Monitoring (Grafana & Prometheus)](#grafana--prometheus)
- [Troubleshooting](#troubleshooting)
- [/metrics returns 401 despite env var](#metrics-401)
- [Building from Source](#building-from-source)
- [Contributing Translations](#contributing-translations)
- [Support and License](#support-and-license)
Expand Down Expand Up @@ -300,7 +301,7 @@ services:
| `Security__EnableAuthentication` | `true` | Require an API key for admin actions. Only turn off for local dev. |
| `Security__MaxAdminDevices` | `3` | How many devices can share the same API key. |
| `Security__GuestSessionDurationHours` | `6` | Default guest session length (also configurable in the UI). |
| `Security__RequireAuthForMetrics` | `false` | Require an API key on `/metrics`. |
| `Security__RequireAuthForMetrics` | `false` | Require an API key on `/metrics`. **This env var is the default only** — if the toggle has ever been saved via the UI (Management → Security), the persisted value takes priority. See [Metrics returns 401 despite env var](#metrics-401) if the env var appears to have no effect. |
| `Security__ProtectSwagger` | `true` | Require auth on Swagger docs in production. |
| `Security__AllowedOrigins` | (empty) | Comma-separated CORS allow list. Empty allows all. |
| `Security__AllowedBrowsePaths` | (empty) | Comma-separated allow-list of file-browser roots. Empty disables the file browser entirely (every request returns `403`). Example: `/data,/mnt`. |
Expand Down Expand Up @@ -831,6 +832,45 @@ lancache_cache_size_bytes / 1024 / 1024 / 1024
2. The mapping service queries the Epic API to identify what's in your cache.
3. Game names and cover art come down automatically.

<a id="metrics-401"></a>
### `/metrics` returns 401 despite `Security__RequireAuthForMetrics=false`

**Symptom:** Prometheus (or a manual `curl`) gets a `401 Unauthorized` from `/metrics` even though `Security__RequireAuthForMetrics=false` is set in the environment and is visible in `docker inspect`.

**Why it happens — config priority:**

```
UI toggle (persisted in state.json) > env var / appsettings.json default
```

`Security__RequireAuthForMetrics` is the *default fallback*. Once the toggle is saved via the UI (Management → Security → "Require authentication for metrics"), that value is written to `data/state/state.json` and permanently wins — the env var is no longer consulted.

**Fix option 1 — via the UI:**

Go to **Management → Security** and toggle "Require authentication for metrics" off, then save.

**Fix option 2 — edit `state.json` directly** (when the UI is inaccessible or you prefer config-as-code):

```bash
# Stop the container first to avoid a concurrent write race
docker stop lancache-manager

# Null out the field so the env var becomes the effective value again
python3 -c "
import json
with open('./data/state/state.json') as f:
s = json.load(f)
s['requireAuthForMetrics'] = None
with open('./data/state/state.json', 'w') as f:
json.dump(s, f, indent=2)
print('done')
"

docker start lancache-manager
```

After the restart, `Security__RequireAuthForMetrics=false` takes effect.

### Lost API key

```bash
Expand Down