diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml new file mode 100644 index 0000000..37599d2 --- /dev/null +++ b/.github/workflows/nuget-publish.yml @@ -0,0 +1,69 @@ +name: NuGet Publish + +on: + push: + tags: [ 'v*' ] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + DOTNET_VERSION: '10.0.x' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + publish: + name: Pack & publish SCDMS.Aspire.Hosting + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Compute version from git tag + shell: pwsh + id: version + run: | + $version = if ("${{ github.ref_type }}" -eq 'tag') { "${{ github.ref_name }}".TrimStart('v') } else { "1.0.0.0" } + Write-Host "Version: $version" + "value=$version" >> $env:GITHUB_OUTPUT + + - name: Pack SCDMS.Aspire.Hosting + run: > + dotnet pack src/SCDMS.Aspire.Hosting/SCDMS.Aspire.Hosting.csproj + --configuration Release + --output ./artifacts + --configfile NuGet.Config + /p:ContinuousIntegrationBuild=true + /p:Version=${{ steps.version.outputs.value }} + /p:PackageReleaseNotes="SCDMS.Aspire.Hosting ${{ steps.version.outputs.value }} - see https://github.com/MPCoreDeveloper/SCDMS/blob/main/docs/aspire.md" + + - name: Push to NuGet.org + shell: bash + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + set -euo pipefail + ls -1 ./artifacts/*.nupkg + dotnet nuget push ./artifacts/*.nupkg \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + + - name: Create release summary + run: | + echo "## πŸ“¦ NuGet Publish Completed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Published **SCDMS.Aspire.Hosting ${{ steps.version.outputs.value }}** to NuGet.org." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Triggered by**: @${{ github.actor }} β€” commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/Directory.Packages.props b/Directory.Packages.props index a14794d..c0f714e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,15 @@ + + + + + + + diff --git a/README.md b/README.md index cf0c44a..f6682a8 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Dev launchers: `scripts/launch.ps1` (Windows), `scripts/launch.sh` (Linux/macOS) 1. Tag: `git tag v1.0.0 && git push origin v1.0.0` 2. The [release workflow](.github/workflows/release.yml) publishes self-contained single-file binaries for `win-x64`, `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64` plus `SHA256SUMS.txt` to GitHub Releases. 3. The [Docker workflow](.github/workflows/docker-publish.yml) builds the container image (`ghcr.io/mpcoredeveloper/scdms`) for `linux/amd64` + `linux/arm64`. +4. The [NuGet workflow](.github/workflows/nuget-publish.yml) packs `SCDMS.Aspire.Hosting` and publishes it to NuGet.org. ## Docker & gRPC deployments @@ -93,11 +94,25 @@ docker run --rm -p 8080:8080 \ ghcr.io/mpcoredeveloper/scdms:local ``` -A full example (SCDMS + SharpCoreDB server + Caddy reverse proxy with Let's Encrypt) lives in [`samples/docker/`](samples/docker/). Configuration reference for container deployments (env variables, HTTP mode, default gRPC server, data volumes) is in [docs/usage.md](docs/usage.md). +A full example (SCDMS + SharpCoreDB server + Caddy reverse proxy with Let's Encrypt) lives in [`samples/docker/`](samples/docker/). Drop-in proxy alternatives with the same topology are [`samples/yarp/`](samples/yarp/) (all-.NET) and [`samples/haproxy/`](samples/haproxy/) (industry-standard HAProxy). Configuration reference for container deployments (env variables, HTTP mode, default gRPC server, data volumes) is in [docs/usage.md](docs/usage.md). + +## .NET Aspire + +SCDMS ships a `SCDMS.Aspire.Hosting` NuGet package (plus a runnable [AppHost example](examples/Aspire/SCDMS.AppHost/)) that runs SharpCoreDB server + SCDMS as one Aspire application β€” like pgweb/pgAdmin next to PostgreSQL: + +```csharp +var db = builder.AddSharpCoreDB("db").WithServerContainer(); // SharpCoreDB server container +builder.AddSCDMS("admin", db); // SCDMS container auto-wired over gRPC +``` + +Requires the published images `ghcr.io/mpcoredeveloper/sharpcoredb-server` (published) and `ghcr.io/mpcoredeveloper/scdms` (built on `v*` tags; until then `docker build -t ghcr.io/mpcoredeveloper/scdms:latest .`). See [docs/aspire.md](docs/aspire.md) for the design, status and the TLS notes. A step-by-step run & test guide for **both** options (Compose and Aspire) is in [docs/container-and-aspire-guide.md](docs/container-and-aspire-guide.md). ## Documentation +- [Run & test guide β€” Docker Compose + .NET Aspire](docs/container-and-aspire-guide.md) + - [Usage & configuration](docs/usage.md) +- [.NET Aspire integration (design & status)](docs/aspire.md) - [Standalone/migration plan](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/viewer/scdms-standalone-plan.md) (in the SharpCoreDB repo) - [SharpCoreDB documentation](https://github.com/MPCoreDeveloper/SharpCoreDB) diff --git a/SCDMS.slnx b/SCDMS.slnx index 42aeb4d..152ad9a 100644 --- a/SCDMS.slnx +++ b/SCDMS.slnx @@ -1,6 +1,10 @@ + + + + diff --git a/docs/aspire.md b/docs/aspire.md index 9b4e715..ab78838 100644 --- a/docs/aspire.md +++ b/docs/aspire.md @@ -1,8 +1,9 @@ # .NET Aspire integration (issue #10) -> **Status: design doc.** Fase 4 van de container/gRPC roadmap. Implementatie vereist eerst -> twee zaken in de **SharpCoreDB**-repo (zie "Prerequisites"), daarna kan dit repo een -> `SCDMS.Aspire.Hosting`-pakket toevoegen. +> **Status: geΓ―mplementeerd (2026-09-05).** Fase 4 van de container/gRPC-roadmap is afgerond: +> beide SharpCoreDB-prerequisites (server-image + `SharpCoreDB.Aspire.Hosting`) zijn gepubliceerd, +> en dit repo levert nu het `SCDMS.Aspire.Hosting`-pakket, een voorbeeld-AppHost, een +> NuGet-publish-workflow en documentatie. ## Goal @@ -14,111 +15,77 @@ var builder = DistributedApplication.CreateBuilder(args); var sharpCoreDb = builder.AddSharpCoreDB("db") .WithServerContainer(); // SharpCoreDB server container -builder.AddSCDMS("admin") - .WithGrpcReference(sharpCoreDb) // SCDMS container linked via gRPC - .WithHttpEndpoint(port: 8080, name: "http"); +// SCDMS web studio, gekoppeld aan de server over gRPC. AddSCDMS registreert meteen het +// HTTP-endpoint (containerpoort 8080) en de SCDMS__* container-voorwaarden. +var scdms = builder.AddSCDMS("admin", sharpCoreDb); builder.Build().Run(); ``` -Al het SCDMS ⇄ SharpCoreDB-dataverkeer loopt over **gRPC**. +Al het SCDMS ⇄ SharpCoreDB-dataverkeer loopt over **gRPC**. Browser-URL in de AppHost: +`scdms.GetEndpoint("http")`. -## Prerequisites (SharpCoreDB repo β€” volgorde) +## Prerequisites (SharpCoreDB-repo) β€” klaar -1. **Server-image publiceren** naar `ghcr.io/mpcoredeveloper/sharpcoredb-server` - (de `Dockerfile` in `src/SharpCoreDB.Server/` bestaat al; voeg een - `docker/build-push-action`-workflow toe op `v*`-tags, `linux/amd64`+`linux/arm64`). -2. **`SharpCoreDB.Aspire.Hosting`-pakket** publiceren met: +1. βœ… **Server-image gepubliceerd** β†’ `ghcr.io/mpcoredeveloper/sharpcoredb-server` + (`linux/amd64` + `linux/arm64`, getagd op elke `v*`-tag). +2. βœ… **`SharpCoreDB.Aspire.Hosting`-pakket gepubliceerd** β†’ versie `2.0.0.2` + (dependency: `Aspire.Hosting` 13.5.3, net10.0). -```csharp -// SharpCoreDB.Aspire.Hosting / SharpCoreDbServerResource.cs -public sealed class SharpCoreDbServerResource(string name) - : ContainerResource(name), IResourceWithConnectionString -{ - public string? JwtSecretKey { get; set; } - // ReferenceExpression voor de gRPC-connection string ("Host=...;Port=...;SSL=true") -} -``` +Publieke API die dit repo gebruikt: -```csharp -public static class SharpCoreDbAspireExtensions -{ - // Container-gebaseerd (gepubliceerde image) - public static IResourceBuilder AddSharpCoreDB( - this IDistributedApplicationBuilder builder, string name) => - builder.AddResource(new SharpCoreDbServerResource(name)) - .WithImage("ghcr.io/mpcoredeveloper/sharpcoredb-server") - .WithImageTag("latest") - .WithHttpEndpoint(port: 5001, name: "grpc"); - - // Convenience-alias voor het issue-snippet - public static IResourceBuilder WithServerContainer( - this IResourceBuilder resource) => resource; -} -``` +| Lid | Betekenis | +|---|---| +| `AddSharpCoreDB(name, imageTag = null, grpcPort = null, httpsApiPort = null)` | Registreert de servercontainer | +| `.WithServerContainer()` | Documentatie-alias voor container-hosting | +| `.WithImageTag(...)` | Image-tag pinnen | +| `.WithJwtSecret(secret)` | Zet `Server__Security__JwtSecretKey` (min. 32 tekens) | +| `SharpCoreDbServerResource.GrpcEndpointName` (= `"grpc"`) | HTTPS-gRPC-endpoint, containerpoort 5001 | +| `SharpCoreDbServerResource.HttpsApiEndpointName` (= `"https"`) | HTTPS REST API, containerpoort 8443 | -## SCDMS-repo implementatie (zodra prerequisites klaar zijn) +Referentie (SharpCoreDB-kant): [`docs/server/ASPIRE_INTEGRATION.md`](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/server/ASPIRE_INTEGRATION.md) -### Nieuw project `src/SCDMS.Aspire.Hosting/SCDMS.Aspire.Hosting.csproj` +## Implementatie in dit repo (SCDMS) -- `TargetFramework: net10.0` -- PackageReference: `Aspire.Hosting` (zelfde versie als SharpCoreDB.AppHost gebruikt, - i.c. 13.x) + project/package-ref naar `SharpCoreDB.Aspire.Hosting`. -- `SCDMSResource : ContainerResource, IResourceWithConnectionString` (web-URL als - connection string). +### `src/SCDMS.Aspire.Hosting` β†’ NuGet: `SCDMS.Aspire.Hosting` -### `SCDMSAspireExtensions.cs` (API-schets) +- **`ScdmsResource`** (`SCDMSResource.cs`) β€” `ContainerResource` + `IResourceWithConnectionString` + (web-URL als connection string). Endpoint-naam `http`, containerpoort 8080. +- **`ScdmsAspireExtensions`** (`ScdmsAspireExtensions.cs`): + - `AddSCDMS(builder, name, sharpCoreDb = null, imageTag = null, port = null)` β€” registreert de + SCDMS-container op `ghcr.io/mpcoredeveloper/scdms` met de container-voorwaarden + (`SCDMS__EnableHttps=false`, `SCDMS__BindAddress=0.0.0.0`, `SCDMS__DataDirectory=/app/data`, + update-check uit). Wordt `sharpCoreDb` meegegeven, dan volgt automatisch `WithGrpcReference`. + - `WithGrpcReference(scdms, sharpCoreDb)` β€” koppelt het `grpc`-endpoint van de server via + `SCDMS__DefaultServerHost` / `SCDMS__DefaultServerPort`, zet `SCDMS__DefaultServerUseSsl=true` + en `SCDMS__DefaultServerAutoConnect=true`. -```csharp -public static class ScdmsAspireExtensions -{ - public static IResourceBuilder AddSCDMS( - this IDistributedApplicationBuilder builder, - string name, - IResourceBuilder? sharpCoreDb = null) - { - var scdms = builder.AddResource(new SCDMSResource(name)) - .WithImage("ghcr.io/mpcoredeveloper/scdms") - .WithImageTag("latest") - .WithHttpEndpoint(targetPort: 8080, name: "http") - .WithEnvironment("SCDMS__EnableHttps", "false") - .WithEnvironment("SCDMS__BindAddress", "0.0.0.0") - .WithEnvironment("SCDMS__DataDirectory", "/app/data") - .WithEnvironment("SCDMS__DefaultServerAutoConnect", "true"); - - return sharpCoreDb is null ? scdms : scdms.WithGrpcReference(sharpCoreDb); - } - - // Koppelt de gRPC-server aan SCDMS via SCDMS__DefaultServer* omgevingsvariabelen. - public static IResourceBuilder WithGrpcReference( - this IResourceBuilder scdms, - IResourceBuilder sharpCoreDb) - { - var grpcEndpoint = sharpCoreDb.GetEndpoint("grpc"); - return scdms - .WithEnvironment("SCDMS__DefaultServerHost", grpcEndpoint) - .WithEnvironment("SCDMS__DefaultServerPort", grpcEndpoint.Property(EndpointProperty.Port)) - .WithEnvironment("SCDMS__DefaultServerUseSsl", "true") - .WithEnvironment("SCDMS__DefaultServerAutoConnect", "true"); - } -} -``` +### Voorbeeld-AppHost: `examples/Aspire/SCDMS.AppHost` -### Voorbeeld-AppHost (nieuw project, bijv. `examples/Aspire/SCDMS.AppHost`) +Volledig draaibaar voorbeeld. Starten: + +```bash +dotnet run --project examples/Aspire/SCDMS.AppHost/SCDMS.AppHost.csproj +``` -- `` + - `SharpCoreDB.Aspire.Hosting` (NuGet). -- `Program.cs` met het snippet bovenaan dit document; browser-URL via - `scdms.GetEndpoint("http")`. +Lees `examples/Aspire/SCDMS.AppHost/README.md` voor de lokale-dev-/TLS-opmerkingen. -### CI +### CI/CD -- Bouw/pack `SCDMS.Aspire.Hosting` en publiceer naar NuGet.org bij releases. +- `ci.yml` bouwt de volledige oplossing (`SCDMS.slnx`, inclusief de nieuwe projecten) op + ubuntu/windows/macos. +- `nuget-publish.yml` packt `SCDMS.Aspire.Hosting` en publiceert naar NuGet.org bij elke `v*`-tag + (vereist het `NUGET_API_KEY`-secret). +- De SCDMS-image (`ghcr.io/mpcoredeveloper/scdms`) wordt gepubliceerd door `docker-publish.yml` + bij een `v*`-tag (deel 1 van het issue). ## Opmerkingen -- De Aspire-local-run kan de server als container draaien (`WithServerContainer`). Voor - TLS: in de Aspire-dev-omgeving is een publiek certificaat niet beschikbaar β€” gebruik de - publiek-vertrouwde-proxy-aanpak in productie (zie `samples/docker/`) en voor lokale dev - een dev-certificaat + `tls_insecure_skip_verify`-achtige optie in de hosting-extensie - (of rechtstreeks container-intern over het Aspire-netwerk met de server-`/health`-check). +- De SharpCoreDB-servercontainer spreekt **uitsluitend TLS** en heeft een certificaat nodig + (`Server__Security__TlsCertificatePath`); SCDMS valideert het certificaat van het gRPC-endpoint. +- **Productie:** beΓ«indig TLS op een publiek vertrouwde reverse proxy (patroon in + `samples/docker/`), precies zoals de compose-sample. +- **Lokale dev:** de servercontainer heeft nog steeds een (dev-)certificaat nodig; lees de + server-side certificaatopties in de SharpCoreDB `ASPIRE_INTEGRATION.md`. Voor een volledig + vertrouwde lokale run zonder extra CA-mounts blijft de proxy-topologie van `samples/docker/` + de aanbevolen route. diff --git a/docs/container-and-aspire-guide.md b/docs/container-and-aspire-guide.md new file mode 100644 index 0000000..3ae5c5c --- /dev/null +++ b/docs/container-and-aspire-guide.md @@ -0,0 +1,368 @@ +# SCDMS + SharpCoreDB in containers β€” run & test guide + +This guide explains how to run **SCDMS** (the web database studio) together with a +**SharpCoreDB server** using the two container-based options the project ships, and how to +**test the whole thing yourself**: + +| Option | What you get | Where it lives | Best for | +|---|---|---|---| +| **A. Docker Compose** | SharpCoreDB server + SCDMS + TLS-terminating reverse proxy | [`samples/docker/`](../samples/docker/) (Caddy, auto-TLS) β€” alternatives: [`samples/yarp/`](../samples/yarp/) (.NET) and [`samples/haproxy/`](../samples/haproxy/) | Production-style deployments; teams with real domain names | +| **B. .NET Aspire** | SharpCoreDB server + SCDMS as one Aspire app (Aspire dashboard, resources, endpoints) | `SCDMS.Aspire.Hosting` package + [`examples/Aspire/SCDMS.AppHost`](../examples/Aspire/SCDMS.AppHost/) | Local development & cloud-native orchestration | + +Both topologies share the same data path: + +```text +Browser ──► SCDMS (web UI) ──gRPC/TLS──► SharpCoreDB server + β”‚ β”‚ + plain HTTP :8080 gRPC :5001 (TLS only) + (TLS at reverse proxy + HTTPS API :8443 + in production) +``` + +All SCDMS ⇄ SharpCoreDB data traffic flows over **gRPC**. SCDMS never talks to the server's +HTTPS management API. + +--- + +## 1. Read this first: how TLS works (it decides what you can test) + +- The SharpCoreDB server **only speaks TLS 1.2+** and refuses to start without a certificate + and a JWT secret (`Server__Security__*` settings). +- SCDMS runs **plain HTTP on port 8080** inside a container. In production a reverse proxy + terminates TLS for the browser. +- When SCDMS connects to the server over gRPC it **validates the server's certificate against + the OS trust store**. No "skip certificate verification" switch exists. + +Consequences for testing: + +| Scenario | Full end-to-end (SCDMS auto-connect) | Why | +|---|---|---| +| Compose with **public domain** | βœ… Works | Caddy holds a publicly trusted (Let's Encrypt) cert; SCDMS validates it | +| Compose/Aspire with `localhost` + self-signed dev cert | ⚠️ Partial | Browser warns; SCDMS cannot validate the self-signed server cert | +| Server container + SCDMS **run on the host** with a **trusted** dev cert | βœ… Works | `dotnet dev-certs https --trust` adds the dev CA to the OS store; SCDMS on the host validates it | + +So: use a **public domain** for a fully green Compose test, or use a **trusted local dev +certificate** with SCDMS running on the host for a green data-path test. Both recipes are below. + +--- + +## 2. Prerequisites + +- **Docker** (Docker Desktop or a Linux engine) with the `docker compose` plugin, engine running. +- **.NET 10 SDK** (the repo's `global.json` pins `10.0.400`). +- Ports free: `80`/`443` (Compose/Caddy), `5001`/`8443` (server), `8080` (SCDMS), + plus dynamically allocated Aspire ports. +- Container images: + + - `ghcr.io/mpcoredeveloper/sharpcoredb-server` β€” **published** (tag `2.0.0.2` / `latest`). + - `ghcr.io/mpcoredeveloper/scdms` β€” built on `v*` tags. **Not published yet** β†’ build locally: + + ```bash + cd /SCDMS + docker build -t ghcr.io/mpcoredeveloper/scdms:latest . + ``` + +--- + +## 3. Option A β€” Docker Compose (production sample) + +The primary sample in [`samples/docker/`](../samples/docker/) runs SharpCoreDB server, SCDMS and +**Caddy** (automatic Let's Encrypt TLS). Two drop-in alternatives with the identical topology and +wiring β€” but manual certificate provisioning instead of automatic TLS β€” are in +[`samples/yarp/`](../samples/yarp/) (all-.NET) and [`samples/haproxy/`](../samples/haproxy/) +(industry-standard HAProxy). + +```text +Browser ──HTTPS (public cert)──► Caddy ──HTTP──► scdms:8080 +SCDMS ──gRPC https://──► Caddy ──gRPC/TLS──► sharpcoredb:5001 +``` + +### 3.1 Configure + +```bash +cd samples/docker +cp .env.example .env # PowerShell: Copy-Item .env.example .env +``` + +Edit `.env`: + +| Variable | Value | +|---|---| +| `SCDMS_DOMAIN` | public hostname for the SCDMS UI, e.g. `scdms.example.com` | +| `GRPC_DOMAIN` | public hostname for the gRPC endpoint, e.g. `scdb.example.com` | +| `ACME_EMAIL` | your e-mail (Let's Encrypt notifications) | +| `SERVER_TLS_CERT_PATH` | `/app/certs/server.pfx` (keep default) | +| `SERVER_JWT_SECRET` | **random secret β‰₯ 32 characters** | +| `SDB_USERNAME` / `SDB_PASSWORD` | login SCDMS uses against the server | + +### 3.2 Provide the server certificate + +Place the server TLS certificate in `./server-certs/`. For testing you can create a development +PFX (see the SharpCoreDB +[`QUICKSTART.md`](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/server/QUICKSTART.md) +for the authoritative dev-cert instructions): + +```bash +mkdir -p server-certs +dotnet dev-certs https -ep server-certs/server.pfx -p devonly --trust +``` + +> A self-signed dev certificate only works for **local** testing. For the fully working Compose +> experience you need a real public domain so Let's Encrypt can issue a publicly trusted +> certificate β€” then SCDMS can validate it. + +### 3.3 Start & verify + +```bash +docker compose up -d +docker compose ps # all three services "healthy" +``` + +Health checks (run from `samples/docker/`): + +```bash +# SCDMS internal health (inside its container) +docker compose exec scdms curl -fs http://localhost:8080/health + +# SharpCoreDB server health (inside its container, self-signed = -k) +docker compose exec sharpcoredb curl -fsk https://localhost:8443/api/v1/health + +# Logs +docker compose logs -f scdms +``` + +Expected results: + +- `docker compose ps` shows `caddy`, `scdms`, `sharpcoredb` all `healthy` (give it ~30-60 s). +- Open `https://` β€” SCDMS UI loads and, with public domains, auto-connects to the + server (left sidebar shows the `master` database and/or the databases list). +- `sharpcoredb` log line: `πŸš€ Primary protocol (flagship): gRPC … Endpoint: https://0.0.0.0:5001`. + +### 3.4 Local-only smoke test (no public domain) + +Point the browser at `https://localhost` instead and accept the browser warning. The +**containers** will still be healthy and the SCDMS UI reachable; SCDMS's automatic gRPC connect +may fail certificate validation β€” that is expected and is exactly the TLS caveat above. Use the +full end-to-end recipe (Β§5) for a green data-path test. + +### 3.5 Stop + +```bash +docker compose down # add -v to also delete the data volumes +``` + +### 3.6 Proxy alternatives: YARP & HAProxy + +Prefer a different proxy than Caddy? Two drop-in samples with the identical wiring are included: + +**YARP β€” all-.NET** ([`samples/yarp/`](../samples/yarp/)): builds a small `Yarp.ReverseProxy` +2.3.0 proxy on Kestrel (~150 lines of C#, host-based routes). TLS is terminated with a mounted +PFX; the proxy skips certificate validation only towards the *internal* server +(`DangerousAcceptAnyServerCertificate`, mirroring Caddy's `tls_insecure_skip_verify`). + +```bash +cd samples/yarp +cp .env.example .env # set SCDMS_DOMAIN/GRPC_DOMAIN (localhost for local tests) +mkdir -p server-certs +dotnet dev-certs https -ep server-certs/server.pfx -p devonly +docker compose up -d --build # builds the yarp proxy image +``` + +**HAProxy β€” industry standard** ([`samples/haproxy/`](../samples/haproxy/)): the most widely +deployed proxy/load balancer; SNI-based routing in `haproxy.cfg`. TLS is terminated with a +mounted **PEM** (private key + certificate): + +```bash +cd samples/haproxy +cp .env.example .env # set SCDMS_DOMAIN/GRPC_DOMAIN (localhost for local tests) +mkdir -p server-certs +dotnet dev-certs https -ep server-certs/server.pfx -p devonly +openssl pkcs12 -in server-certs/server.pfx -out server-certs/haproxy.pem -nodes -passin pass:devonly +docker compose up -d +``` + +For **fully automatic** Let's Encrypt certificates the same folder ships an ACME variant +(`docker-compose.acme.yml`): HAProxy answers `http-01` challenges via the janeczku Lua plugin, a +certbot companion issues/renews the certificate, and the entrypoint reloads gracefully when the +PEM changes. Requires publicly reachable domains + ports 80/443 β€” see +[`samples/haproxy/README.md`](../samples/haproxy/README.md). + +Caddy is the only proxy with certificate management built in; the HAProxy ACME variant automates +it as well. SCDMS always validates the proxy's *public* certificate β€” for a fully green local +data-path test use the trusted-dev recipe (Β§5). Full details: +[`samples/yarp/README.md`](../samples/yarp/README.md) and +[`samples/haproxy/README.md`](../samples/haproxy/README.md). + +--- + +## 4. Option B β€” .NET Aspire (SCDMS.Aspire.Hosting) + +The AppHost example in +[`examples/Aspire/SCDMS.AppHost`](../examples/Aspire/SCDMS.AppHost/) is backed by the +**`SCDMS.Aspire.Hosting`** NuGet package (`AddSharpCoreDB` + `AddSCDMS` + `WithGrpcReference`). +You can use the same package in your own Aspire app. + +### 4.1 Prepare images & certificate + +```bash +cd /SCDMS + +# 1) SCDMS image (until the official image is published) +docker build -t ghcr.io/mpcoredeveloper/scdms:latest . + +# 2) Pull the published server image +docker pull ghcr.io/mpcoredeveloper/sharpcoredb-server:2.0.0.2 + +# 3) Development certificate for the server container +cd examples/Aspire/SCDMS.AppHost +mkdir -p certs +dotnet dev-certs https -ep certs/server.pfx -p devonly +``` + +The AppHost automatically mounts `certs/server.pfx` when present and points +`Server__Security__TlsCertificatePath` at it. It also uses a dev JWT secret β€” override with the +`SCDMS_SERVER_JWT_SECRET` environment variable if you want your own (β‰₯ 32 chars). + +### 4.2 Run the AppHost + +```bash +dotnet run # from examples/Aspire/SCDMS.AppHost +``` + +The Aspire dashboard opens in the browser. Expect: + +- Two resources: **`db`** (SharpCoreDB server; endpoints `grpc`, `https`) and **`admin`** + (SCDMS; endpoint `http`), both **Running**. +- Click `admin` β†’ open the **http** endpoint to see the SCDMS web UI. +- SCDMS has received the gRPC link as environment variables. Verify: + +```bash +# find the SCDMS container created by Aspire +docker ps --format '{{.Names}}\t{{.Image}}' | grep scdms +docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^SCDMS__' +``` + +You should see `SCDMS__DefaultServerHost`, `SCDMS__DefaultServerPort`, +`SCDMS__DefaultServerUseSsl=true`, `SCDMS__DefaultServerAutoConnect=true` plus the container +defaults (`SCDMS__EnableHttps=false`, `SCDMS__DataDirectory=/app/data`, …). + +> With the self-signed dev certificate, SCDMS inside the container cannot validate the server +> certificate, so the *automatic* gRPC connect may fail in this pure container dev setup. The +> wiring is correct (see the environment variables above); use Β§5 for a green data-path test. + +### 4.3 Stop + +Stop the AppHost with `Ctrl+C` in the terminal. Containers are removed automatically +(`docker ps` afterwards should no longer list them). + +--- + +## 5. Verify the wiring end-to-end (recommended local recipe) + +This gives a **green** data-path test without needing a public domain: the server runs in the +published container, SCDMS runs on your machine and validates the server's dev certificate +because you trust it on the host. + +> Run the commands below from `samples/docker/` (where Β§3.2 created `server-certs/server.pfx`), +> or change the `-v` source so it points at whichever folder holds your PFX (e.g. the AppHost +> `certs/` directory from Β§4.1). + +```bash +# 1) Server container on host ports, using the dev PFX from Β§3.2/Β§4.1 +docker run -d --name scdb-test \ + -p 5001:5001 -p 8443:8443 \ + -e Server__Security__JwtSecretKey="some-random-secret-of-at-least-32-chars!" \ + -e Server__Security__TlsCertificatePath=/certs/server.pfx \ + -e Server__SystemDatabases__Enabled=true \ + -v "$(pwd)/server-certs:/certs:ro" \ + ghcr.io/mpcoredeveloper/sharpcoredb-server:2.0.0.2 + +# 2) Health check the server +curl -fsk https://localhost:8443/api/v1/health + +# 3) Trust the dev certificate on the host (once) +dotnet dev-certs https --trust +``` + +Then start SCDMS from source with the default-server settings pointing at the container: + +```bash +# PowerShell +$env:SCDMS__DefaultServerHost="localhost" +$env:SCDMS__DefaultServerPort="5001" +$env:SCDMS__DefaultServerAutoConnect="true" +dotnet run --project src/SCDMS/SCDMS.csproj +``` + +```bash +# bash +export SCDMS__DefaultServerHost=localhost +export SCDMS__DefaultServerPort=5001 +export SCDMS__DefaultServerAutoConnect=true +dotnet run --project src/SCDMS/SCDMS.csproj +``` + +Open `https://localhost:5443` (accept SCDMS's own self-signed UI certificate once): + +- The status bar shows a **connected** server session (`localhost:5001/master`). +- The sidebar lists the **`master`** database. +- Run `SELECT * FROM ...` in the SQL editor to confirm gRPC query execution. + +Clean up when done: `docker rm -f scdb-test`. + +--- + +## 6. Use `SCDMS.Aspire.Hosting` in your own AppHost + +Once published, add the package to your Aspire host: + +```bash +dotnet add package SCDMS.Aspire.Hosting +``` + +```csharp +using Scdms.Aspire.Hosting; +using SharpCoreDB.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +var db = builder.AddSharpCoreDB("db") + .WithServerContainer() + .WithJwtSecret("your-random-secret-of-at-least-32-chars"); + +builder.AddSCDMS("admin", db); // SCDMS container auto-wired to the server over gRPC + +builder.Build().Run(); +``` + +`AddSCDMS` without the `db` argument starts SCDMS standalone (no default server). Both overloads +accept an optional `imageTag` and a fixed host `port` for the HTTP endpoint. + +--- + +## 7. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `sharpcoredb` restarts / never healthy | No certificate or no JWT secret | Check `docker compose logs sharpcoredb`. Generate the PFX (`dotnet dev-certs https -ep server-certs/server.pfx -p devonly`), keep `SERVER_JWT_SECRET` β‰₯ 32 chars. If the PFX cannot be loaded, see the server certificate docs (password/format). | +| `scdms` unhealthy | Wrong container env or data volume permissions | Check `docker compose logs scdms`. Required: `SCDMS__EnableHttps=false`, `SCDMS__BindAddress=0.0.0.0`, writable `/app/data` (container runs as uid 1000). | +| Caddy logs certificate errors | Domain does not resolve publicly, or ports 80/443 blocked | Use real public DNS + open ports for Let's Encrypt, or switch to the local `localhost` smoke test (Β§3.4). | +| SCDMS UI opens but shows a server connection error | SCDMS cannot validate a self-signed server certificate | Expected with self-signed dev certs. Use a public-domain proxy (Option A) or the trusted local recipe (Β§5). | +| Aspire resources run but SCDMS does not connect | Same certificate validation + wrong `SCDMS__DefaultServer*` | Verify env via `docker inspect` (Β§4.2); for a green connect use Β§5. | +| `dotnet` uses the wrong SDK version | Machine default is not .NET 10 | The repo's `global.json` requires a .NET 10 SDK (10.0.400). Install it; VS Code/`dotnet` pick it up automatically. | +| `docker compose`/Aspire says image not found for `scdms` | Official SCDMS image not published yet | Build it locally: `docker build -t ghcr.io/mpcoredeveloper/scdms:latest .` | + +--- + +## 8. Related documentation + +- [`docs/aspire.md`](aspire.md) β€” .NET Aspire design & status (issue #10) +- [`docs/usage.md`](usage.md) β€” SCDMS configuration, environment variables, connection modes +- [`samples/docker/README.md`](../samples/docker/README.md) β€” Compose sample notes (Caddy) +- [`samples/yarp/README.md`](../samples/yarp/README.md) β€” Compose sample notes (YARP, all-.NET) +- [`samples/haproxy/README.md`](../samples/haproxy/README.md) β€” Compose sample notes (HAProxy, industry standard) +- [`examples/Aspire/SCDMS.AppHost/README.md`](../examples/Aspire/SCDMS.AppHost/README.md) β€” AppHost example notes +- SharpCoreDB side: [`ASPIRE_INTEGRATION.md`](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/server/ASPIRE_INTEGRATION.md) and [`QUICKSTART.md`](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/server/QUICKSTART.md) + + + diff --git a/docs/usage.md b/docs/usage.md index f9237b1..0cacf6b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -83,6 +83,10 @@ SCDMS validates the public certificate, so no custom CA or certificate-skip logi Mount a volume at the `SCDMS__DataDirectory` location (`/app/data` in the official image) to persist settings, saved queries/history and built-in databases across container restarts. +### .NET Aspire integration + +A `SCDMS.Aspire.Hosting` NuGet package plus a runnable [AppHost example](../examples/Aspire/SCDMS.AppHost/) run SharpCoreDB server + SCDMS as one .NET Aspire application (all SCDMS ⇄ SharpCoreDB traffic over gRPC). Design, status and the TLS-in-development notes: [docs/aspire.md](aspire.md). + ## Security posture SCDMS is configured secure-by-default: diff --git a/examples/Aspire/SCDMS.AppHost/Program.cs b/examples/Aspire/SCDMS.AppHost/Program.cs new file mode 100644 index 0000000..4aace16 --- /dev/null +++ b/examples/Aspire/SCDMS.AppHost/Program.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using Aspire.Hosting; +using Scdms.Aspire.Hosting; +using SharpCoreDB.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// JWT secret for the dev server (min. 32 chars). Override with SCDMS_SERVER_JWT_SECRET to keep +// it stable across runs; otherwise a random per-run secret is generated (dev only - JWT tokens +// do not survive restarts). +var jwtSecret = builder.Configuration["SCDMS_SERVER_JWT_SECRET"]; +if (string.IsNullOrWhiteSpace(jwtSecret)) +{ + jwtSecret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); +} + +// SharpCoreDB network server container (HTTPS gRPC on container port 5001, HTTPS REST API on +// container port 8443) from ghcr.io/mpcoredeveloper/sharpcoredb-server. Pin a specific image +// tag with the SCDB_IMAGE_TAG environment variable; defaults to the published "latest" tag. +var sharpCoreDb = builder.AddSharpCoreDB("db") + .WithServerContainer() + .WithJwtSecret(jwtSecret); + +if (builder.Configuration["SCDB_IMAGE_TAG"] is { Length: > 0 } serverImageTag) +{ + sharpCoreDb = sharpCoreDb.WithImageTag(serverImageTag); +} + +// Optional local development certificate. Drop certs/server.pfx next to this project +// (generate with: dotnet dev-certs https -ep certs/server.pfx -p devonly) and the server +// container mounts and uses it. The server only speaks TLS and will not start without a +// certificate. See README.md for the TLS notes. +var certDirectory = Path.Combine(Environment.CurrentDirectory, "certs"); +var certFile = Path.Combine(certDirectory, "server.pfx"); +if (Directory.Exists(certDirectory) && File.Exists(certFile)) +{ + sharpCoreDb + .WithBindMount(certDirectory, "/app/certs", isReadOnly: true) + .WithEnvironment("Server__Security__TlsCertificatePath", "/app/certs/server.pfx"); +} + +// SCDMS web studio container (plain HTTP on container port 8080), linked to the server over gRPC +// through the SCDMS__DefaultServer* environment variables (see ScdmsAspireExtensions). +builder.AddSCDMS("admin", sharpCoreDb); + +await builder.Build().RunAsync(); + diff --git a/examples/Aspire/SCDMS.AppHost/README.md b/examples/Aspire/SCDMS.AppHost/README.md new file mode 100644 index 0000000..f0a9888 --- /dev/null +++ b/examples/Aspire/SCDMS.AppHost/README.md @@ -0,0 +1,70 @@ +# SCDMS + SharpCoreDB β€” .NET Aspire sample (gRPC) + +This Aspire host runs SharpCoreDB server and SCDMS as one application, the same topology as the +production Docker Compose sample in [`samples/docker/`](../../../samples/docker/README.md): + +- **SharpCoreDB server** container (`ghcr.io/mpcoredeveloper/sharpcoredb-server`) β€” HTTPS gRPC + on container port 5001, HTTPS REST API on container port 8443. +- **SCDMS** web studio container (`ghcr.io/mpcoredeveloper/scdms`) β€” plain HTTP on container + port 8080, auto-wired to the server over **gRPC**. + +All SCDMS ⇄ SharpCoreDB data traffic flows over gRPC. + +## Prerequisites + +- .NET 10 SDK and Docker (with a running Docker engine). +- SharpCoreDB server image: published (`ghcr.io/mpcoredeveloper/sharpcoredb-server`). +- SCDMS image: defaults to `ghcr.io/mpcoredeveloper/scdms:latest`. Until that image is + published, build it locally from the repository root: + + ```bash + docker build -t ghcr.io/mpcoredeveloper/scdms:latest . + ``` + +## Run + +The SharpCoreDB server only speaks TLS and requires a certificate. For a local run, generate a +development certificate and drop it next to this project (`certs/server.pfx`); the AppHost +mounts it automatically when present: + +```bash +# from the SCDMS.AppHost folder so ./certs resolves: +cd examples/Aspire/SCDMS.AppHost +dotnet dev-certs https -ep certs/server.pfx -p devonly +dotnet run +``` + +Optional overrides: + +```bash +# different JWT secret for the dev server (min. 32 characters) +SCDMS_SERVER_JWT_SECRET="some-random-32-char-secret!" dotnet run + +# pin a specific SharpCoreDB server image tag instead of "latest" +SCDB_IMAGE_TAG="2.0.0.2" dotnet run +``` + +The Aspire dashboard opens automatically; the resources (`db`, `admin`) and their endpoints are +listed there. Open the SCDMS **http** endpoint to reach the web studio. + +> Note: with the self-signed `dotnet dev-certs` certificate, the *browser* will warn about the +> SCDMS endpoint certificate **only if** you open the dashboard/SCDMS over TLS. SCDMS itself +> serves plain HTTP inside the container. Because SCDMS validates the server certificate, an +> end-to-end auto-connect over gRPC in this container-only dev setup requires a certificate that +> is trusted by the SCDMS container β€” see the TLS section below. + +## TLS & production notes + +- In **production**, terminate TLS at a reverse proxy holding a publicly trusted certificate + (Let's Encrypt) and let SCDMS reach the gRPC endpoint through that proxy β€” exactly what the + [`samples/docker/`](../../../samples/docker/README.md) compose sample does. +- The SharpCoreDB server always enforces TLS 1.2+ and refuses to start without a certificate + and a JWT secret (`WithJwtSecret`). For local development you can mount a `dotnet dev-certs` + PFX as shown above; see the SharpCoreDB + [`ASPIRE_INTEGRATION.md`](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/server/ASPIRE_INTEGRATION.md) + for the full server-side development-certificate story. +- Persist SCDMS state by mounting a volume on `/app/data` + (`scdms.WithVolume(...)`/`WithBindMount(...)`); by default state is ephemeral. + +A full end-to-end test guide (both Docker Compose and Aspire) lives in +[`docs/container-and-aspire-guide.md`](../../../docs/container-and-aspire-guide.md). diff --git a/examples/Aspire/SCDMS.AppHost/SCDMS.AppHost.csproj b/examples/Aspire/SCDMS.AppHost/SCDMS.AppHost.csproj new file mode 100644 index 0000000..801d75a --- /dev/null +++ b/examples/Aspire/SCDMS.AppHost/SCDMS.AppHost.csproj @@ -0,0 +1,39 @@ + + + + false + + + + + + Exe + net10.0 + 14.0 + enable + enable + false + + + + + + + + + + + + + + + + $(NoWarn);ASPIRE010 + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..5269c28 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/samples/docker/README.md b/samples/docker/README.md index 1345bce..552632d 100644 --- a/samples/docker/README.md +++ b/samples/docker/README.md @@ -23,11 +23,11 @@ to the server's internal certificate, so no custom CA is needed inside SCDMS. The compose file references published images by default: -- `ghcr.io/mpcoredeveloper/scdms:latest` -- `ghcr.io/mpcoredeveloper/sharpcoredb-server:latest` +- `ghcr.io/mpcoredeveloper/scdms:latest` β€” built by the `docker-publish.yml` workflow on every + `v*` tag. Until it is published, build it locally (see below). +- `ghcr.io/mpcoredeveloper/sharpcoredb-server:latest` β€” **published** (built on every `v*` tag). -Until those images are published (the `docker-publish.yml` workflow builds SCDMS on a -`v*` tag), build them locally by uncommenting the `build:` blocks in `docker-compose.yml`: +To build the SCDMS image locally, uncomment the SCDMS `build:` block in `docker-compose.yml`: ```yaml # for scdms @@ -35,7 +35,7 @@ build: context: ../.. dockerfile: Dockerfile -# for sharpcoredb +# for sharpcoredb (optional: only needed when you want to build the server image yourself) build: context: /path/to/SharpCoreDB dockerfile: src/SharpCoreDB.Server/Dockerfile diff --git a/samples/docker/docker-compose.yml b/samples/docker/docker-compose.yml index 506a5b3..bb0a006 100644 --- a/samples/docker/docker-compose.yml +++ b/samples/docker/docker-compose.yml @@ -13,9 +13,9 @@ # docker compose up -d # # Prerequisites: -# - Published images ghcr.io/mpcoredeveloper/scdms and -# ghcr.io/mpcoredeveloper/sharpcoredb-server. Until they are published you can -# build them locally (see the commented `build:` blocks below). +# - Published image ghcr.io/mpcoredeveloper/sharpcoredb-server (available). +# - SCDMS image ghcr.io/mpcoredeveloper/scdms: published on v* tags; until then build it +# locally by uncommenting the scdms `build:` block below. services: caddy: @@ -66,7 +66,8 @@ services: sharpcoredb: image: ghcr.io/mpcoredeveloper/sharpcoredb-server:latest - # Until the image is published, build it from a local SharpCoreDB checkout: + # The image is published on every v* tag. To build it from a local SharpCoreDB checkout, + # uncomment: # build: # context: /path/to/SharpCoreDB # dockerfile: src/SharpCoreDB.Server/Dockerfile diff --git a/samples/haproxy/.env.example b/samples/haproxy/.env.example new file mode 100644 index 0000000..900ec4d --- /dev/null +++ b/samples/haproxy/.env.example @@ -0,0 +1,31 @@ +# ── Proxy hostnames ── +# For local testing use localhost for both (one dev certificate covers both). +# For production use the real public hostnames; the PEM must cover both names. +SCDMS_DOMAIN=scdms.example.com +GRPC_DOMAIN=scdb.example.com + +# ── Automatic certificates (ACME variant only) ── +# docker-compose.acme.yml obtains/renews a Let's Encrypt certificate automatically. The domains +# above must resolve publicly to this host and ports 80/443 must be reachable. +# e.g. ACME_EMAIL=admin@example.com +ACME_EMAIL= + +# ── HAProxy TLS ── +# HAProxy terminates TLS for the browser UI and the SCDMS gRPC client and needs a PEM file +# (private key + certificate [+ chain]). For local testing, generate the dev PFX and convert +# it to PEM: +# mkdir -p server-certs +# dotnet dev-certs https -ep server-certs/server.pfx -p devonly +# openssl pkcs12 -in server-certs/server.pfx -out server-certs/haproxy.pem -nodes -passin pass:devonly +# (Windows without openssl: use Git Bash, WSL, or any openssl binary.) +# Production: place your public certificate (e.g. certbot fullchain.pem + privkey.pem) at +# server-certs/haproxy.pem β€” or use docker-compose.acme.yml for fully automatic issuance. + +# ── SharpCoreDB server credentials ── +# The SharpCoreDB server uses its own (internal) certificate from the same server-certs folder. +SERVER_TLS_CERT_PATH=/app/certs/server.pfx +SERVER_JWT_SECRET=replace-with-a-random-32-char-secret + +# ── SCDMS default-server login used against the SharpCoreDB server ── +SDB_USERNAME=anonymous +SDB_PASSWORD= diff --git a/samples/haproxy/.gitignore b/samples/haproxy/.gitignore new file mode 100644 index 0000000..af24271 --- /dev/null +++ b/samples/haproxy/.gitignore @@ -0,0 +1,4 @@ +# Runtime state for the ACME variant (certificates, webroot tokens, certbot account state). +# Never commit these. +acme-state/ +server-certs/ diff --git a/samples/haproxy/README.md b/samples/haproxy/README.md new file mode 100644 index 0000000..98240a4 --- /dev/null +++ b/samples/haproxy/README.md @@ -0,0 +1,136 @@ +# SCDMS + SharpCoreDB β€” HAProxy reverse proxy sample + +HAProxy is the battle-tested, industry-standard load balancer / reverse proxy. This sample +runs the same topology as [`samples/docker/`](../docker/) and [`samples/yarp/`](../yarp/) but +terminates TLS with **HAProxy** instead of Caddy/YARP: + +```text +Browser ──HTTPS (cert on HAProxy)──► haproxy ──HTTP──► scdms:8080 (SCDMS web UI) +SCDMS ──gRPC https://──► haproxy ──gRPC/TLS──► sharpcoredb:5001 +``` + +All SCDMS ⇄ SharpCoreDB data traffic flows over **gRPC**. The proxy terminates TLS for both the +SCDMS web UI and the SCDMSβ†’server gRPC channel, then re-encrypts to the SharpCoreDB server's +internal certificate (`ssl verify none`, mirroring Caddy's `tls_insecure_skip_verify`). + +## Caddy vs. YARP vs. HAProxy + +| | Caddy (`samples/docker/`) | YARP (`samples/yarp/`) | HAProxy (this folder) | +|---|---|---|---| +| Proxy | `caddy:2.9` official image | Your .NET image (`Yarp.ReverseProxy`) | `haproxy:3.0` official image | +| TLS certificates | **Automatic** Let's Encrypt | Manual PFX | Manual **PEM** | +| Config | Caddyfile | C# code | `haproxy.cfg` (templated) | +| Language | Go | 100% .NET/C# | C | +| Sweet spot | Zero-maintenance auto-TLS | Everything in .NET | Industry-standard proxy/LB, huge ecosystem | + +Choose HAProxy when you (or your ops team) already standardize on it β€” it is the most widely +deployed option and supports advanced LB features (health checks, stickiness, routing policies). + +## Quick start + +1. Make the images available: + + ```bash + cd /SCDMS + docker build -t ghcr.io/mpcoredeveloper/scdms:latest . # until the official image is published + docker pull ghcr.io/mpcoredeveloper/sharpcoredb-server:2.0.0.2 + ``` + +2. Configure and create the TLS certificate. HAProxy needs a **PEM** (key + certificate); + for local testing convert the dev PFX: + + ```bash + cd samples/haproxy + cp .env.example .env # PowerShell: Copy-Item .env.example .env + mkdir -p server-certs + dotnet dev-certs https -ep server-certs/server.pfx -p devonly + openssl pkcs12 -in server-certs/server.pfx -out server-certs/haproxy.pem -nodes -passin pass:devonly + ``` + + Edit `.env`: set `SCDMS_DOMAIN=localhost` and `GRPC_DOMAIN=localhost` for local testing, or + your real public hostnames in production (the PEM must cover both hostnames). + +3. Start everything: + + ```bash + docker compose up -d + docker compose ps # haproxy, scdms, sharpcoredb all "healthy" + ``` + +## Verify + +```bash +# HAProxy (stats socket, socat) +docker compose exec haproxy sh -c "echo 'show info' | socat unix-connect:/var/lib/haproxy/admin.sock stdio" | head + +# SCDMS health (through the proxy) +docker compose exec scdms curl -fs http://localhost:8080/health + +# SharpCoreDB server health (internal self-signed cert = -k) +docker compose exec sharpcoredb curl -fsk https://localhost:8443/api/v1/health + +# Logs +docker compose logs -f haproxy scdms +``` + +- Browser: `https://` (accept the self-signed warning when using `localhost`). +- The HAProxy log lines show the SNI-based backend selection (`scdms_ui` / `scdb_grpc`). + +## TLS notes + +- **SCDMS validates the certificate of the gRPC endpoint it connects to.** In production the + mounted PEM must be publicly trusted (as with any proxy). With a self-signed `localhost` cert + the browser warns and SCDMS's automatic gRPC connect cannot validate the proxy cert β€” the same + TLS caveat as the other samples (see + [`docs/container-and-aspire-guide.md`](../../docs/container-and-aspire-guide.md), Β§5 for a + green local data-path test). +- The proxy skips certificate validation only towards the *internal* SharpCoreDB server + (`ssl verify none`). That switch never affects what SCDMS validates. +- The container runs as the non-root `haproxy` user; binding 80/443 needs the + `NET_BIND_SERVICE` capability (added in `docker-compose.yml`). + +## Fully automatic certificates (ACME variant) + +`docker-compose.acme.yml` runs the same stack but manages the HAProxy certificate +**automatically** with Let's Encrypt β€” no manual PEM, no manual renewal: + +- **HAProxy** (custom image `./acme`, based on `haproxy:3.0` + `openssl`) loads the + [janeczku HAProxy ACME Lua plugin](https://github.com/janeczku/haproxy-acme-validation-plugin) + (`./acme/acme-http01-webroot.lua`) which answers `http-01` challenges on port 80 from the + shared `/webroot` folder. Its entrypoint creates a temporary self-signed bootstrap cert when + none exists yet and **gracefully reloads (SIGUSR2)** whenever the PEM file changes. +- **Certbot companion** (`certbot/certbot`) issues/renews the certificate every 12 h for + `SCDMS_DOMAIN` + `GRPC_DOMAIN` (webroot method). After every successful issue/renewal its + deploy hook (`./acme/certbot-deploy.sh`) combines `privkey.pem` + `fullchain.pem` into + `/certs/haproxy.pem`. + +Requirements: `SCDMS_DOMAIN` and `GRPC_DOMAIN` must **resolve publicly** to this host and ports +`80`/`443` must be reachable (that is how Let's Encrypt validates ownership). Set `ACME_EMAIL` +in `.env`. Local `localhost` testing still uses the manual compose file above. + +```bash +cd samples/haproxy +cp .env.example .env # set real domains + ACME_EMAIL + secrets +mkdir -p server-certs # dev PFX for the SharpCoreDB server's internal TLS +dotnet dev-certs https -ep server-certs/server.pfx -p devonly +docker compose -f docker-compose.acme.yml up -d --build +``` + +Check the flow: + +```bash +docker compose -f docker-compose.acme.yml ps # haproxy, certbot, scdms, sharpcoredb +docker compose -f docker-compose.acme.yml logs -f certbot # issuance/renewal activity +docker compose -f docker-compose.acme.yml logs haproxy # "[entrypoint] certificate changed - graceful reload" +# after issuance, the real certificate is live on https:// and https:// +``` + +State (live `haproxy.pem`, challenge webroot, certbot account) lives in the Docker named volumes +`haproxy-certs`, `haproxy-webroot` and `letsencrypt`. Reset with +`docker compose -f docker-compose.acme.yml down -v`. + +## Stop + +```bash +docker compose down # add -v to also delete the data volumes +``` diff --git a/samples/haproxy/acme/Dockerfile b/samples/haproxy/acme/Dockerfile new file mode 100644 index 0000000..9c280ad --- /dev/null +++ b/samples/haproxy/acme/Dockerfile @@ -0,0 +1,19 @@ +# ── HAProxy ACME variant runtime image ── +# Same as the official haproxy:3.0 image plus the openssl CLI used by entrypoint.sh to create +# the temporary bootstrap certificate. The Lua plugin, config template and scripts are mounted +# read-only by docker-compose.acme.yml. +# +# The container keeps the official image's non-root 'haproxy' user. /certs and /webroot are +# pre-created here so that Docker named volumes (see docker-compose.acme.yml) inherit their +# ownership. Binding 80/443 requires the NET_BIND_SERVICE capability (cap_add in compose). + +FROM haproxy:3.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /certs /webroot \ + && chown haproxy:haproxy /certs /webroot + +ENTRYPOINT ["/bin/sh", "/usr/local/etc/haproxy/acme-entrypoint.sh"] + diff --git a/samples/haproxy/acme/acme-http01-webroot.lua b/samples/haproxy/acme/acme-http01-webroot.lua new file mode 100644 index 0000000..71ba424 --- /dev/null +++ b/samples/haproxy/acme/acme-http01-webroot.lua @@ -0,0 +1,91 @@ +-- ACME http-01 domain validation plugin for HAProxy 1.6+ +-- Upstream: https://github.com/janeczku/haproxy-acme-validation-plugin +-- copyright (C) 2015 Jan Broer (MIT License) +-- +-- SCDMS sample: vendored copy with the webroot path pinned to /webroot (shared volume with the +-- certbot companion container). The upstream file is configured by setting +-- acme.conf.non_chroot_webroot below; no other changes were made. + +acme = {} +acme.version = "0.1.1" + +-- +-- Configuration +-- +-- When HAProxy is *not* configured with the 'chroot' option you must set an absolute path here and pass +-- that as 'webroot-path' to the letsencrypt client + +acme.conf = { + ["non_chroot_webroot"] = "/webroot" +} + +-- +-- Startup +-- +acme.startup = function() + core.Info("[acme] http-01 plugin v" .. acme.version); +end + +-- +-- ACME http-01 validation endpoint +-- +acme.http01 = function(applet) + local response = "" + local reqPath = applet.path + local src = applet.sf:src() + local token = reqPath:match( ".+/(.*)$" ) + + if token then + token = sanitizeToken(token) + end + + if (token == nil or token == '') then + response = "bad request\n" + applet:set_status(400) + core.Warning("[acme] malformed request (client-ip: " .. tostring(src) .. ")") + else + auth = getKeyAuth(token) + if (auth:len() >= 1) then + response = auth .. "\n" + applet:set_status(200) + core.Info("[acme] served http-01 token: " .. token .. " (client-ip: " .. tostring(src) .. ")") + else + response = "resource not found\n" + applet:set_status(404) + core.Warning("[acme] http-01 token not found: " .. token .. " (client-ip: " .. tostring(src) .. ")") + end + end + + applet:add_header("Server", "haproxy/acme-http01-authenticator") + applet:add_header("Content-Length", string.len(response)) + applet:add_header("Content-Type", "text/plain") + applet:start_response() + applet:send(response) +end + +-- +-- strip chars that are not in the URL-safe Base64 alphabet +-- see https://github.com/letsencrypt/acme-spec/blob/master/draft-barnes-acme.md +-- +function sanitizeToken(token) + _strip="[^%a%d%+%-%_=]" + token = token:gsub(_strip,'') + return token +end + +-- +-- get key auth from token file +-- +function getKeyAuth(token) + local keyAuth = "" + local path = acme.conf.non_chroot_webroot .. "/.well-known/acme-challenge/" .. token + local f = io.open(path, "rb") + if f ~= nil then + keyAuth = f:read("*all") + f:close() + end + return keyAuth +end + +core.register_init(acme.startup) +core.register_service("acme-http01", "http", acme.http01) diff --git a/samples/haproxy/acme/certbot-deploy.sh b/samples/haproxy/acme/certbot-deploy.sh new file mode 100644 index 0000000..83f5a1e --- /dev/null +++ b/samples/haproxy/acme/certbot-deploy.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Certbot deploy hook (called after every successful issue/renewal). +# Combines the private key + full chain into the PEM that HAProxy uses (/certs/haproxy.pem). +# HAProxy does not need a signal from here: its entrypoint watches the PEM and triggers a +# graceful reload (SIGUSR2) when the file changes. +set -eu + +: "${RENEWED_LINEAGE:?RENEWED_LINEAGE is not set (certbot deploy hook context)}" + +cat "$RENEWED_LINEAGE/privkey.pem" "$RENEWED_LINEAGE/fullchain.pem" > /certs/haproxy.pem +echo "[certbot deploy] wrote /certs/haproxy.pem from $RENEWED_LINEAGE" diff --git a/samples/haproxy/acme/certbot-entrypoint.sh b/samples/haproxy/acme/certbot-entrypoint.sh new file mode 100644 index 0000000..55aca1a --- /dev/null +++ b/samples/haproxy/acme/certbot-entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Certbot companion loop for the HAProxy ACME variant: +# - issues/renews certificates for SCDMS_DOMAIN and GRPC_DOMAIN via http-01 (webroot), +# - runs the deploy hook (/bin/sh /hooks/certbot-deploy.sh) after every successful +# issue/renewal; the hook writes the combined HAProxy PEM, +# - retries every 12 hours (harmless while certificates are still valid thanks to +# --keep-until-expiring). +set -eu + +: "${ACME_EMAIL:?ACME_EMAIL is required (set it in samples/haproxy/.env)}" +: "${SCDMS_DOMAIN:?SCDMS_DOMAIN is required}" +: "${GRPC_DOMAIN:?GRPC_DOMAIN is required}" + +WEBROOT=/webroot +mkdir -p "$WEBROOT" + +echo "[certbot] starting companion for ${SCDMS_DOMAIN} + ${GRPC_DOMAIN}" + +while true; do + echo "[certbot] requesting certificates (http-01 via haproxy on :80)" + certbot certonly \ + --webroot -w "$WEBROOT" \ + -d "$SCDMS_DOMAIN" -d "$GRPC_DOMAIN" \ + --non-interactive \ + --agree-tos \ + --email "$ACME_EMAIL" \ + --keep-until-expiring \ + --deploy-hook "/bin/sh /hooks/certbot-deploy.sh" \ + || echo "[certbot] attempt failed - retrying in 12h (domains must resolve to this host and :80 must be reachable)" + + sleep 12h +done diff --git a/samples/haproxy/acme/entrypoint.sh b/samples/haproxy/acme/entrypoint.sh new file mode 100644 index 0000000..2029152 --- /dev/null +++ b/samples/haproxy/acme/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# HAProxy entrypoint for the ACME variant: +# 1) renders haproxy.cfg.tmpl (__SCDMS_DOMAIN__ / __GRPC_DOMAIN__) to /tmp/haproxy.cfg +# 2) creates a temporary self-signed certificate if /certs/haproxy.pem does not exist yet +# (HAProxy needs a cert to boot; the certbot companion replaces it with a real one) +# 3) starts HAProxy in master-worker mode (-W -db) and watches the PEM file: on change it +# sends SIGUSR2 (graceful reload re-reading the certificate) β€” no downtime. +set -eu + +SCDMS_DOMAIN="${SCDMS_DOMAIN:-scdms.example.com}" +GRPC_DOMAIN="${GRPC_DOMAIN:-scdb.example.com}" +CERT_PATH="${HAPROXY_PEM_PATH:-/certs/haproxy.pem}" +CHECK_INTERVAL="${HAPROXY_RELOAD_INTERVAL:-10}" + +# 1) render configuration +sed -e "s/__SCDMS_DOMAIN__/${SCDMS_DOMAIN}/g" \ + -e "s/__GRPC_DOMAIN__/${GRPC_DOMAIN}/g" \ + /usr/local/etc/haproxy/haproxy.cfg.tmpl > /tmp/haproxy.cfg + +# 2) bootstrap certificate (only when the certbot companion has not written one yet) +if [ ! -f "$CERT_PATH" ]; then + echo "[entrypoint] no certificate at $CERT_PATH - creating a temporary self-signed one (certbot will replace it)." + tmpdir="$(mktemp -d)" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$tmpdir/key.pem" -out "$tmpdir/cert.pem" \ + -days 30 \ + -subj "/CN=${SCDMS_DOMAIN}" \ + -addext "subjectAltName=DNS:${SCDMS_DOMAIN},DNS:${GRPC_DOMAIN}" 2>/dev/null + cat "$tmpdir/key.pem" "$tmpdir/cert.pem" > "$CERT_PATH" + rm -rf "$tmpdir" +fi + +# 3) start HAProxy (master-worker: SIGUSR2 = graceful reload) +haproxy -W -db -f /tmp/haproxy.cfg & +HAPROXY_PID=$! + +last_mtime="$(stat -c %Y "$CERT_PATH")" + +forward_shutdown() { + echo "[entrypoint] shutting down (signal to haproxy pid $HAPROXY_PID)" + kill -TERM "$HAPROXY_PID" 2>/dev/null || true + exit 0 +} +trap forward_shutdown TERM INT + +while kill -0 "$HAPROXY_PID" 2>/dev/null; do + current_mtime="$(stat -c %Y "$CERT_PATH")" + if [ "$current_mtime" != "$last_mtime" ]; then + echo "[entrypoint] certificate changed - graceful reload (SIGUSR2)" + kill -USR2 "$HAPROXY_PID" 2>/dev/null || true + last_mtime="$current_mtime" + fi + sleep "$CHECK_INTERVAL" +done + +echo "[entrypoint] haproxy exited unexpectedly" +exit 1 diff --git a/samples/haproxy/acme/haproxy.cfg.tmpl b/samples/haproxy/acme/haproxy.cfg.tmpl new file mode 100644 index 0000000..ce32a2d --- /dev/null +++ b/samples/haproxy/acme/haproxy.cfg.tmpl @@ -0,0 +1,63 @@ +# ── HAProxy sample (ACME variant): SCDMS web UI + SharpCoreDB gRPC ────────────── +# TLS is terminated here. Placeholders __SCDMS_DOMAIN__ / __GRPC_DOMAIN__ are replaced by +# entrypoint.sh from the SCDMS_DOMAIN / GRPC_DOMAIN environment variables. +# +# ACME additions vs. samples/haproxy/haproxy.cfg.tmpl: +# - global: lua-load of the http-01 validation plugin +# - http-in: /.well-known/acme-challenge/* is answered by the Lua service so the certbot +# companion can prove domain ownership on port 80 (zero downtime) + +global + log stdout format raw local0 notice + maxconn 4096 + # Used by the container HEALTHCHECK (socat "show info"). + stats socket /var/lib/haproxy/admin.sock level admin mode 600 + ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11 + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384 + ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 + # ACME http-01 validation (janeczku/haproxy-acme-validation-plugin, webroot=/webroot). + lua-load /usr/local/etc/haproxy/acme-http01-webroot.lua + +defaults + mode http + log global + option httplog + option dontlognull + option http-server-close + timeout connect 5s + timeout client 60s + timeout server 60s + timeout tunnel 15m + +# ── HTTP frontend: ACME challenges + container /health + redirect to HTTPS ─────── +frontend http-in + bind *:80 + acl url_acme_http01 path_beg /.well-known/acme-challenge/ + http-request use-service lua.acme-http01 if METH_GET url_acme_http01 + http-request return status 200 content-type text/plain string "OK" if { path /health } + http-request redirect scheme https unless { path /health } + +# ── HTTPS frontend (TLS terminated): HTTP/1.1 (UI) + HTTP/2 (gRPC) ────────────── +frontend https-in + bind *:443 ssl crt /certs/haproxy.pem alpn h2,http/1.1 + http-request set-header X-Forwarded-Proto https + http-request set-header X-Forwarded-Port 443 + # Long timeout for long-lived gRPC streams. + timeout client 1h + + # Route by Server Name Indication (browser sends SNI for the UI host, the SCDMS gRPC + # client sends SNI for the gRPC host). + use_backend scdms_ui if { ssl_fc_sni -i __SCDMS_DOMAIN__ } + use_backend scdb_grpc if { ssl_fc_sni -i __GRPC_DOMAIN__ } + default_backend scdms_ui + +# SCDMS web UI β†’ plain HTTP on scdms:8080. +backend scdms_ui + server scdms scdms:8080 check inter 30s fall 3 rise 2 + +# SharpCoreDB gRPC β†’ HTTPS/HTTP2 upstream. 'ssl verify none' skips the server's internal +# (self-signed) certificate β€” the public client still validates HAProxy's certificate +# (mirror of Caddy's tls_insecure_skip_verify / the YARP sample). +backend scdb_grpc + timeout server 1h + server sharpcoredb sharpcoredb:5001 ssl verify none alpn h2 check inter 30s fall 3 rise 2 diff --git a/samples/haproxy/docker-compose.acme.yml b/samples/haproxy/docker-compose.acme.yml new file mode 100644 index 0000000..06df7ac --- /dev/null +++ b/samples/haproxy/docker-compose.acme.yml @@ -0,0 +1,132 @@ +# SCDMS + SharpCoreDB Server + HAProxy (ACME variant) β€” fully automatic Let's Encrypt. +# +# Same topology as samples/haproxy/docker-compose.yml (manual certificates) but with: +# - the janeczku HAProxy ACME Lua plugin serving http-01 challenges on :80, +# - a certbot companion container that issues/renews certificates and writes the combined PEM, +# - the HAProxy entrypoint reloading gracefully (SIGUSR2) whenever the PEM changes. +# +# Usage (requires SCDMS_DOMAIN + GRPC_DOMAIN to resolve publicly to this host, :80/:443 open): +# cp ../.env.example .env # or set the variables below +# docker compose -f docker-compose.acme.yml up -d --build +# +# TLS flow: +# Let's Encrypt ──http-01 on :80──► haproxy (Lua) ──token from /webroot──► certbot writes token +# certbot issues ──deploy-hook──► combines key+chain into the haproxy-certs volume +# haproxy entrypoint detects change ──SIGUSR2──► graceful reload (new cert live) +# +# State (certificates, webroot, certbot account) lives in Docker named volumes: +# haproxy-certs, haproxy-webroot, letsencrypt (see bottom). Reset with `down -v`. + +services: + haproxy: + build: ./acme + image: scdms-haproxy-acme:local + restart: unless-stopped + # The container runs as the non-root 'haproxy' user; binding 80/443 needs this capability. + cap_add: + - NET_BIND_SERVICE + ports: + - "80:80" + - "443:443" + environment: + - SCDMS_DOMAIN=${SCDMS_DOMAIN:-scdms.example.com} + - GRPC_DOMAIN=${GRPC_DOMAIN:-scdb.example.com} + volumes: + - ./acme/haproxy.cfg.tmpl:/usr/local/etc/haproxy/haproxy.cfg.tmpl:ro + - ./acme/acme-http01-webroot.lua:/usr/local/etc/haproxy/acme-http01-webroot.lua:ro + - ./acme/entrypoint.sh:/usr/local/etc/haproxy/acme-entrypoint.sh:ro + # Named volumes: ownership (haproxy) is copied from the image into a fresh volume, so the + # non-root haproxy user can write the bootstrap PEM and the challenge webroot. + - haproxy-certs:/certs + - haproxy-webroot:/webroot + depends_on: + - scdms + - sharpcoredb + healthcheck: + test: ["CMD-SHELL", "echo 'show info' | socat unix-connect:/var/lib/haproxy/admin.sock stdio >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + certbot: + image: certbot/certbot + restart: unless-stopped + entrypoint: ["/bin/sh", "/hooks/certbot-entrypoint.sh"] + environment: + - ACME_EMAIL=${ACME_EMAIL:-} + - SCDMS_DOMAIN=${SCDMS_DOMAIN:-scdms.example.com} + - GRPC_DOMAIN=${GRPC_DOMAIN:-scdb.example.com} + volumes: + - ./acme/certbot-entrypoint.sh:/hooks/certbot-entrypoint.sh:ro + - ./acme/certbot-deploy.sh:/hooks/certbot-deploy.sh:ro + - letsencrypt:/etc/letsencrypt + - haproxy-webroot:/webroot + - haproxy-certs:/certs + depends_on: + - haproxy + + scdms: + image: ghcr.io/mpcoredeveloper/scdms:latest + # Until the official image is published you can build it locally from this repository: + # build: + # context: ../.. + # dockerfile: Dockerfile + restart: unless-stopped + environment: + - SCDMS__EnableHttps=false + - SCDMS__BindAddress=0.0.0.0 + - SCDMS__DataDirectory=/app/data + - SCDMS__UseForwardedHeaders=true + # Default gRPC server, reached through the HAProxy proxy (public cert on the proxy): + - SCDMS__DefaultServerHost=${GRPC_DOMAIN:-scdb.example.com} + - SCDMS__DefaultServerPort=443 + - SCDMS__DefaultServerDatabase=master + - SCDMS__DefaultServerUsername=${SDB_USERNAME:-anonymous} + - SCDMS__DefaultServerPassword=${SDB_PASSWORD:-} + - SCDMS__DefaultServerUseSsl=true + - SCDMS__DefaultServerAutoConnect=true + # Optional: disable the in-app update check inside a container + - SCDMS__UpdateCheckEnabled=false + volumes: + - scdms-data:/app/data + healthcheck: + test: ["CMD", "curl", "-fs", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + sharpcoredb: + image: ghcr.io/mpcoredeveloper/sharpcoredb-server:latest + # The image is published on every v* tag. To build it from a local SharpCoreDB checkout, + # uncomment: + # build: + # context: /path/to/SharpCoreDB + # dockerfile: src/SharpCoreDB.Server/Dockerfile + restart: unless-stopped + environment: + # The server uses its own (internal) dev PFX from ./server-certs. Only the HAProxy + # certificate (public) is managed by ACME; the proxy skips validation to this internal + # certificate, so it never needs to be publicly trusted. + - Server__Security__TlsCertificatePath=${SERVER_TLS_CERT_PATH:-/app/certs/server.pfx} + - Server__Security__JwtSecretKey=${SERVER_JWT_SECRET:-change-me-to-a-random-32-char-secret!} + - Server__SystemDatabases__Enabled=true + volumes: + - sharpcoredb-data:/app/data + - ./server-certs:/app/certs:ro + healthcheck: + test: ["CMD", "curl", "-fsk", "https://localhost:5001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + scdms-data: + sharpcoredb-data: + # ACME state (named volumes: ownership is seeded from the haproxy image, so the non-root + # 'haproxy' user can write; certbot runs as root and writes the real certificate). + haproxy-certs: + haproxy-webroot: + letsencrypt: diff --git a/samples/haproxy/docker-compose.yml b/samples/haproxy/docker-compose.yml new file mode 100644 index 0000000..c3d1d22 --- /dev/null +++ b/samples/haproxy/docker-compose.yml @@ -0,0 +1,102 @@ +# SCDMS + SharpCoreDB Server + HAProxy β€” industry-standard reverse proxy sample. +# +# Topology (identical wiring to samples/docker and samples/yarp): +# Browser ──HTTPS (cert on HAProxy)──► haproxy ──HTTP──► scdms:8080 (SCDMS web UI) +# SCDMS ──gRPC https://──► haproxy ──gRPC/TLS──► sharpcoredb:5001 +# +# All SCDMS ⇄ SharpCoreDB data traffic flows over gRPC. HAProxy does NOT provision TLS +# certificates automatically (unlike Caddy): mount a PEM (private key + certificate) that +# covers the proxy hostnames (dev: localhost; prod: your public certificate). +# +# Usage: +# cp .env.example .env # edit domain names + credentials +# docker compose up -d +# +# haproxy.cfg.tmpl is rendered to /tmp/haproxy.cfg by entrypoint.sh from the +# SCDMS_DOMAIN / GRPC_DOMAIN environment variables. + +services: + haproxy: + image: haproxy:3.0 + restart: unless-stopped + # Binding 80/443 as the non-root 'haproxy' user requires the NET_BIND_SERVICE capability. + cap_add: + - NET_BIND_SERVICE + ports: + - "80:80" + - "443:443" + entrypoint: ["/bin/sh", "/usr/local/etc/haproxy/entrypoint.sh"] + environment: + - SCDMS_DOMAIN=${SCDMS_DOMAIN:-scdms.example.com} + - GRPC_DOMAIN=${GRPC_DOMAIN:-scdb.example.com} + volumes: + - ./haproxy.cfg.tmpl:/usr/local/etc/haproxy/haproxy.cfg.tmpl:ro + - ./entrypoint.sh:/usr/local/etc/haproxy/entrypoint.sh:ro + - ./server-certs:/certs:ro + depends_on: + - scdms + - sharpcoredb + healthcheck: + # socat is included in the official image; queries the HAProxy stats socket. + test: ["CMD-SHELL", "echo 'show info' | socat unix-connect:/var/lib/haproxy/admin.sock stdio >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + scdms: + image: ghcr.io/mpcoredeveloper/scdms:latest + # Until the official image is published you can build it locally from this repository: + # build: + # context: ../.. + # dockerfile: Dockerfile + restart: unless-stopped + environment: + - SCDMS__EnableHttps=false + - SCDMS__BindAddress=0.0.0.0 + - SCDMS__DataDirectory=/app/data + - SCDMS__UseForwardedHeaders=true + # Default gRPC server, reached through the HAProxy proxy (public cert on the proxy): + - SCDMS__DefaultServerHost=${GRPC_DOMAIN:-scdb.example.com} + - SCDMS__DefaultServerPort=443 + - SCDMS__DefaultServerDatabase=master + - SCDMS__DefaultServerUsername=${SDB_USERNAME:-anonymous} + - SCDMS__DefaultServerPassword=${SDB_PASSWORD:-} + - SCDMS__DefaultServerUseSsl=true + - SCDMS__DefaultServerAutoConnect=true + # Optional: disable the in-app update check inside a container + - SCDMS__UpdateCheckEnabled=false + volumes: + - scdms-data:/app/data + healthcheck: + test: ["CMD", "curl", "-fs", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + sharpcoredb: + image: ghcr.io/mpcoredeveloper/sharpcoredb-server:latest + # The image is published on every v* tag. To build it from a local SharpCoreDB checkout, + # uncomment: + # build: + # context: /path/to/SharpCoreDB + # dockerfile: src/SharpCoreDB.Server/Dockerfile + restart: unless-stopped + environment: + - Server__Security__TlsCertificatePath=${SERVER_TLS_CERT_PATH:-/app/certs/server.pfx} + - Server__Security__JwtSecretKey=${SERVER_JWT_SECRET:-change-me-to-a-random-32-char-secret!} + - Server__SystemDatabases__Enabled=true + volumes: + - sharpcoredb-data:/app/data + - ./server-certs:/app/certs:ro + healthcheck: + test: ["CMD", "curl", "-fsk", "https://localhost:5001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + scdms-data: + sharpcoredb-data: diff --git a/samples/haproxy/entrypoint.sh b/samples/haproxy/entrypoint.sh new file mode 100644 index 0000000..eaf413e --- /dev/null +++ b/samples/haproxy/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Renders haproxy.cfg.tmpl (placeholders __SCDMS_DOMAIN__ / __GRPC_DOMAIN__) into +# /tmp/haproxy.cfg and starts HAProxy in foreground (-W -db, like the official entrypoint). +set -eu + +SCDMS_DOMAIN="${SCDMS_DOMAIN:-scdms.example.com}" +GRPC_DOMAIN="${GRPC_DOMAIN:-scdb.example.com}" + +sed -e "s/__SCDMS_DOMAIN__/${SCDMS_DOMAIN}/g" \ + -e "s/__GRPC_DOMAIN__/${GRPC_DOMAIN}/g" \ + /usr/local/etc/haproxy/haproxy.cfg.tmpl > /tmp/haproxy.cfg + +exec haproxy -W -db -f /tmp/haproxy.cfg "$@" diff --git a/samples/haproxy/haproxy.cfg.tmpl b/samples/haproxy/haproxy.cfg.tmpl new file mode 100644 index 0000000..6260ed6 --- /dev/null +++ b/samples/haproxy/haproxy.cfg.tmpl @@ -0,0 +1,54 @@ +# ── HAProxy sample: SCDMS web UI + SharpCoreDB gRPC ───────────────────────────── +# TLS is terminated here. Placeholders __SCDMS_DOMAIN__ / __GRPC_DOMAIN__ are replaced by +# entrypoint.sh from the SCDMS_DOMAIN / GRPC_DOMAIN environment variables. + +global + log stdout format raw local0 notice + maxconn 4096 + # Used by the container HEALTHCHECK (socat "show info"). + stats socket /var/lib/haproxy/admin.sock level admin mode 600 + ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11 + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384 + ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 + +defaults + mode http + log global + option httplog + option dontlognull + option http-server-close + timeout connect 5s + timeout client 60s + timeout server 60s + timeout tunnel 15m + +# ── HTTP frontend: container /health + redirect everything else to HTTPS ──────── +frontend http-in + bind *:80 + http-request return status 200 content-type text/plain string "OK" if { path /health } + http-request redirect scheme https unless { path /health } + +# ── HTTPS frontend (TLS terminated): HTTP/1.1 (UI) + HTTP/2 (gRPC) ────────────── +frontend https-in + bind *:443 ssl crt /certs/haproxy.pem alpn h2,http/1.1 + http-request set-header X-Forwarded-Proto https + http-request set-header X-Forwarded-Port 443 + # Long timeout for long-lived gRPC streams. + timeout client 1h + + # Route by Server Name Indication (browser sends SNI for the UI host, the SCDMS gRPC + # client sends SNI for the gRPC host). + use_backend scdms_ui if { ssl_fc_sni -i __SCDMS_DOMAIN__ } + use_backend scdb_grpc if { ssl_fc_sni -i __GRPC_DOMAIN__ } + default_backend scdms_ui + +# SCDMS web UI β†’ plain HTTP on scdms:8080. +backend scdms_ui + server scdms scdms:8080 check inter 30s fall 3 rise 2 + +# SharpCoreDB gRPC β†’ HTTPS/HTTP2 upstream. 'ssl verify none' skips the server's internal +# (self-signed) certificate β€” the public client still validates HAProxy's certificate +# (mirror of Caddy's tls_insecure_skip_verify / the YARP sample). +backend scdb_grpc + timeout server 1h + server sharpcoredb sharpcoredb:5001 ssl verify none alpn h2 check inter 30s fall 3 rise 2 diff --git a/samples/yarp/.env.example b/samples/yarp/.env.example new file mode 100644 index 0000000..b682e93 --- /dev/null +++ b/samples/yarp/.env.example @@ -0,0 +1,23 @@ +# ── Proxy hostnames ── +# For local testing use localhost for both (single dev certificate covers both). +# For production use the real public hostnames; the PFX must cover both names. +SCDMS_DOMAIN=scdms.example.com +GRPC_DOMAIN=scdb.example.com + +# ── YARP proxy TLS ── +# YARP terminates TLS for the browser UI and the SCDMS gRPC client. Provide a PFX covering +# SCDMS_DOMAIN and GRPC_DOMAIN, e.g. for local testing: +# mkdir -p server-certs +# dotnet dev-certs https -ep server-certs/server.pfx -p devonly +# (leave YARP_TLS_CERT_PASSWORD empty when the PFX has no password) +YARP_TLS_CERT_PATH=/certs/server.pfx +YARP_TLS_CERT_PASSWORD=devonly + +# ── SharpCoreDB server credentials ── +# The SharpCoreDB server uses its own (internal) certificate from the same server-certs folder. +SERVER_TLS_CERT_PATH=/app/certs/server.pfx +SERVER_JWT_SECRET=replace-with-a-random-32-char-secret + +# ── SCDMS default-server login used against the SharpCoreDB server ── +SDB_USERNAME=anonymous +SDB_PASSWORD= diff --git a/samples/yarp/README.md b/samples/yarp/README.md new file mode 100644 index 0000000..29ed550 --- /dev/null +++ b/samples/yarp/README.md @@ -0,0 +1,95 @@ +# SCDMS + SharpCoreDB β€” YARP reverse proxy sample (all-.NET) + +This is an **all-.NET** alternative to the Caddy-based sample in +[`samples/docker/`](../docker/). It runs the same three-part topology but replaces Caddy with a +small **YARP** (`Yarp.ReverseProxy`) proxy that you build from `./proxy`: + +```text +Browser ──HTTPS (cert on YARP)──► yarp ──HTTP──► scdms:8080 (SCDMS web UI) +SCDMS ──gRPC https://──► yarp ──gRPC/TLS──► sharpcoredb:5001 +``` + +All SCDMS ⇄ SharpCoreDB data traffic flows over **gRPC**. The proxy terminates TLS for both +the SCDMS web UI and the SCDMSβ†’server gRPC channel, then re-encrypts to the SharpCoreDB +server's internal certificate (the server only speaks TLS). + +## Caddy vs. YARP β€” what changes + +| | Caddy sample (`samples/docker/`) | YARP sample (this folder) | +|---|---|---| +| Proxy | Official `caddy:2.9` image | Your own .NET image built from `./proxy` | +| TLS certificates | **Automatic** Let's Encrypt per domain | **Manual**: you mount a PFX; cert renewal is your job | +| Language | Go binary + Caddyfile | 100% C# (`Yarp.ReverseProxy` on Kestrel) | +| Customization | Config file | Full programmatic control (routes/clusters/transforms in code) | + +Choose YARP when you want everything in .NET and/or deep programmatic proxy control; choose the +Caddy sample when you want zero-maintenance automatic TLS. + +## Quick start + +1. Make the images available: + + ```bash + cd /SCDMS + docker build -t ghcr.io/mpcoredeveloper/scdms:latest . # until the official image is published + docker pull ghcr.io/mpcoredeveloper/sharpcoredb-server:2.0.0.2 + ``` + +2. Configure and create the TLS certificate (local test β†’ use `localhost` in `.env`): + + ```bash + cd samples/yarp + cp .env.example .env # PowerShell: Copy-Item .env.example .env + mkdir -p server-certs + dotnet dev-certs https -ep server-certs/server.pfx -p devonly + ``` + + Edit `.env`: set `SCDMS_DOMAIN=localhost` and `GRPC_DOMAIN=localhost` for local testing, + or your real public hostnames in production (the PFX must then cover both hostnames). + +3. Start everything: + + ```bash + docker compose up -d --build + docker compose ps # yarp, scdms, sharpcoredb all "healthy" + ``` + +## Verify + +```bash +# YARP proxy health +docker compose exec yarp curl -fs http://localhost:80/health + +# SCDMS health (via the proxy, https) +docker compose exec scdms curl -fs http://localhost:8080/health + +# SharpCoreDB server health (internal self-signed cert = -k) +docker compose exec sharpcoredb curl -fsk https://localhost:8443/api/v1/health + +# Logs +docker compose logs -f yarp scdms +``` + +- Browser: `https://` (accept the self-signed warning when using `localhost`). +- The YARP proxy log shows the routes it registered at startup. + +## TLS notes + +- **SCDMS validates the certificate of the gRPC endpoint it connects to.** In production the + mounted PFX must be publicly trusted (as with any proxy). With a self-signed `localhost` cert + the browser warns and SCDMS's automatic gRPC connect cannot validate the proxy cert β€” that is + the same TLS caveat as the Caddy sample (see + [`docs/container-and-aspire-guide.md`](../../docs/container-and-aspire-guide.md), Β§5 for a + green local data-path test). +- The proxy deliberately **skips** certificate validation towards the internal SharpCoreDB + server (`DangerousAcceptAnyServerCertificate`), exactly like the Caddy sample's + `tls_insecure_skip_verify`. That switch only affects the proxy↔server leg. +- The proxy runs as a **non-root** user inside the container; binding ports `80`/`443` needs the + `NET_BIND_SERVICE` capability (added via `cap_add` in `docker-compose.yml`). Map higher ports + (`YARP_HTTP_PORT`/`YARP_HTTPS_PORT`) when capabilities cannot be granted. + +## Stop + +```bash +docker compose down # add -v to also delete the data volumes +``` diff --git a/samples/yarp/docker-compose.yml b/samples/yarp/docker-compose.yml new file mode 100644 index 0000000..a5b6107 --- /dev/null +++ b/samples/yarp/docker-compose.yml @@ -0,0 +1,103 @@ +# SCDMS + SharpCoreDB Server + YARP β€” all-.NET reverse proxy sample. +# +# Topology (identical wiring to samples/docker, but with a YARP proxy instead of Caddy): +# Browser ──HTTPS (cert on YARP)──► yarp ──HTTP──► scdms:8080 (SCDMS web UI) +# SCDMS ──gRPC https://──► yarp ──gRPC/TLS──► sharpcoredb:5001 +# +# All SCDMS ⇄ SharpCoreDB data traffic flows over gRPC. YARP does NOT provision TLS +# certificates automatically (unlike Caddy): mount a PFX that covers the proxy hostnames +# (dev: localhost; prod: your public certificate). +# +# Usage: +# cp .env.example .env # edit domain names + credentials +# docker compose up -d --build # builds the yarp proxy image locally +# +# Prerequisites: +# - Published image ghcr.io/mpcoredeveloper/sharpcoredb-server (available). +# - SCDMS image ghcr.io/mpcoredeveloper/scdms: published on v* tags; until then build it +# locally by uncommenting the scdms `build:` block below. + +services: + yarp: + build: ./proxy + image: yarp-proxy:local + restart: unless-stopped + # The container runs as a non-root user; binding 80/443 needs this capability. + cap_add: + - NET_BIND_SERVICE + ports: + - "80:80" + - "443:443" + environment: + - SCDMS_DOMAIN=${SCDMS_DOMAIN:-scdms.example.com} + - GRPC_DOMAIN=${GRPC_DOMAIN:-scdb.example.com} + - YARP_TLS_CERT_PATH=${YARP_TLS_CERT_PATH:-/certs/server.pfx} + - YARP_TLS_CERT_PASSWORD=${YARP_TLS_CERT_PASSWORD:-devonly} + volumes: + - ./server-certs:/certs:ro + depends_on: + - scdms + - sharpcoredb + healthcheck: + test: ["CMD", "curl", "-fs", "http://localhost:80/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + scdms: + image: ghcr.io/mpcoredeveloper/scdms:latest + # Until the official image is published you can build it locally from this repository: + # build: + # context: ../.. + # dockerfile: Dockerfile + restart: unless-stopped + environment: + - SCDMS__EnableHttps=false + - SCDMS__BindAddress=0.0.0.0 + - SCDMS__DataDirectory=/app/data + - SCDMS__UseForwardedHeaders=true + # Default gRPC server, reached through the YARP proxy (public cert on the proxy): + - SCDMS__DefaultServerHost=${GRPC_DOMAIN:-scdb.example.com} + - SCDMS__DefaultServerPort=443 + - SCDMS__DefaultServerDatabase=master + - SCDMS__DefaultServerUsername=${SDB_USERNAME:-anonymous} + - SCDMS__DefaultServerPassword=${SDB_PASSWORD:-} + - SCDMS__DefaultServerUseSsl=true + - SCDMS__DefaultServerAutoConnect=true + # Optional: disable the in-app update check inside a container + - SCDMS__UpdateCheckEnabled=false + volumes: + - scdms-data:/app/data + healthcheck: + test: ["CMD", "curl", "-fs", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + sharpcoredb: + image: ghcr.io/mpcoredeveloper/sharpcoredb-server:latest + # The image is published on every v* tag. To build it from a local SharpCoreDB checkout, + # uncomment: + # build: + # context: /path/to/SharpCoreDB + # dockerfile: src/SharpCoreDB.Server/Dockerfile + restart: unless-stopped + environment: + - Server__Security__TlsCertificatePath=${SERVER_TLS_CERT_PATH:-/app/certs/server.pfx} + - Server__Security__JwtSecretKey=${SERVER_JWT_SECRET:-change-me-to-a-random-32-char-secret!} + - Server__SystemDatabases__Enabled=true + volumes: + - sharpcoredb-data:/app/data + - ./server-certs:/app/certs:ro + healthcheck: + test: ["CMD", "curl", "-fsk", "https://localhost:5001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + scdms-data: + sharpcoredb-data: diff --git a/samples/yarp/proxy/.dockerignore b/samples/yarp/proxy/.dockerignore new file mode 100644 index 0000000..9f8ccca --- /dev/null +++ b/samples/yarp/proxy/.dockerignore @@ -0,0 +1,9 @@ +# Build context exclusions for the YARP proxy sample image +.git +.github +**/bin +**/obj +*.md +*.user +.dockerignore +Dockerfile diff --git a/samples/yarp/proxy/Dockerfile b/samples/yarp/proxy/Dockerfile new file mode 100644 index 0000000..a521290 --- /dev/null +++ b/samples/yarp/proxy/Dockerfile @@ -0,0 +1,43 @@ +# ── YARP reverse proxy (SCDMS + SharpCoreDB) ── +# All-.NET TLS-terminating proxy sample. +# Image: yarp-proxy (local build from samples/yarp/proxy) + +# ── Stage 1: Build ── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY YarpProxy.csproj . +RUN dotnet restore YarpProxy.csproj + +COPY . . +RUN dotnet publish YarpProxy.csproj \ + -c Release \ + --no-restore \ + -o /app/publish + +# ── Stage 2: Runtime ── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# curl for the container HEALTHCHECK +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +# Kestrel endpoints are configured in Program.cs from environment variables (YARP_HTTP_PORT / +# YARP_HTTPS_PORT). Exposed for documentation; the HTTPS listener terminates TLS for both the +# SCDMS web UI and the SharpCoreDB gRPC endpoint. +EXPOSE 80 443 + +# Health check (http listener; /health is excluded from the https redirect). +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fs http://localhost:80/health || exit 1 + +# The proxy runs as a non-root user; binding 80/443 needs the NET_BIND_SERVICE capability +# (added via cap_add in docker-compose.yml). Map higher ports (YARP_HTTP_PORT/YARP_HTTPS_PORT) +# when capabilities cannot be granted. +USER 1000:1000 + +ENTRYPOINT ["dotnet", "YarpProxy.dll"] diff --git a/samples/yarp/proxy/Program.cs b/samples/yarp/proxy/Program.cs new file mode 100644 index 0000000..80f167c --- /dev/null +++ b/samples/yarp/proxy/Program.cs @@ -0,0 +1,145 @@ +using System.Net; +using System.Net.Http; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Yarp.ReverseProxy; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; + +// ── YARP reverse proxy sample ───────────────────────────────────────────────────────────── +// All-.NET, TLS-terminating reverse proxy in front of: +// β€’ SCDMS β†’ http://scdms:8080 (web UI, HTTP/1.1) +// β€’ SharpCoreDB β†’ https://sharpcoredb:5001 (gRPC over HTTP/2) +// +// The proxy is fully driven by environment variables (see docker-compose.yml), mirroring the +// samples/docker Caddyfile topology. Unlike Caddy, YARP does not provision certificates +// automatically: mount a PFX that covers the proxy hostname(s) (dev: localhost via +// `dotnet dev-certs https -ep server-certs/server.pfx -p devonly`; prod: your public cert). + +static string GetEnv(string name, string fallback) => + Environment.GetEnvironmentVariable(name) ?? fallback; + +var scdmsDomain = GetEnv("SCDMS_DOMAIN", "scdms.example.com"); +var grpcDomain = GetEnv("GRPC_DOMAIN", "scdb.example.com"); +var httpPort = int.Parse(GetEnv("YARP_HTTP_PORT", "80")); +var httpsPort = int.Parse(GetEnv("YARP_HTTPS_PORT", "443")); +var certPath = GetEnv("YARP_TLS_CERT_PATH", "/certs/server.pfx"); +var certPassword = GetEnv("YARP_TLS_CERT_PASSWORD", ""); +// Plain HTTP is intentional on this leg: SCDMS serves HTTP inside its container and TLS is +// terminated by this proxy (see README). Suppress S5332 accordingly. +var scdmsUpstream = GetEnv("SCDMS_UPSTREAM", "http://scdms:8080"); // NOSONAR +var grpcUpstream = GetEnv("SCDB_UPSTREAM", "https://sharpcoredb:5001"); + +var builder = WebApplication.CreateBuilder(args); + +// Kestrel endpoints: HTTP for redirects + container healthcheck, HTTPS (HTTP/1.1 + HTTP/2 for +// gRPC) with the mounted TLS certificate. +builder.WebHost.ConfigureKestrel(kestrel => +{ + kestrel.Listen(IPAddress.Any, httpPort); + + kestrel.Listen(IPAddress.Any, httpsPort, listen => + { + listen.Protocols = HttpProtocols.Http1AndHttp2; + + if (!File.Exists(certPath)) + { + throw new FileNotFoundException( + $"TLS certificate not found at '{certPath}'. Generate one and mount it, e.g. " + + "'dotnet dev-certs https -ep server-certs/server.pfx -p devonly' (see README)."); + } + + listen.UseHttps(https => https.ServerCertificate = + X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword)); + }); +}); + +// Host-based routing (mirror of samples/docker/caddy/Caddyfile): +// β†’ SCDMS web UI (plain HTTP upstream) +// β†’ SharpCoreDB gRPC (HTTPS/HTTP2 upstream, internal certificate skipped, +// exactly like Caddy's tls_insecure_skip_verify) +var routes = new[] +{ + new RouteConfig + { + RouteId = "scdms-ui", + ClusterId = "scdms-cluster", + Match = new RouteMatch { Hosts = new[] { scdmsDomain } }, + Transforms = new[] + { + new Dictionary + { + ["RequestHeader"] = "X-Forwarded-Proto", + ["Set"] = "https" + } + } + }, + new RouteConfig + { + RouteId = "scdb-grpc", + ClusterId = "scdb-grpc-cluster", + Match = new RouteMatch { Hosts = new[] { grpcDomain } } + } +}; + +var clusters = new[] +{ + new ClusterConfig + { + ClusterId = "scdms-cluster", + Destinations = new Dictionary + { + ["scdms"] = new DestinationConfig { Address = scdmsUpstream } + } + }, + new ClusterConfig + { + ClusterId = "scdb-grpc-cluster", + Destinations = new Dictionary + { + ["sharpcoredb"] = new DestinationConfig { Address = grpcUpstream } + }, + HttpClient = new HttpClientConfig + { + // The SharpCoreDB container uses an internal/self-signed certificate; the public + // client still validates the proxy's certificate. Dev only (Caddy equivalent: + // tls_insecure_skip_verify). + DangerousAcceptAnyServerCertificate = true, + SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + EnableMultipleHttp2Connections = true + }, + HttpRequest = new ForwarderRequestConfig + { + Version = HttpVersion.Version20, // gRPC requires HTTP/2 upstream + VersionPolicy = HttpVersionPolicy.RequestVersionOrLower + } + } +}; + +builder.Services.AddReverseProxy().LoadFromMemory(routes, clusters); + +var app = builder.Build(); + +// http β†’ https redirect for the browser UI (never for /health). +app.Use(async (context, next) => +{ + if (!context.Request.IsHttps && context.Request.Path != "/health") + { + var host = httpsPort == 443 ? context.Request.Host.Host : $"{context.Request.Host.Host}:{httpsPort}"; + context.Response.Redirect( + $"https://{host}{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}", + permanent: true); + return; + } + + await next(context); +}); + +// Proxy-container health endpoint (used by the Docker HEALTHCHECK). +app.MapGet("/health", () => Results.Text("OK")); + +app.MapReverseProxy(); + +await app.RunAsync(); diff --git a/samples/yarp/proxy/YarpProxy.csproj b/samples/yarp/proxy/YarpProxy.csproj new file mode 100644 index 0000000..4c28dec --- /dev/null +++ b/samples/yarp/proxy/YarpProxy.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + 14.0 + enable + enable + YarpProxy + YarpProxy + + false + + + + + + + diff --git a/src/SCDMS.Aspire.Hosting/NuGet.README.md b/src/SCDMS.Aspire.Hosting/NuGet.README.md new file mode 100644 index 0000000..1848f17 --- /dev/null +++ b/src/SCDMS.Aspire.Hosting/NuGet.README.md @@ -0,0 +1,38 @@ +# SCDMS.Aspire.Hosting + +.NET Aspire hosting integration for the [SCDMS](https://github.com/MPCoreDeveloper/SCDMS) +web studio β€” the database studio for [SharpCoreDB](https://github.com/MPCoreDeveloper/SharpCoreDB). + +Spin up a **SharpCoreDB server container** and an **SCDMS container** as one Aspire application +(pgweb/pgAdmin-style). All SCDMS ⇄ SharpCoreDB data traffic flows over **gRPC**. + +## Getting started + +```csharp +using Scdms.Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// SharpCoreDB network server container (HTTPS gRPC on 5001, HTTPS REST API on 8443). +var db = builder.AddSharpCoreDB("db") + .WithServerContainer() + .WithJwtSecret("a-random-secret-of-at-least-32-characters"); + +// SCDMS web studio container, auto-wired to the server over gRPC. +builder.AddSCDMS("admin", db); + +builder.Build().Run(); +``` + +`AddSCDMS` uses the published image `ghcr.io/mpcoredeveloper/scdms:latest` and configures the +container defaults (plain HTTP on 8080, bind `0.0.0.0`, data in `/app/data`). When the optional +SharpCoreDB resource is passed, `WithGrpcReference` forwards the server's gRPC endpoint through +the `SCDMS__DefaultServerHost`/`SCDMS__DefaultServerPort` environment variables and enables +auto-connect. + +## TLS & production notes + +SCDMS validates the public certificate of the gRPC endpoint. In production terminate TLS at a +reverse proxy holding a publicly trusted certificate (see the Docker Compose sample in the +SCDMS repository, `samples/docker/`). The SharpCoreDB server container always exposes TLS +endpoints and requires a JWT secret (use `WithJwtSecret`). diff --git a/src/SCDMS.Aspire.Hosting/SCDMS.Aspire.Hosting.csproj b/src/SCDMS.Aspire.Hosting/SCDMS.Aspire.Hosting.csproj new file mode 100644 index 0000000..a4778cb --- /dev/null +++ b/src/SCDMS.Aspire.Hosting/SCDMS.Aspire.Hosting.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + 14.0 + enable + enable + true + + SCDMS.Aspire.Hosting + Scdms.Aspire.Hosting + .NET Aspire hosting integration for the SCDMS web studio - registers the SCDMS container and links it to a SharpCoreDB server container over gRPC + + + SCDMS.Aspire.Hosting + MPCoreDeveloper + database;aspire;hosting;container;sharpcoredb;scdms;net10 + MIT + https://github.com/MPCoreDeveloper/SCDMS + https://github.com/MPCoreDeveloper/SCDMS + git + NuGet.README.md + true + snupkg + + + + + + + + + + + + diff --git a/src/SCDMS.Aspire.Hosting/SCDMSResource.cs b/src/SCDMS.Aspire.Hosting/SCDMSResource.cs new file mode 100644 index 0000000..5d72e08 --- /dev/null +++ b/src/SCDMS.Aspire.Hosting/SCDMSResource.cs @@ -0,0 +1,23 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Scdms.Aspire.Hosting; + +/// +/// A container resource representing the SCDMS web studio image +/// (ghcr.io/mpcoredeveloper/scdms). Inside the container SCDMS serves plain HTTP on +/// port 8080; in production a reverse proxy terminates TLS and forwards the browser traffic +/// to this endpoint. All SCDMS ⇄ SharpCoreDB data traffic flows over gRPC (server mode). +/// +public sealed class ScdmsResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + /// Name of the HTTP endpoint exposed by the SCDMS container. + public const string HttpEndpointName = "http"; + + /// Gets a reference to the HTTP endpoint of the container. + public EndpointReference HttpEndpoint => new(this, HttpEndpointName); + + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{HttpEndpoint.Property(EndpointProperty.Scheme)}://{HttpEndpoint.Property(EndpointProperty.Host)}:{HttpEndpoint.Property(EndpointProperty.Port)}"); +} diff --git a/src/SCDMS.Aspire.Hosting/ScdmsAspireExtensions.cs b/src/SCDMS.Aspire.Hosting/ScdmsAspireExtensions.cs new file mode 100644 index 0000000..bef1bb2 --- /dev/null +++ b/src/SCDMS.Aspire.Hosting/ScdmsAspireExtensions.cs @@ -0,0 +1,80 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using SharpCoreDB.Aspire.Hosting; + +namespace Scdms.Aspire.Hosting; + +/// +/// .NET Aspire extension methods that register a SCDMS web studio container and link it to a +/// SharpCoreDB server container. SCDMS talks to the server exclusively over gRPC; the link is +/// configured through the SCDMS__DefaultServer* environment variables that the SCDMS +/// image reads at startup (see docs/usage.md). +/// +public static class ScdmsAspireExtensions +{ + /// Published OCI image for SCDMS. + public const string ScdmsImage = "ghcr.io/mpcoredeveloper/scdms"; + + /// Default image tag used when no explicit tag is supplied. + public const string DefaultImageTag = "latest"; + + /// Default HTTP port inside the SCDMS container (see the repository Dockerfile). + public const int DefaultHttpTargetPort = 8080; + + /// + /// Adds a SCDMS web studio container to the Aspire application. The resource exposes an HTTP + /// endpoint (named , container port 8080). When + /// is supplied the SCDMS container is linked to the SharpCoreDB + /// server over gRPC via . + /// + /// The distributed application builder. + /// The resource name. + /// Optional SharpCoreDB server resource to auto-connect to. + /// Optional container image tag (defaults to latest). + /// Optional fixed host port for the HTTP endpoint (default: allocated by Aspire). + /// The SCDMS resource builder. + public static IResourceBuilder AddSCDMS( + this IDistributedApplicationBuilder builder, + string name, + IResourceBuilder? sharpCoreDb = null, + string? imageTag = null, + int? port = null) + { + var scdms = builder.AddResource(new ScdmsResource(name)) + .WithImage(ScdmsImage) + .WithImageTag(imageTag ?? DefaultImageTag) + .WithHttpEndpoint(targetPort: DefaultHttpTargetPort, port: port, name: ScdmsResource.HttpEndpointName) + .WithEnvironment("SCDMS__EnableHttps", "false") // reverse proxy terminates TLS in production + .WithEnvironment("SCDMS__BindAddress", "0.0.0.0") // bind all container interfaces + .WithEnvironment("SCDMS__DataDirectory", "/app/data") // volume mount point for persistence + .WithEnvironment("SCDMS__UpdateCheckEnabled", "false"); // no GitHub reachability needed in containers + + return sharpCoreDb is null ? scdms : scdms.WithGrpcReference(sharpCoreDb); + } + + /// + /// Links a SCDMS container to a SharpCoreDB server container. The gRPC endpoint of the server + /// (container port 5001) is resolved and forwarded as SCDMS__DefaultServerHost and + /// SCDMS__DefaultServerPort; TLS stays enabled and SCDMS auto-connects when the UI + /// opens, mirroring the production compose sample in samples/docker/. + /// + /// The SCDMS resource builder. + /// The SharpCoreDB server resource builder. + /// The SCDMS resource builder. + /// Thrown when either argument is null. + public static IResourceBuilder WithGrpcReference( + this IResourceBuilder scdms, + IResourceBuilder sharpCoreDb) + { + ArgumentNullException.ThrowIfNull(scdms); + ArgumentNullException.ThrowIfNull(sharpCoreDb); + + var grpcEndpoint = sharpCoreDb.GetEndpoint(SharpCoreDbServerResource.GrpcEndpointName); + + return scdms + .WithEnvironment("SCDMS__DefaultServerHost", grpcEndpoint.Property(EndpointProperty.Host)) + .WithEnvironment("SCDMS__DefaultServerPort", grpcEndpoint.Property(EndpointProperty.Port)) + .WithEnvironment("SCDMS__DefaultServerUseSsl", "true") + .WithEnvironment("SCDMS__DefaultServerAutoConnect", "true"); + } +}