Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda d03ca01172 feat!: migrate to .NET 10 LTS (RECOMMENDATIONS #1)
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 23s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 52s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m1s
.NET 8 security support ends 2026-11-10; .NET 10 is LTS to Nov 2028.
- TargetFramework net8.0 -> net10.0 (Directory.Build.props)
- EF Core 8.0.4 -> 10.0.9; Npgsql provider 8.0.4 -> 10.0.2; Pgvector.EFCore
  0.2.0 -> 0.3.0; ASP.NET/Extensions packages -> 10.0.9
- Dockerfile sdk/aspnet 8.0 -> 10.0; CI setup-dotnet -> 10.0.x
- Modernised deprecated APIs: X509CertificateLoader (SYSLIB0057),
  KnownIPNetworks + System.Net.IPNetwork.Parse (ASPDEPR005)
- Adapted tests to Npgsql 10's eager connection-string validation (dummy conn
  string in test factories; behaviour change from lazy 8.x)

Verified: 0 errors/0 warnings; all 54 tests green on net10; the 3 LiveDb tests
green against a real pgvector container on the new EF10/Npgsql10/Pgvector 0.3
stack; dotnet format clean; vulnerable-package scan clean; the .NET 10 API
Docker image builds successfully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:29:21 +02:00
16 changed files with 41 additions and 75 deletions
-3
View File
@@ -15,6 +15,3 @@ FRONTEND_ORIGIN=http://localhost:8081
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox. # Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
DEV_MODE=false DEV_MODE=false
MAX_MESSAGES=0 MAX_MESSAGES=0
# Nightly DB backup rotation (days of dumps to keep in ./backups)
BACKUP_KEEP_DAYS=7
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '8.0.x' dotnet-version: '10.0.x'
- name: Restore - name: Restore
run: dotnet restore InboxIntel.sln run: dotnet restore InboxIntel.sln
- name: Build - name: Build
@@ -45,7 +45,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '8.0.x' dotnet-version: '10.0.x'
- name: dotnet format (verify only) - name: dotnet format (verify only)
run: dotnet format InboxIntel.sln --verify-no-changes run: dotnet format InboxIntel.sln --verify-no-changes
@@ -67,7 +67,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '8.0.x' dotnet-version: '10.0.x'
- name: Wait for Postgres - name: Wait for Postgres
run: | run: |
for i in $(seq 1 30); do for i in $(seq 1 30); do
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '8.0.x' dotnet-version: '10.0.x'
- name: Restore - name: Restore
run: dotnet restore InboxIntel.sln run: dotnet restore InboxIntel.sln
- name: .NET vulnerable packages (fail on any) - name: .NET vulnerable packages (fail on any)
-3
View File
@@ -21,9 +21,6 @@ frontend/.vite/
appsettings.*.local.json appsettings.*.local.json
secrets.json secrets.json
## DB backups (never commit dumps)
backups/
## Logs ## Logs
logs/ logs/
*.log *.log
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
+1 -3
View File
@@ -29,9 +29,7 @@ reverse proxy.
mitigate (a third party reading the DB files) reduces to "someone with access to your mitigate (a third party reading the DB files) reduces to "someone with access to your
machine" — mitigate it at the layer that actually works: machine" — mitigate it at the layer that actually works:
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended. - **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
- **Encrypt backups**: nightly `pg_dump` rotation runs via the compose `backup` service - **Encrypt backups** of the `pgdata` volume the same way.
into `./backups/` (git-ignored) — keep that directory on an encrypted disk and copy it
off-machine. Restore: `docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql`.
- Before any **multi-user** deployment, revisit per the multi-provider security design - Before any **multi-user** deployment, revisit per the multi-provider security design
(host admins must not be able to read members' mail — plaintext bodies break that promise). (host admins must not be able to read members' mail — plaintext bodies break that promise).
2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network / 2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
-31
View File
@@ -22,37 +22,6 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# Nightly logical backups (RECOMMENDATIONS #3 — previously there were NONE). Dumps
# rotate after BACKUP_KEEP_DAYS. The ./backups host directory should live on an
# encrypted disk and be included in your off-machine backup regime (see SECURITY.md).
# Restore: docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql
backup:
image: pgvector/pgvector:pg16
entrypoint: /bin/sh
command:
- -c
- |
while true; do
ts=$$(date -u +%Y%m%d-%H%M%S)
if pg_dump -h postgres -U inboxintel -d inboxintel > /backups/inboxintel-$$ts.sql.tmp; then
mv /backups/inboxintel-$$ts.sql.tmp /backups/inboxintel-$$ts.sql
echo "backup OK: inboxintel-$$ts.sql"
else
rm -f /backups/inboxintel-$$ts.sql.tmp
echo "backup FAILED at $$ts" >&2
fi
find /backups -name 'inboxintel-*.sql' -mtime +$${BACKUP_KEEP_DAYS:-7} -delete
sleep 86400
done
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-7}
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy
api: api:
build: build:
context: . context: .
+2 -2
View File
@@ -1,5 +1,5 @@
# Multi-stage build for the ASP.NET Core API. # Multi-stage build for the ASP.NET Core API.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy solution + project files first for layer-cached restore. # Copy solution + project files first for layer-cached restore.
@@ -13,7 +13,7 @@ RUN dotnet restore src/InboxIntel.Api/InboxIntel.Api.csproj
COPY src/ src/ COPY src/ src/
RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
+3 -3
View File
@@ -5,10 +5,10 @@
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId> <UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<!-- Required on the startup project for `dotnet ef migrations` to work. --> <!-- Required on the startup project for `dotnet ef migrations` to work. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
+5 -6
View File
@@ -38,8 +38,8 @@ var dp = builder.Services.AddDataProtection()
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"]; var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
if (!string.IsNullOrWhiteSpace(dpCertPath)) if (!string.IsNullOrWhiteSpace(dpCertPath))
{ {
dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2( dp.ProtectKeysWithCertificate(System.Security.Cryptography.X509Certificates.X509CertificateLoader
dpCertPath, builder.Configuration["DataProtection:CertificatePassword"])); .LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
} }
builder.Services.AddApplication(); builder.Services.AddApplication();
@@ -201,15 +201,14 @@ var forwardedOptions = new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1 ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
}; };
forwardedOptions.KnownNetworks.Clear(); forwardedOptions.KnownIPNetworks.Clear();
forwardedOptions.KnownProxies.Clear(); forwardedOptions.KnownProxies.Clear();
var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>()
?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" }; ?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" };
foreach (var cidr in trustedNetworks) foreach (var cidr in trustedNetworks)
{ {
var parts = cidr.Split('/'); if (System.Net.IPNetwork.TryParse(cidr, out var network))
if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len)) forwardedOptions.KnownIPNetworks.Add(network);
forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len));
} }
app.UseForwardedHeaders(forwardedOptions); app.UseForwardedHeaders(forwardedOptions);
@@ -6,13 +6,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.2" /> <PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query. <!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. --> Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. --> <!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. -->
<PackageReference Include="System.Text.Json" Version="8.0.6" /> <PackageReference Include="System.Text.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" /> <ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
@@ -6,7 +6,7 @@
<ItemGroup> <ItemGroup>
<!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as <!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as
column types on the Email entity — same pragmatic precedent for both. --> column types on the Email entity — same pragmatic precedent for both. -->
<PackageReference Include="Npgsql" Version="8.0.3" /> <PackageReference Include="Npgsql" Version="10.0.2" />
<PackageReference Include="Pgvector" Version="0.2.0" /> <PackageReference Include="Pgvector" Version="0.3.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -4,18 +4,18 @@
<AssemblyName>InboxIntel.Infrastructure</AssemblyName> <AssemblyName>InboxIntel.Infrastructure</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" /> <PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" /> <PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.0" /> <PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Polly" Version="8.4.1" /> <PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="QuestPDF" Version="2024.7.0" /> <PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.0.1" />
@@ -24,9 +24,9 @@
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by <!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by
EF Core / ASP.NET / DataProtection. Remove once the parent packages ship EF Core / ASP.NET / DataProtection. Remove once the parent packages ship
these versions transitively. --> these versions transitively. -->
<PackageReference Include="System.Text.Json" Version="8.0.6" /> <PackageReference Include="System.Text.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" /> <PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" /> <ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
@@ -25,7 +25,7 @@ namespace InboxIntel.IntegrationTests;
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary> /// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions> public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{ {
public const string Scheme = "Test"; public new const string Scheme = "Test";
// Stable across requests so per-user rate-limit partitions accumulate correctly. // Stable across requests so per-user rate-limit partitions accumulate correctly.
public static readonly string Uid = Guid.NewGuid().ToString(); public static readonly string Uid = Guid.NewGuid().ToString();
@@ -52,6 +52,9 @@ public class AuditTestAppFactory : WebApplicationFactory<Program>
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?> builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{ {
["Database:AutoMigrate"] = "false", ["Database:AutoMigrate"] = "false",
// Npgsql 10 eagerly validates the connection string when the DbContext is
// resolved (8.x was lazy); these tests never connect, but the string must parse.
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
["GoogleOAuth:ClientId"] = "test-client-id", ["GoogleOAuth:ClientId"] = "test-client-id",
["GoogleOAuth:ClientSecret"] = "test-client-secret", ["GoogleOAuth:ClientSecret"] = "test-client-secret",
// H-2: make the auth policy trip on the 3rd request within the window. // H-2: make the auth policy trip on the 3rd request within the window.
@@ -19,6 +19,9 @@ public class TestAppFactory : WebApplicationFactory<Program>
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?> builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{ {
["Database:AutoMigrate"] = "false", ["Database:AutoMigrate"] = "false",
// Npgsql 10 eagerly validates the connection string when the DbContext is
// resolved (8.x was lazy); these tests never connect, but the string must parse.
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
// Dummy OAuth creds so the Google challenge produces a real 302 redirect // Dummy OAuth creds so the Google challenge produces a real 302 redirect
// (an empty ClientId can make the handler throw instead of redirecting). // (an empty ClientId can make the handler throw instead of redirecting).
["GoogleOAuth:ClientId"] = "test-client-id", ["GoogleOAuth:ClientId"] = "test-client-id",
@@ -7,8 +7,8 @@
<PackageReference Include="xunit" Version="2.9.0" /> <PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" /> <ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />