diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 4b9bb10295..ca4486821a 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -30,6 +30,14 @@ FSH_ADMIN_PORT=8081 FSH_DASHBOARD_PORT=8082 FSH_S3_PORT=9000 +# Mailpit inbox UI (catches every e-mail the API sends). Bound to the +# host loopback only: open http://localhost:8025 on the Docker host. +FSH_MAILPIT_PORT=8025 + +# Sender address on every e-mail the API sends. Use a domain your SMTP provider +# accepts once you point MailOptions__Smtp__* at real delivery. +FSH_MAIL_FROM=no-reply@fsh.local + # ── Secrets ───────────────────────────────────────────────────────── # JWT signing key — must be 32+ chars and NOT contain the substring # "replace-with" (the framework's placeholder detector blocks that). diff --git a/deploy/docker/README.md b/deploy/docker/README.md index eb548cd242..9265705dd0 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -11,6 +11,7 @@ This brings up the full stack on a single host: | `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas | | `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | | `rustfs` | `rustfs/rustfs:1.0.0` | `FSH_S3_PORT` (default 9000), S3 API only | S3-compatible blob store for the Files module ([RustFS](https://rustfs.com)); published because browsers upload to it through presigned URLs | +| `mailpit` | `axllent/mailpit:v1.31.3` | `127.0.0.1:FSH_MAILPIT_PORT` (default 8025), SMTP 1025 internal | Local mail catcher: every e-mail the API sends lands here ([Mailpit](https://mailpit.axllent.org)) | The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine. @@ -18,7 +19,7 @@ The compose file does **not** include a reverse proxy or TLS terminator. You bri - Docker Engine 24+ with the Compose plugin (`docker compose version` should print v2.x). - 2 GB free RAM, 5 GB disk for first-run images + builds. -- Ports 8080–8082 and 9000 free on the host (or set custom ports in `.env`). +- Ports 8080–8082, 8025 and 9000 free on the host (or set custom ports in `.env`). ## Five-minute deploy @@ -47,6 +48,14 @@ curl -fsSI http://localhost:8082/ | head -1 # dashboard SPA curl -fsS http://localhost:9000/health # RustFS S3 API ``` +## Reading e-mail + +The API sends every e-mail (confirmation, password reset, welcome) to the bundled `mailpit` service, so nothing leaves the host and no SMTP account is needed. Open **http://localhost:8025** on the Docker host to read them and follow the links. A user an operator registers must confirm the e-mail before signing in, and the confirmation link is in that inbox. + +The inbox UI is published on the host loopback only, because it holds live password-reset and confirmation links. From another machine, use an SSH tunnel (`ssh -L 8025:127.0.0.1:8025 `). + +To deliver real mail, set `MailOptions__Smtp__Host`, `MailOptions__Smtp__Port`, `MailOptions__Smtp__UserName`, `MailOptions__Smtp__Password` and `MailOptions__Smtp__Security` on the `api` service to your provider's values (`StartTls` for port 587, `SslOnConnect` for 465), set `FSH_MAIL_FROM` in `.env` to a sender your provider accepts, and remove the `mailpit` service and its `depends_on` entry. + ## Wire up your external proxy Point four TLS subdomains at the published ports: @@ -100,7 +109,7 @@ Single-host compose is the default story; production deployments often point at 1. Comment out the `postgres` / `redis` / `rustfs` service blocks (and `rustfs-init`) AND remove them from the `depends_on:` of `api` and `migrator`. 2. Swap the matching env vars on `api` and `migrator`: - - `DatabaseOptions__ConnectionString` → your managed Postgres connection string + - `DatabaseOptions__ConnectionString` → your managed Postgres connection string (keep `GSS Encryption Mode=Disable` unless your server uses Kerberos: the chiseled images have no `libgssapi_krb5`) - `CachingOptions__Redis` → your managed Redis connection string (`host:port,password=...,ssl=True` etc.) - `Storage__Provider`, `Storage__S3__*` → your S3-compatible store (drop `Storage__S3__PresignServiceUrl` when the store's own endpoint is reachable from browsers, as with AWS S3) 3. `docker compose up -d`. @@ -117,4 +126,6 @@ The data-plane volumes (`pg_data`, `redis_data`, `rustfs_data`) can be deleted o | API up but admin shows a CORS error | `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` in `.env` doesn't match what your external proxy serves. Both go on the CORS allow-list. | | A reset or confirmation e-mail links to the API instead of the app | Same cause: `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` don't match the origins the browser actually uses. Both also feed `FrontendOptions__AllowedOrigins`, and `FSH_DASHBOARD_URL` feeds `FrontendOptions__DefaultOrigin`. | | File upload fails in the browser (network error, CORS error, or `403 SignatureDoesNotMatch`) | `FSH_S3_PUBLIC_URL` doesn't match what your proxy serves on the S3 port, the proxy rewrites the `Host` header, or the page's origin isn't `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` (the RustFS CORS allow-list). | +| No e-mail arrives, or the API logs `The SMTP server does not support the STARTTLS extension` | `MailOptions__Smtp__Security` does not match the server. The bundled Mailpit needs `None`; a provider on port 587 needs `StartTls`, on 465 `SslOnConnect`. | +| `Cannot load library libgssapi_krb5.so.2` in the API or migrator log | A connection string without `GSS Encryption Mode=Disable`. Npgsql tries GSS encryption by default and the chiseled images do not ship the Kerberos library. Harmless, but append the setting to silence it. | | `migrator` retries Postgres for 2 minutes then fails | Postgres didn't come up — check `docker compose logs postgres`. Most often a `POSTGRES_PASSWORD` change against an existing `pg_data` volume; delete the volume with `docker compose down -v` (destructive) and start over. | diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 8cc65d6d89..416b790d4c 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -7,6 +7,7 @@ # the FSH services and the RustFS S3 API on host ports (browsers upload # straight to presigned S3 URLs); postgres/redis stay compose-internal # unless you uncomment their ports blocks. +# The Mailpit inbox UI is published too, bound to the host loopback only. name: fsh @@ -102,6 +103,19 @@ services: aws --endpoint-url http://rustfs:9000 s3api head-bucket --bucket fsh 2>/dev/null || aws --endpoint-url http://rustfs:9000 s3api create-bucket --bucket fsh + # Local mail catcher: every e-mail the API sends (confirmation, password + # reset, welcome) lands here and never leaves the host. Read it at + # http://localhost:8025 (FSH_MAILPIT_PORT). For real delivery, point the + # api's MailOptions__Smtp__* at your provider and remove this service. + # SMTP (1025) stays on the compose network. + mailpit: + image: axllent/mailpit:v1.31.3 + container_name: fsh-mailpit + restart: unless-stopped + ports: + # Loopback only: the inbox holds live password-reset and confirmation links. + - "127.0.0.1:${FSH_MAILPIT_PORT:-8025}:8025" + migrator: build: context: ../.. @@ -113,7 +127,8 @@ services: postgres: { condition: service_healthy } environment: DOTNET_ENVIRONMENT: Production - DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD} + # The chiseled image has no libgssapi_krb5; Npgsql's default GSS Prefer logs a load failure per start. + DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD};GSS Encryption Mode=Disable CachingOptions__Redis: redis,password=${REDIS_PASSWORD} JwtOptions__SigningKey: ${JWT_SIGNING_KEY:?JWT_SIGNING_KEY is required} Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD:?SEED_ADMIN_PASSWORD is required} @@ -132,10 +147,12 @@ services: rustfs: { condition: service_healthy } rustfs-init: { condition: service_completed_successfully } migrator: { condition: service_completed_successfully } + mailpit: { condition: service_started } environment: DOTNET_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 - DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD} + # Same chiseled-image reason as the migrator: no libgssapi_krb5, so skip the GSS attempt. + DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD};GSS Encryption Mode=Disable CachingOptions__Redis: redis,password=${REDIS_PASSWORD} JwtOptions__SigningKey: ${JWT_SIGNING_KEY} Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD} @@ -147,6 +164,12 @@ services: Storage__S3__SecretKey: ${RUSTFS_SECRET_KEY} Storage__S3__Bucket: fsh Storage__S3__ForcePathStyle: "true" + # Plain SMTP to the compose-local Mailpit, which offers no STARTTLS to upgrade to. + MailOptions__Smtp__Host: mailpit + MailOptions__Smtp__Port: "1025" + MailOptions__Smtp__Security: "None" + # appsettings.Production.json leaves the sender blank, and a real provider rejects an empty MAIL FROM. + MailOptions__From: ${FSH_MAIL_FROM:-no-reply@fsh.local} AllowedHosts: "*" HangfireOptions__UserName: ${HANGFIRE_USERNAME:?HANGFIRE_USERNAME is required} HangfireOptions__Password: ${HANGFIRE_PASSWORD:?HANGFIRE_PASSWORD is required} diff --git a/src/BuildingBlocks/Mailing/MailOptions.cs b/src/BuildingBlocks/Mailing/MailOptions.cs index 9657cc19fd..f5a7fcb848 100644 --- a/src/BuildingBlocks/Mailing/MailOptions.cs +++ b/src/BuildingBlocks/Mailing/MailOptions.cs @@ -1,4 +1,6 @@ -namespace FSH.Framework.Mailing; +using MailKit.Security; + +namespace FSH.Framework.Mailing; public sealed class MailOptions { @@ -15,6 +17,10 @@ public sealed class SmtpOptions public int Port { get; set; } public string? UserName { get; set; } public string? Password { get; set; } + + // StartTls keeps every existing deployment's behaviour; a local catcher that speaks plain SMTP + // (Mailpit, MailHog, smtp4dev) needs None, and implicit-TLS port 465 needs SslOnConnect. + public SecureSocketOptions Security { get; set; } = SecureSocketOptions.StartTls; } public sealed class SendGridOptions diff --git a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs index de4592fddb..7f109dad0a 100644 --- a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs +++ b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs @@ -1,4 +1,3 @@ -using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; @@ -136,7 +135,7 @@ private async Task SendEmailAsync(MimeMessage email, CancellationToken ct) try { - await client.ConnectAsync(_settings.Smtp!.Host!, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct); + await client.ConnectAsync(_settings.Smtp!.Host!, _settings.Smtp.Port, _settings.Smtp.Security, ct); if (!string.IsNullOrWhiteSpace(_settings.Smtp.UserName) && !string.IsNullOrWhiteSpace(_settings.Smtp.Password)) { diff --git a/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs b/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs new file mode 100644 index 0000000000..891a037ce2 --- /dev/null +++ b/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; +using MailKit.Security; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Mailing; + +// MailOptions:Smtp:Security decides how SmtpMailService opens the connection. The connect call lives +// inside the service with a MailKit client it constructs itself, so the only honest observation point +// is a real socket: a loopback listener that speaks plain SMTP and, like Mailpit, never offers STARTTLS. +public sealed class SmtpConnectionSecurityTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private static ServiceProvider BuildProvider(int port, string? security) + { + var settings = new Dictionary + { + ["MailOptions:UseSendGrid"] = "false", + ["MailOptions:From"] = "noreply@x.com", + ["MailOptions:Smtp:Host"] = "127.0.0.1", + ["MailOptions:Smtp:Port"] = port.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + if (security is not null) + { + settings["MailOptions:Smtp:Security"] = security; + } + + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); + services.AddLogging(); + services.AddHeroMailing(); + return services.BuildServiceProvider(); + } + + private static MailRequest Request() => + new(to: ["dest@x.com"], subject: "Confirm your e-mail", body: "

hi

", from: "sender@x.com"); + + #region Binding + + [Fact] + public void Security_Should_DefaultToStartTls_When_NotConfigured() + { + // Arrange + using var provider = BuildProvider(port: 587, security: null); + + // Act + SecureSocketOptions security = provider.GetRequiredService>().Value.Smtp!.Security; + + // Assert — every deployment configured before the option existed keeps connecting with STARTTLS. + security.ShouldBe(SecureSocketOptions.StartTls); + } + + [Theory] + [InlineData("None", SecureSocketOptions.None)] + [InlineData("SslOnConnect", SecureSocketOptions.SslOnConnect)] + [InlineData("StartTlsWhenAvailable", SecureSocketOptions.StartTlsWhenAvailable)] + public void Security_Should_BindByName_When_Configured(string configured, SecureSocketOptions expected) + { + // Arrange + using var provider = BuildProvider(port: 587, security: configured); + + // Act + SecureSocketOptions security = provider.GetRequiredService>().Value.Smtp!.Security; + + // Assert + security.ShouldBe(expected); + } + + [Fact] + public void Security_Should_FailToBind_When_TheNameIsUnknown() + { + // Arrange — a typo must stop the options from binding rather than silently fall back to a + // mode the operator did not ask for. + using var provider = BuildProvider(port: 587, security: "Plain"); + IOptions options = provider.GetRequiredService>(); + + // Act + Assert + Should.Throw(() => options.Value); + } + + #endregion + + #region Connection + + [Fact] + public async Task SendAsync_Should_DeliverOverPlainSmtp_When_SecurityIsNone() + { + // Arrange + using var cts = new CancellationTokenSource(Timeout); + using var server = PlainSmtpServer.Start(cts.Token); + using var provider = BuildProvider(server.Port, security: "None"); + IMailService mail = provider.GetRequiredService(); + + // Act + await mail.SendAsync(Request(), cts.Token); + IReadOnlyList commands = await server.CompletedSessionAsync(cts.Token); + + // Assert — the envelope and the message reached the server, which is what a local catcher needs. + commands.ShouldContain(c => c.StartsWith("MAIL FROM:", StringComparison.Ordinal)); + commands.ShouldContain(c => c.StartsWith("RCPT TO:", StringComparison.Ordinal)); + server.Data.ShouldContain("Subject: Confirm your e-mail"); + server.ClientHungUp.ShouldBeFalse(); + commands[^1].ShouldBe("QUIT"); + } + + [Fact] + public async Task SendAsync_Should_Fail_When_SecurityDefaultsToStartTlsAndTheServerOffersNone() + { + // Arrange + using var cts = new CancellationTokenSource(Timeout); + using var server = PlainSmtpServer.Start(cts.Token); + using var provider = BuildProvider(server.Port, security: null); + IMailService mail = provider.GetRequiredService(); + + // Act + InvalidOperationException ex = await Should.ThrowAsync( + () => mail.SendAsync(Request(), cts.Token)); + IReadOnlyList commands = await server.CompletedSessionAsync(cts.Token); + + // Assert — the pre-option behaviour, kept as the default: a plain catcher is refused before any + // envelope is sent, so nothing is delivered. + ex.InnerException.ShouldBeOfType(); + commands.ShouldNotContain(c => c.StartsWith("MAIL FROM", StringComparison.Ordinal)); + } + + #endregion + + // One-connection SMTP server: greets, answers 250 to everything (so EHLO advertises no extension, + // STARTTLS included), takes one DATA block and ends on QUIT or when the client hangs up. + private sealed class PlainSmtpServer : IDisposable + { + private readonly TcpListener _listener; + private readonly List _commands = []; + private readonly StringBuilder _data = new(); + private Task _session = Task.CompletedTask; + + private PlainSmtpServer(TcpListener listener) => _listener = listener; + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public string Data => _data.ToString(); + + public bool ClientHungUp { get; private set; } + + public static PlainSmtpServer Start(CancellationToken ct) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var server = new PlainSmtpServer(listener); + server._session = server.RunAsync(ct); + return server; + } + + public async Task> CompletedSessionAsync(CancellationToken ct) + { + await _session.WaitAsync(ct); + return _commands; + } + + private async Task RunAsync(CancellationToken ct) + { + using TcpClient client = await _listener.AcceptTcpClientAsync(ct); + await using NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII); + await using var writer = new StreamWriter(stream, Encoding.ASCII) { NewLine = "\r\n", AutoFlush = true }; + + try + { + await writer.WriteLineAsync("220 localhost ESMTP test"); + while (await reader.ReadLineAsync(ct) is { } line) + { + _commands.Add(line); + string verb = line.Split(' ', 2)[0].ToUpperInvariant(); + if (verb == "QUIT") + { + await writer.WriteLineAsync("221 bye"); + return; + } + + if (verb == "DATA") + { + await writer.WriteLineAsync("354 end with ."); + await ReadDataAsync(reader, ct); + } + + await writer.WriteLineAsync("250 ok"); + } + } + // A client dropping the socket mid-dialogue is the refused-connection path under test, not a + // server fault: the commands recorded up to that point are the evidence. + catch (IOException) + { + ClientHungUp = true; + } + } + + private async Task ReadDataAsync(StreamReader reader, CancellationToken ct) + { + while (await reader.ReadLineAsync(ct) is { } line && line != ".") + { + _data.AppendLine(line); + } + } + + // Both tests await the session, so its outcome is observed there; disposing only frees the port. + public void Dispose() => _listener.Dispose(); + } +}