Skip to content

Commit

Permalink
feat: add blacklist feature and API key authentication
Browse files Browse the repository at this point in the history
- Introduced a blacklist feature to prevent processing of unwanted torrents.
- Added API key authentication for securing API endpoints.
- Updated database schema to include BlacklistedItems table.
- Enhanced DmmScraping and GenericIngestionProcessor to filter out blacklisted torrents.
- Implemented new endpoints for adding and removing items from the blacklist.
- Updated configuration to support API key generation and management.
- Improved Kubernetes service discovery with authentication type options.
- Enhanced Swagger documentation with API key security scheme.
  • Loading branch information
iPromKnight committed Nov 17, 2024
1 parent ae73304 commit 562bd14
Show file tree
Hide file tree
Showing 34 changed files with 889 additions and 55 deletions.
64 changes: 47 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The DMM import reruns on missing pages every hour.
```json
{
"Zilean": {
"ApiKey": "5c43b70d3be04308b72ada4f61515fb4e278b08c48ec4c8a87e954ec658f8e4e",
"FirstRun": false,
"Dmm": {
"EnableScraping": true,
"EnableEndpoint": true,
Expand All @@ -29,31 +31,28 @@ The DMM import reruns on missing pages every hour.
"Database": {
"ConnectionString": "Host=localhost;Database=zilean;Username=postgres;Password=postgres;Include Error Detail=true;Timeout=300;CommandTimeout=300;"
},
"Prowlarr": {
"EnableEndpoint": true
},
"Torrents": {
"EnableEndpoint": false
"EnableEndpoint": true
},
"Imdb": {
"EnableImportMatching": true,
"EnableEndpoint": true,
"MinimumScoreMatch": 0.85
},
"Ingestion": {
"ZurgInstances": [],
"ZurgInstances": [
{
"Url": "http://zurg:9999",
"EndpointType": 1
}
],
"ZileanInstances": [],
"EnableScraping": false,
"EnableScraping": true,
"Kubernetes": {
"EnableServiceDiscovery": false,
"KubernetesSelectors": [
{
"UrlTemplate": "http://zurg.{0}:9999",
"LabelSelector": "app.elfhosted.com/name=zurg",
"EndpointType": 1
}
],
"KubeConfigFile": "/$HOME/.kube/config"
"KubernetesSelectors": [],
"KubeConfigFile": "/$HOME/.kube/config",
"AuthenticationType": 0
},
"BatchSize": 500,
"MaxChannelSize": 5000,
Expand Down Expand Up @@ -124,7 +123,8 @@ The `Ingestion` section in the JSON configuration defines the behavior and optio
"EndpointType": 1
}
],
"KubeConfigFile": "/$HOME/.kube/config"
"KubeConfigFile": "/$HOME/.kube/config",
"AuthenticationType": 0
},
"BatchSize": 500,
"MaxChannelSize": 5000,
Expand Down Expand Up @@ -192,6 +192,7 @@ The `Ingestion` section in the JSON configuration defines the behavior and optio
- **`LabelSelector`**: Label selector to filter Kubernetes services.
- **`EndpointType`**: Indicates the type of endpoint (0 = Zilean, 1 = Zurg).
- **`KubeConfigFile`**: Path to the Kubernetes configuration file.
- **`AuthenticationType`**: Authentication type for Kubernetes service discovery (0 = ConfigFile, 1 = RoleBased).

### `BatchSize`
- **Type**: `int`
Expand Down Expand Up @@ -273,9 +274,21 @@ If `EnableServiceDiscovery` is set to `true` in the Kubernetes section, the appl
"EndpointType": 1
}
],
"KubeConfigFile": "/$HOME/.kube/config"
"KubeConfigFile": "/$HOME/.kube/config",
"AuthenticationType": 0
}
```
### `AuthenticationType`
Defines the Types of authentication to use when connecting to the kubernetes service host.

```csharp
public enum KubernetesAuthenticationType
{
ConfigFile = 0,
RoleBased = 1
}
```
note: In order for RBAC to work, the service account must have the correct permissions to list services in the namespace, and the `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` environment variables must be set.

### Behavior
1. The application uses the Kubernetes client to list services matching the `LabelSelector`.
Expand All @@ -296,4 +309,21 @@ Key events in the ingestion process are logged:
- Discovered URLs.
- Filtered torrents (existing in the database).
- Processed torrents (new and valid).
- Errors during processing or service discovery.
- Errors during processing or service discovery.

---

## Blacklisting

The ingestion pipeline supports blacklisting infohashes to prevent them from being processed. This feature is useful for filtering out unwanted torrents or duplicates.
See the `/blacklist` endpoints for more information in scalar.
These endpoints are protected by the ApiKey that will be generated on first run of the application and stored in the settings.json file as well as a one time print to application logs on startup.
Blacklisting an item also removes it from the database.

---

## Api Key

The ApiKey is generated on first run of the application and stored in the settings.json file as well as a one time print to application logs on startup.
The key can also be cycled to a new key if you set the environment variable `ZILEAN__NEW__API__KEY` to `true` and restart the application.
To authenticate with the API, you must include the `ApiKey` in the request headers. The header key is `X-Api-Key` and will automatically be configured in scalar.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Zilean.ApiService.Features.Authentication;

public static class ApiKeyAuthentication
{
public const string Scheme = "ApiKey";
public const string Policy = "ApiKeyPolicy";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Zilean.ApiService.Features.Authentication;

public class ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
ZileanConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-API-KEY", out var extractedApiKey))
{
return Task.FromResult(AuthenticateResult.Fail("API Key was not provided"));
}

var configuredApiKey = configuration.ApiKey;
if (string.IsNullOrEmpty(configuredApiKey) || extractedApiKey != configuredApiKey)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid API Key"));
}

var claims = new[] { new Claim(ClaimTypes.Name, "ApiKeyUser") };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);

return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
public class ApiKeyDocumentTransformer : IOpenApiDocumentTransformer
{
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
// Define the API key security scheme
var apiKeyScheme = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
Name = "X-API-KEY",
In = ParameterLocation.Header,
Description = "API Key required for accessing protected endpoints."
};

document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes[ApiKeyAuthentication.Scheme] = apiKeyScheme;

foreach (var group in context.DescriptionGroups)
{
foreach (var apiDescription in group.Items)
{
var metadata = apiDescription.ActionDescriptor.EndpointMetadata?
.OfType<OpenApiSecurityMetadata>()
.FirstOrDefault();

if (metadata is { SecurityScheme: ApiKeyAuthentication.Scheme })
{
var route = apiDescription.RelativePath;
if (document.Paths.TryGetValue("/" + route, out var pathItem))
{
foreach (var operation in pathItem.Operations.Values)
{
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[apiKeyScheme] = Array.Empty<string>()
});
}
}
}
}
}

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Zilean.ApiService.Features.Authentication;

public class OpenApiSecurityMetadata(string securityScheme)
{
public string SecurityScheme { get; } = securityScheme;
}
116 changes: 116 additions & 0 deletions src/Zilean.ApiService/Features/Blacklist/BlacklistEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
namespace Zilean.ApiService.Features.Blacklist;

public static class BlacklistEndpoints
{
private const string GroupName = "blacklist";
private const string Add = "/add";
private const string Remove = "/remove";

public static WebApplication MapBlacklistEndpoints(this WebApplication app)
{
app.MapGroup(GroupName)
.WithTags(GroupName)
.Torrents()
.DisableAntiforgery()
.RequireAuthorization(ApiKeyAuthentication.Policy)
.WithMetadata(new OpenApiSecurityMetadata(ApiKeyAuthentication.Scheme));

return app;
}

private static RouteGroupBuilder Torrents(this RouteGroupBuilder group)
{
group.MapPut(Add, AddBlacklistItem)
.Produces(StatusCodes.Status204NoContent)
.Produces<string>(StatusCodes.Status400BadRequest)
.Produces<string>(StatusCodes.Status409Conflict);

group.MapDelete(Remove, RemoveBlacklistItem)
.Produces(StatusCodes.Status204NoContent)
.Produces<string>(StatusCodes.Status404NotFound)
.Produces<string>(StatusCodes.Status400BadRequest);

return group;
}

private static async Task<IResult> RemoveBlacklistItem(HttpContext context, ZileanDbContext dbContext, ILogger<BlacklistLogger> logger, [FromQuery] string infoHash)
{
try
{
if (string.IsNullOrWhiteSpace(infoHash))
{
logger.LogWarning("Attempted to remove blacklisted item with empty info hash");
return Results.BadRequest("InfoHash is required");
}

var item = await dbContext.BlacklistedItems.FirstOrDefaultAsync(x => x.InfoHash == infoHash);

if (item == null)
{
logger.LogWarning("Attempted to remove non-existent blacklisted item {InfoHash}", infoHash);
return Results.NotFound();
}

dbContext.BlacklistedItems.Remove(item);
await dbContext.SaveChangesAsync();

logger.LogInformation("Removed blacklisted item {InfoHash}", infoHash);

return Results.NoContent();
}
catch (Exception e)
{
logger.LogError(e, "An error occurred while removing a blacklisted item");
return Results.BadRequest("An error occurred while removing a blacklisted item");
}
}

private static async Task<IResult> AddBlacklistItem(HttpContext context, ZileanDbContext dbContext, [AsParameters] BlacklistItemRequest request, ILogger<BlacklistLogger> logger)
{
try
{
if (string.IsNullOrWhiteSpace(request.info_hash))
{
return Results.BadRequest("info_hash is required");
}

if (string.IsNullOrWhiteSpace(request.reason))
{
return Results.BadRequest("reason is required");
}

if (await dbContext.BlacklistedItems.AnyAsync(x => x.InfoHash == request.info_hash))
{
return Results.Conflict("Item already blacklisted");
}

var blacklistedItem = new BlacklistedItem
{
InfoHash = request.info_hash,
Reason = request.reason,
BlacklistedAt = DateTime.UtcNow
};

dbContext.BlacklistedItems.Add(blacklistedItem);

var torrentInfo = await dbContext.Torrents.FirstOrDefaultAsync(x => x.InfoHash == request.info_hash);

if (torrentInfo != null)
{
dbContext.Torrents.Remove(torrentInfo);
logger.LogInformation("Removed torrent {InfoHash} from database", request.info_hash);
}

await dbContext.SaveChangesAsync();

return Results.NoContent();
}
catch (Exception e)
{
logger.LogError(e, "An error occurred while adding a blacklisted item");
return Results.BadRequest("An error occurred while adding a blacklisted item");
}
}

private abstract class BlacklistLogger;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// ReSharper disable InconsistentNaming
namespace Zilean.ApiService.Features.Blacklist;

public class BlacklistItemRequest
{
public required string info_hash { get; set; }
public required string reason { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Zilean.ApiService.Features.Bootstrapping;

public class ConfigurationUpdaterService(ZileanConfiguration configuration, ILogger<ConfigurationUpdaterService> logger) : IHostedService
{
private const string ResetApiKeyEnvVar = "ZILEAN__NEW__API__KEY";

public async Task StartAsync(CancellationToken cancellationToken)
{
bool firstRun = configuration.FirstRun;

if (firstRun)
{
configuration.FirstRun = false;
}

if (Environment.GetEnvironmentVariable(ResetApiKeyEnvVar) is "1" or "true")
{
configuration.ApiKey = ApiKey.Generate();
logger.LogInformation("API Key regenerated:'{ApiKey}'", configuration.ApiKey);
logger.LogInformation("Please keep this key safe and secure.");
}

var configurationFolderPath = Path.Combine(AppContext.BaseDirectory, ConfigurationLiterals.ConfigurationFolder);
var configurationFilePath = Path.Combine(configurationFolderPath, ConfigurationLiterals.SettingsConfigFilename);

var configWrapper = new Dictionary<string, object>
{
[ConfigurationLiterals.MainSettingsSectionName] = configuration,
};

await File.WriteAllTextAsync(configurationFilePath,
JsonSerializer.Serialize(configWrapper,
new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = null,
}), cancellationToken);

if (firstRun)
{
logger.LogInformation("Zilean API Key: '{ApiKey}'", configuration.ApiKey);
logger.LogInformation("Please keep this key safe and secure.");
}
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Loading

0 comments on commit 562bd14

Please sign in to comment.