feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

24
Tiku.Worker/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Directory.Build.props", "Directory.Packages.props", "./"]
COPY ["Tiku.Domain/Tiku.Domain.csproj", "Tiku.Domain/"]
COPY ["Tiku.Application/Tiku.Application.csproj", "Tiku.Application/"]
COPY ["Tiku.Infrastructure/Tiku.Infrastructure.csproj", "Tiku.Infrastructure/"]
COPY ["Tiku.Worker/Tiku.Worker.csproj", "Tiku.Worker/"]
RUN dotnet restore "Tiku.Worker/Tiku.Worker.csproj"
COPY . .
RUN dotnet publish "Tiku.Worker/Tiku.Worker.csproj" \
-c $BUILD_CONFIGURATION \
-o /app/publish \
--no-restore \
/p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Tiku.Worker.dll"]

26
Tiku.Worker/Program.cs Normal file
View File

@@ -0,0 +1,26 @@
using Serilog;
using Serilog.Events;
using Tiku.Worker;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting TIKU Worker");
var builder = Host.CreateApplicationBuilder(args);
builder.AddWorkerServices();
await builder.Build().RunAsync();
}
catch (Exception exception)
{
Log.Fatal(exception, "TIKU Worker terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync();
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,119 @@
using Serilog;
using Tiku.Application;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.Observability;
using OpenTelemetry.Metrics;
namespace Tiku.Worker;
internal static class WorkerDependencyInjection
{
internal static HostApplicationBuilder AddWorkerServices(this HostApplicationBuilder builder)
{
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext(),
preserveStaticLogger: true);
var connectionString = builder.Configuration.GetConnectionString("Database") ??
builder.Configuration["DATABASE_URL"] ??
(builder.Environment.IsDevelopment()
? $"Host=localhost;Database=tiku;Username={Environment.UserName}"
: throw new InvalidOperationException(
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
builder.Services.AddApplication();
builder.Services.AddInfrastructure(connectionString);
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"];
var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(WorkerTelemetry.MeterName));
if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint))
{
telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
}
builder.Services.Configure<ObjectStorageOptions>(builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
builder.Services.Configure<AliyunOssOptions>(builder.Configuration.GetSection(AliyunOssOptions.SectionName));
builder.Services.PostConfigure<ObjectStorageOptions>(options =>
{
options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
options.AllowedMimePrefixes = SplitLegacyList(
builder.Configuration["STORAGE_ALLOWED_MIME_PREFIXES"],
options.AllowedMimePrefixes);
options.AllowedMimeTypes = SplitLegacyList(
builder.Configuration["STORAGE_ALLOWED_MIME_TYPES"],
options.AllowedMimeTypes);
options.RequireTenantPrefix = bool.TryParse(
builder.Configuration["STORAGE_REQUIRE_TENANT_PREFIX"],
out var requireTenantPrefix)
? requireTenantPrefix
: options.RequireTenantPrefix;
options.MaxUploadBytes = long.TryParse(
builder.Configuration["STORAGE_MAX_UPLOAD_BYTES"],
out var maxUploadBytes)
? maxUploadBytes
: options.MaxUploadBytes;
});
builder.Services.PostConfigure<AliyunOssOptions>(options =>
{
options.Region = builder.Configuration["ALIYUN_OSS_REGION"] ?? options.Region;
options.Endpoint = builder.Configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint;
options.AccessKeyId = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId;
options.AccessKeySecret = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret;
options.SecurityToken = builder.Configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken;
options.UseInternalEndpoint = bool.TryParse(
builder.Configuration["ALIYUN_OSS_INTERNAL"],
out var useInternalEndpoint)
? useInternalEndpoint
: options.UseInternalEndpoint;
});
builder.Services.AddOptions<ObjectStorageOptions>()
.Validate(
options => !builder.Environment.IsProduction() ||
options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
"Production managed storage must use the configured Aliyun OSS provider.")
.ValidateOnStart();
builder.Services.AddOptions<AliyunOssOptions>()
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
(aliyun, storage) =>
!builder.Environment.IsProduction() ||
storage.Value.DefaultProvider != Tiku.Application.Storage.ObjectStorageProviders.AliyunOss ||
aliyun.IsConfigured,
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
.ValidateOnStart();
builder.Services.AddOptions<ClamAvOptions>()
.Bind(builder.Configuration.GetSection(ClamAvOptions.SectionName))
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
(clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes,
"ClamAV StreamMaxLength must cover the storage max upload size.")
.ValidateOnStart();
builder.Services.AddOptions<WorkerOptions>()
.Bind(builder.Configuration.GetSection(WorkerOptions.SectionName))
.Validate(WorkerOptions.BeValid, "Worker settings are invalid.")
.ValidateOnStart();
builder.Services.AddOptions<DomainLifecycleOptions>()
.Bind(builder.Configuration.GetSection("TenantDomains"));
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
builder.Services.AddSingleton<IPeriodicProcessorLock, PostgresPeriodicProcessorLock>();
builder.Services.AddSingleton<WorkerStateReporter>();
builder.Services.AddHostedService<TenantDomainWorker>();
builder.Services.AddHostedService<SaasSubscriptionWorker>();
builder.Services.AddHostedService<FeatureUsageWorker>();
builder.Services.AddHostedService<BackgroundJobsWorker>();
return builder;
}
private static string[] SplitLegacyList(string? value, string[] fallback) =>
string.IsNullOrWhiteSpace(value)
? fallback
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}

View File

@@ -0,0 +1,264 @@
using System.Data;
using Microsoft.Extensions.Options;
using Npgsql;
using Tiku.Application.Jobs;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Observability;
using System.Diagnostics;
namespace Tiku.Worker;
public sealed class WorkerOptions
{
public const string SectionName = "Worker";
public bool Enabled { get; set; } = true;
public int JobPollSeconds { get; set; } = 2;
public int JobParallelism { get; set; } = 4;
public int JobBatchSize { get; set; } = 5;
public static bool BeValid(WorkerOptions options) =>
options.JobPollSeconds is >= 1 and <= 3600 &&
options.JobParallelism is >= 1 and <= 32 &&
options.JobBatchSize is >= 1 and <= 100;
}
internal interface IPeriodicProcessorLock
{
Task<IAsyncDisposable?> TryAcquireAsync(string processor, CancellationToken cancellationToken);
}
internal sealed class WorkerStateReporter(NpgsqlDataSource dataSource)
{
private readonly string workerId = $"{Environment.MachineName}:{Environment.ProcessId}";
private readonly DateTimeOffset startedAt = DateTimeOffset.UtcNow;
public Task StartedAsync(string processor, CancellationToken cancellationToken) =>
UpsertAsync(processor, true, null, cancellationToken);
public Task CompletedAsync(string processor, Exception? error, CancellationToken cancellationToken) =>
UpsertAsync(processor, false, error?.Message, cancellationToken);
private async Task UpsertAsync(
string processor,
bool running,
string? error,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO worker_heartbeats
(id, worker_id, processor, started_at, last_heartbeat_at, last_iteration_started_at,
last_iteration_completed_at, last_succeeded_at, last_error, is_running)
VALUES
(gen_random_uuid(), @worker_id, @processor, @started_at, @now,
CASE WHEN @running THEN @now ELSE NULL END,
CASE WHEN @running THEN NULL ELSE @now END,
CASE WHEN NOT @running AND @error IS NULL THEN @now ELSE NULL END,
@error, @running)
ON CONFLICT (worker_id, processor) DO UPDATE SET
last_heartbeat_at = EXCLUDED.last_heartbeat_at,
last_iteration_started_at = CASE WHEN EXCLUDED.is_running THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_iteration_started_at END,
last_iteration_completed_at = CASE WHEN EXCLUDED.is_running THEN worker_heartbeats.last_iteration_completed_at ELSE EXCLUDED.last_heartbeat_at END,
last_succeeded_at = CASE WHEN NOT EXCLUDED.is_running AND EXCLUDED.last_error IS NULL THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_succeeded_at END,
last_error = EXCLUDED.last_error,
is_running = EXCLUDED.is_running
""";
command.Parameters.AddWithValue("worker_id", workerId);
command.Parameters.AddWithValue("processor", processor);
command.Parameters.AddWithValue("started_at", startedAt);
command.Parameters.AddWithValue("now", now);
command.Parameters.AddWithValue("running", running);
command.Parameters.AddWithValue("error", (object?)error ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
internal sealed class PostgresPeriodicProcessorLock(NpgsqlDataSource dataSource) : IPeriodicProcessorLock
{
public async Task<IAsyncDisposable?> TryAcquireAsync(string processor, CancellationToken cancellationToken)
{
var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = "SELECT pg_try_advisory_lock(hashtextextended(@processor, 0))";
command.Parameters.AddWithValue("processor", processor);
var acquired = (bool)(await command.ExecuteScalarAsync(cancellationToken) ?? false);
if (!acquired)
{
await connection.DisposeAsync();
return null;
}
return new AdvisoryLockLease(connection, processor);
}
private sealed class AdvisoryLockLease(NpgsqlConnection connection, string processor) : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
if (connection.State == ConnectionState.Open)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT pg_advisory_unlock(hashtextextended(@processor, 0))";
command.Parameters.AddWithValue("processor", processor);
await command.ExecuteScalarAsync();
}
await connection.DisposeAsync();
}
}
}
internal abstract class PeriodicWorker(
ILogger logger,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
string processorName,
TimeSpan interval,
bool enabled) : BackgroundService
{
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!enabled)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var lease = await processorLock.TryAcquireAsync(processorName, stoppingToken);
if (lease is not null)
{
var iterationTimestamp = Stopwatch.GetTimestamp();
await stateReporter.StartedAsync(processorName, stoppingToken);
try
{
var processed = await ProcessAsync(stoppingToken);
await stateReporter.CompletedAsync(processorName, null, stoppingToken);
WorkerTelemetry.RecordIteration(processorName, true, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds);
if (processed > 0)
{
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
}
}
catch (Exception exception)
{
await stateReporter.CompletedAsync(processorName, exception, CancellationToken.None);
WorkerTelemetry.RecordIteration(processorName, false, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds);
throw;
}
}
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
protected static void InitializeSystem(IServiceProvider services, string reason) =>
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
}
internal sealed class TenantDomainWorker(
IServiceScopeFactory scopeFactory,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
IOptions<DomainLifecycleOptions> domainOptions,
IOptions<WorkerOptions> workerOptions,
ILogger<TenantDomainWorker> logger)
: PeriodicWorker(logger, processorLock, stateReporter, "tenant-domain-lifecycle",
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)), workerOptions.Value.Enabled)
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle worker");
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
.ProcessPendingAsync(cancellationToken);
}
}
internal sealed class SaasSubscriptionWorker(
IServiceScopeFactory scopeFactory,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
IOptions<WorkerOptions> options,
ILogger<SaasSubscriptionWorker> logger)
: PeriodicWorker(logger, processorLock, stateReporter, "saas-subscription-lifecycle", TimeSpan.FromSeconds(60), options.Value.Enabled)
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle worker");
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
.ProcessDueAsync(cancellationToken: cancellationToken);
}
}
internal sealed class FeatureUsageWorker(
IServiceScopeFactory scopeFactory,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
IOptions<FeatureUsageReconciliationOptions> featureOptions,
IOptions<WorkerOptions> workerOptions,
ILogger<FeatureUsageWorker> logger)
: PeriodicWorker(logger, processorLock, stateReporter, "feature-usage-reconciliation",
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)), workerOptions.Value.Enabled)
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation worker");
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
.ProcessDueAsync(cancellationToken);
}
}
internal sealed class BackgroundJobsWorker(
IServiceScopeFactory scopeFactory,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
IOptions<WorkerOptions> options,
ILogger<BackgroundJobsWorker> logger)
: PeriodicWorker(logger, processorLock, stateReporter, "background-jobs", TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
{
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
private readonly int parallelism = options.Value.JobParallelism;
private readonly int batchSize = options.Value.JobBatchSize;
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
var workers = Enumerable.Range(0, parallelism)
.Select(index => ProcessPartitionAsync(index, cancellationToken));
return (await Task.WhenAll(workers)).Sum();
}
private async Task<int> ProcessPartitionAsync(int index, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Background job lease worker");
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync($"{workerId}:{index}", batchSize, includeImmediateJobs: true, cancellationToken: cancellationToken);
}
}

View File

@@ -0,0 +1,7 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
}
}
}

View File

@@ -0,0 +1,90 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
}
],
"Properties": {
"Application": "Tiku.Worker"
}
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"Worker": {
"Enabled": true,
"JobPollSeconds": 2,
"JobParallelism": 4,
"JobBatchSize": 5
},
"TenantDomains": {
"Enabled": true,
"PollSeconds": 60,
"BatchSize": 50,
"DnsJsonEndpoint": "https://cloudflare-dns.com/dns-query",
"VerificationRecordPrefix": "_tiku-verification",
"AllowedCnameTargets": [],
"GatewayBaseUrl": null,
"GatewayApiKey": null
},
"SaasSubscriptions": {
"Enabled": true,
"BatchSize": 100,
"PastDueGraceDays": 7
},
"FeatureUsageReconciliation": {
"Enabled": true,
"BatchSize": 100,
"IntervalMinutes": 60
},
"Storage": {
"DefaultProvider": "aliyun_oss",
"DefaultBucket": "tenant-assets",
"PublicBaseUrl": "",
"MaxUploadBytes": 524288000,
"AllowedMimePrefixes": [
"image/",
"video/",
"audio/"
],
"AllowedMimeTypes": [
"application/pdf",
"application/json",
"application/gzip",
"text/csv",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
],
"RequireTenantPrefix": true,
"AliyunOss": {
"Region": "",
"Endpoint": "",
"AccessKeyId": "",
"AccessKeySecret": "",
"SecurityToken": "",
"UseInternalEndpoint": false,
"UsePathStyle": false,
"UseCName": false,
"PresignDefaultMinutes": 15
}
},
"Security": {
"ClamAV": {
"Host": "localhost",
"Port": 3310,
"TimeoutSeconds": 30,
"ChunkBytes": 65536,
"StreamMaxLength": 524288000
}
}
}