Skip to content
Open
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
8 changes: 8 additions & 0 deletions deploy/docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 13 additions & 2 deletions deploy/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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.

## Prerequisites

- 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

Expand Down Expand Up @@ -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 <host>`).

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:
Expand Down Expand Up @@ -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`.
Expand All @@ -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. |
27 changes: 25 additions & 2 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: ../..
Expand All @@ -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}
Expand All @@ -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}
Expand All @@ -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}
Expand Down
8 changes: 7 additions & 1 deletion src/BuildingBlocks/Mailing/MailOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace FSH.Framework.Mailing;
using MailKit.Security;

namespace FSH.Framework.Mailing;

public sealed class MailOptions
{
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
Expand Down Expand Up @@ -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))
{
Expand Down
214 changes: 214 additions & 0 deletions src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?>
{
["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<IConfiguration>(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: "<p>hi</p>", 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<IOptions<MailOptions>>().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<IOptions<MailOptions>>().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<MailOptions> options = provider.GetRequiredService<IOptions<MailOptions>>();

// Act + Assert
Should.Throw<InvalidOperationException>(() => 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<IMailService>();

// Act
await mail.SendAsync(Request(), cts.Token);
IReadOnlyList<string> 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:<sender@x.com>", StringComparison.Ordinal));
commands.ShouldContain(c => c.StartsWith("RCPT TO:<dest@x.com>", 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<IMailService>();

// Act
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
() => mail.SendAsync(Request(), cts.Token));
IReadOnlyList<string> 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<NotSupportedException>();
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<string> _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<IReadOnlyList<string>> 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 <CRLF>.<CRLF>");
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();
}
}
Loading