From 35c755aef564c01d95587a6f874c9970cf2200fa Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sat, 4 Jul 2026 17:50:22 +0200 Subject: [PATCH 1/6] feat(routing): support single-host path/port reverse proxying Let self-hosters run the whole stack on one host under path prefixes (example.org/api, example.org/gateway) instead of a subdomain per service. - api/lcg: mount under an optional path prefix via UsePathBase (OpenShock:Api:PathBase, OPENSHOCK__LCG__PUBLICPATH). Traefik routes by PathPrefix without stripping, so generated URLs (OAuth redirect_uri, Scalar) keep the prefix. - lcg: advertise a configurable public port/path (LcgOptions.PublicPort/ PublicPath); self-register under a composite node id host[:port][/path] that stays byte-identical to the old bare-host key on the default port/root path. - redis: re-key LcgNode on that composite id; rename Fqdn -> Host, add Port and PathPrefix (index lcg-online-v4 -> v5). - assignLCG v2 now returns the real Host/Port/Path; add a v2 {deviceId}/lcg that returns the gateway host/port/base-path so the browser builds the live-control URL itself. v1 endpoints stay bare-host:443 legacy. - refactor the shared gateway lookup to OneOf. - tests: cover path/port propagation and default backward-compatibility. --- .../Tests/LcgAssignmentTests.cs | 67 ++++++++++++++++++- API/Controller/Device/AssignLCG.cs | 2 +- API/Controller/Device/AssignLCGV2.cs | 6 +- API/Controller/Devices/DevicesController.cs | 55 ++++++++++++--- API/Program.cs | 6 +- Common/Models/LcgResponseV2.cs | 18 +++++ Common/OpenShockMiddlewareHelper.cs | 21 +++++- Common/Redis/LcgNode.cs | 24 +++++-- .../Controllers/HubControllerBase.cs | 2 +- LiveControlGateway/LcgKeepAlive.cs | 15 +++-- LiveControlGateway/Options/LcgOptions.cs | 38 ++++++++++- LiveControlGateway/Program.cs | 2 +- 12 files changed, 224 insertions(+), 32 deletions(-) create mode 100644 Common/Models/LcgResponseV2.cs diff --git a/API.IntegrationTests/Tests/LcgAssignmentTests.cs b/API.IntegrationTests/Tests/LcgAssignmentTests.cs index 0b93abdf..6c92fef3 100644 --- a/API.IntegrationTests/Tests/LcgAssignmentTests.cs +++ b/API.IntegrationTests/Tests/LcgAssignmentTests.cs @@ -150,10 +150,13 @@ private async Task AddGateways(string[] availableGateways, string? environmentOv if (split.Length != 2) throw new ArgumentException("Invalid gateway format"); + var host = split[1]; return new LcgNode { + Id = host, + Host = host, + Port = 443, Country = split[0], - Fqdn = split[1], Load = 0, Environment = environmentOverride ?? webHostEnvironment.EnvironmentName }; @@ -162,9 +165,67 @@ private async Task AddGateways(string[] availableGateways, string? environmentOv await lcgNodesCollection.InsertAsync(testGateways); } - private async Task SendAssignRequest(string? requesterCountry) + private async Task AddGatewayNode(string host, ushort port, string pathPrefix, string country) { - var httpRequest = new HttpRequestMessage(HttpMethod.Get, "/2/device/assignLCG?version=1"); + await using var context = WebApplicationFactory.Services.CreateAsyncScope(); + var redisConnectionProvider = context.ServiceProvider.GetRequiredService(); + var webHostEnvironment = context.ServiceProvider.GetRequiredService(); + var lcgNodesCollection = redisConnectionProvider.RedisCollection(false); + + var id = host; + if (port != 443) id += ":" + port; + id += pathPrefix; + + await lcgNodesCollection.InsertAsync(new LcgNode + { + Id = id, + Host = host, + Port = port, + PathPrefix = pathPrefix, + Country = country, + Load = 0, + Environment = webHostEnvironment.EnvironmentName + }); + } + + [Test] + [NotInParallel(ParalellGateway)] + public async Task CheckPathAndPortPropagation() + { + // A gateway on a custom port and path prefix must be advertised verbatim, with the + // firmware WS route appended to the prefix. + await AddGatewayNode("de1.example.com", 8080, "/gateway", "DE"); + + var response = await SendAssignRequest("DE", schemaVersion: 2); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var data = await response.Content.ReadFromJsonAsync(); + await Assert.That(data).IsNotNull(); + await Assert.That(data!.Host).IsEqualTo("de1.example.com"); + await Assert.That(data.Port).IsEqualTo((ushort)8080); + await Assert.That(data.Path).IsEqualTo("/gateway/2/ws/hub"); + } + + [Test] + [NotInParallel(ParalellGateway)] + public async Task CheckDefaultGatewayIsBackwardCompatible() + { + // Default port/root path -> bare host, port 443, unprefixed route (unchanged wire shape). + await AddGateways(["DE|de1.example.com"]); + + var response = await SendAssignRequest("DE", schemaVersion: 2); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var data = await response.Content.ReadFromJsonAsync(); + await Assert.That(data).IsNotNull(); + await Assert.That(data!.Host).IsEqualTo("de1.example.com"); + await Assert.That(data.Port).IsEqualTo((ushort)443); + await Assert.That(data.Path).IsEqualTo("/2/ws/hub"); + } + + private async Task SendAssignRequest(string? requesterCountry, uint schemaVersion = 1) + { + var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/2/device/assignLCG?version={schemaVersion}"); httpRequest.Headers.Add("Device-Token", _hubToken); if (!string.IsNullOrEmpty(requesterCountry)) httpRequest.Headers.Add("CF-IPCountry", requesterCountry); diff --git a/API/Controller/Device/AssignLCG.cs b/API/Controller/Device/AssignLCG.cs index 2e5b9322..da9a0ef4 100644 --- a/API/Controller/Device/AssignLCG.cs +++ b/API/Controller/Device/AssignLCG.cs @@ -34,7 +34,7 @@ public async Task GetLiveControlGateway([FromServices] ILCGNodePr return LegacyDataOk(new LcgNodeResponse { - Fqdn = closestNode.Fqdn, + Fqdn = closestNode.Host, Country = closestNode.Country }); } diff --git a/API/Controller/Device/AssignLCGV2.cs b/API/Controller/Device/AssignLCGV2.cs index d7d80ca5..2afa24c6 100644 --- a/API/Controller/Device/AssignLCGV2.cs +++ b/API/Controller/Device/AssignLCGV2.cs @@ -46,9 +46,9 @@ public async Task GetLiveControlGatewayV2([FromQuery(Name = "vers return Ok(new LcgNodeResponseV2 { - Host = closestNode.Fqdn, - Port = 443, - Path = path, + Host = closestNode.Host, + Port = closestNode.Port, + Path = closestNode.PathPrefix + path, Country = closestNode.Country }); } diff --git a/API/Controller/Devices/DevicesController.cs b/API/Controller/Devices/DevicesController.cs index 46539308..38cfacb5 100644 --- a/API/Controller/Devices/DevicesController.cs +++ b/API/Controller/Devices/DevicesController.cs @@ -3,6 +3,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using OneOf; using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.API.Services.DeviceUpdate; @@ -229,35 +230,69 @@ public async Task GetPairCode([FromRoute] Guid deviceId) /// LCG node was found and device is online /// Device does not exist or does not belong to you /// Device is not online - /// Device is online but not connected to a LCG node, you might need to upgrade your firmware to use this feature - /// Internal server error, lcg node could not be found [HttpGet("{deviceId}/lcg")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound, DeviceIsNotOnline - [ProducesResponseType(StatusCodes.Status412PreconditionFailed, MediaTypeNames.Application.ProblemJson)] // DeviceNotConnectedToGateway [MapToApiVersion("1")] public async Task GetLiveControlGatewayInfo([FromRoute] Guid deviceId) + { + var result = await ResolveDeviceGatewayAsync(deviceId); + if (result.TryPickT1(out var problem, out var gateway)) return Problem(problem); + + return LegacyDataOk(new LcgResponse + { + Gateway = gateway.Host, + Country = gateway.Country + }); + } + + /// + /// Gets the live control gateway a hub is connected to, including its public port and the full + /// live-control WebSocket path (honoring the gateway's configured path prefix). + /// + /// Successfully retrieved live control gateway info + /// Device does not exist or is not online + [HttpGet("{deviceId}/lcg")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound, DeviceIsNotOnline + [MapToApiVersion("2")] + public async Task GetLiveControlGatewayInfoV2([FromRoute] Guid deviceId) + { + var result = await ResolveDeviceGatewayAsync(deviceId); + if (result.TryPickT1(out var problem, out var gateway)) return Problem(problem); + + return Ok(new LcgResponseV2 + { + Host = gateway.Host, + Port = gateway.Port, + PathPrefix = gateway.PathPrefix, + Country = gateway.Country + }); + } + + /// + /// Shared lookup for the LCG node a hub is currently connected to. Returns the node, or an + /// when the caller lacks access, the hub is offline, or it has + /// no gateway. + /// + private async Task> ResolveDeviceGatewayAsync(Guid deviceId) { // Check if user owns device or has a share var deviceExistsAndYouHaveAccess = await _db.Devices.AnyAsync(x => x.Id == deviceId && (x.OwnerId == CurrentUser.Id || x.Shockers.Any(y => y.UserShares.Any( z => z.SharedWithUserId == CurrentUser.Id)))); - if (!deviceExistsAndYouHaveAccess) return Problem(HubError.HubNotFound); + if (!deviceExistsAndYouHaveAccess) return HubError.HubNotFound; // Check if device is online var devicesOnline = _redis.RedisCollection(); var online = await devicesOnline.FindByIdAsync(deviceId.ToString()); - if (online is null) return Problem(HubError.HubIsNotOnline); + if (online is null) return HubError.HubIsNotOnline; // Get LCG node info var lcgNodes = _redis.RedisCollection(); var gateway = await lcgNodes.FindByIdAsync(online.Gateway); if (gateway is null) throw new Exception("Internal server error, lcg node could not be found"); - return LegacyDataOk(new LcgResponse - { - Gateway = gateway.Fqdn, - Country = gateway.Country - }); + return gateway; } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 83719d63..105e6f5d 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -115,7 +115,11 @@ static void DefaultOptions(RemoteAuthenticationOptions options, string provider) var app = builder.Build(); -await app.UseCommonOpenShockMiddleware(); +// Optional path prefix the API is served under (e.g. "/api" when reverse-proxied on a +// shared host). Empty by default -> served at root, unchanged behavior. +var apiPathBase = builder.Configuration.GetValue("OpenShock:Api:PathBase"); + +await app.UseCommonOpenShockMiddleware(apiPathBase); if (!databaseOptions.SkipMigration) { diff --git a/Common/Models/LcgResponseV2.cs b/Common/Models/LcgResponseV2.cs new file mode 100644 index 00000000..6d32fb73 --- /dev/null +++ b/Common/Models/LcgResponseV2.cs @@ -0,0 +1,18 @@ +namespace OpenShock.Common.Models; + +public sealed class LcgResponseV2 +{ + /// Public host of the gateway the hub is connected to. + public required string Host { get; init; } + + /// Public port to connect to (default 443). + public required ushort Port { get; init; } + + /// + /// Base path prefix the gateway is served under, e.g. "/gateway" (empty for root). The client + /// builds the full URL and appends the live-control route (/1/ws/live/{hubId}) itself. + /// + public required string PathPrefix { get; init; } + + public required string Country { get; init; } +} diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index b5140485..c529f783 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -22,11 +22,20 @@ public static class OpenShockMiddlewareHelper ForwardedForHeaderName = "CF-Connecting-IP" }; - public static async Task UseCommonOpenShockMiddleware(this WebApplication app) + public static async Task UseCommonOpenShockMiddleware(this WebApplication app, string? pathBase = null) { var metricsOptions = app.Services.GetRequiredService(); var metricsAllowedIpNetworks = metricsOptions.AllowedNetworks.Select(IPNetwork.Parse).ToArray(); + // Mount the whole app under a path prefix (e.g. "/api" or "/gateway") when configured. + // This must run before routing so controllers still match at their root-relative routes, + // and it records Request.PathBase so generated URLs (OAuth redirect_uri, Scalar, ...) + // include the prefix. The proxy forwards the full path unchanged (no StripPrefix). + if (!string.IsNullOrWhiteSpace(pathBase)) + { + app.UsePathBase(NormalizePathBase(pathBase)); + } + foreach (var proxy in await TrustedProxiesFetcher.GetTrustedNetworksAsync()) { ForwardedSettings.KnownIPNetworks.Add(proxy); @@ -93,6 +102,16 @@ public static async Task UseCommonOpenShockMiddleware(this return app; } + /// + /// Normalizes a configured path base into a valid : ensures a single + /// leading slash and strips any trailing slash (so "api", "/api", "/api/" all become "/api"). + /// + private static string NormalizePathBase(string pathBase) + { + var trimmed = pathBase.Trim().Trim('/'); + return trimmed.Length == 0 ? "/" : "/" + trimmed; + } + public static async Task ApplyPendingOpenShockMigrations(this IApplicationBuilder app, DatabaseOptions options) { using var scope = app.ApplicationServices.CreateScope(); diff --git a/Common/Redis/LcgNode.cs b/Common/Redis/LcgNode.cs index 104dabeb..7370de07 100644 --- a/Common/Redis/LcgNode.cs +++ b/Common/Redis/LcgNode.cs @@ -1,13 +1,27 @@ -using Redis.OM.Modeling; +using Redis.OM.Modeling; namespace OpenShock.Common.Redis; -[Document(StorageType = StorageType.Json, IndexName = "lcg-online-v4")] +[Document(StorageType = StorageType.Json, IndexName = "lcg-online-v5")] public sealed class LcgNode { - [RedisIdField] [Indexed(IndexEmptyAndMissing = false)] public required string Fqdn { get; set; } + /// + /// Composite public node id (the Redis key): the gateway's full public base as + /// host[:port][/path]. Port and path are only present when they differ from the + /// defaults (443 / root), so a default-layout gateway keeps the bare-host id it always had. + /// + [RedisIdField] public required string Id { get; set; } + + /// Public host clients connect to. + public required string Host { get; set; } + + /// Public port clients connect to (default 443). + public required ushort Port { get; set; } + + /// Public path prefix the gateway is served under, e.g. "/gateway" (empty = root). + public string PathPrefix { get; set; } = string.Empty; + [Indexed(IndexEmptyAndMissing = false)] public required string Country { get; set; } [Indexed(Sortable = true, IndexEmptyAndMissing = false)] public required byte Load { get; set; } [Indexed(IndexEmptyAndMissing = false)] public string Environment { get; set; } = "Production"; - -} \ No newline at end of file +} diff --git a/LiveControlGateway/Controllers/HubControllerBase.cs b/LiveControlGateway/Controllers/HubControllerBase.cs index f8acb87d..aab9f028 100644 --- a/LiveControlGateway/Controllers/HubControllerBase.cs +++ b/LiveControlGateway/Controllers/HubControllerBase.cs @@ -235,7 +235,7 @@ protected async Task SelfOnline(ulong uptimeMs, ushort? latency = null, in await HubLifetime.Online(CurrentHubId, new SelfOnlineData() { Owner = CurrentHubOwnerId, - Gateway = _options.Fqdn, + Gateway = _options.GetPublicNodeId(), FirmwareVersion = _firmwareVersion!, ConnectedAt = _connected, UserAgent = _userAgent, diff --git a/LiveControlGateway/LcgKeepAlive.cs b/LiveControlGateway/LcgKeepAlive.cs index d9a3020b..12bb45ec 100644 --- a/LiveControlGateway/LcgKeepAlive.cs +++ b/LiveControlGateway/LcgKeepAlive.cs @@ -38,13 +38,18 @@ public LcgKeepAlive(IRedisConnectionProvider redisConnectionProvider, IWebHostEn private async Task SelfOnline() { var lcgNodes = _redisConnectionProvider.RedisCollection(false); - - var online = await lcgNodes.FindByIdAsync(_options.Fqdn); + + var nodeId = _options.GetPublicNodeId(); + + var online = await lcgNodes.FindByIdAsync(nodeId); if (online is null) { await lcgNodes.InsertAsync(new LcgNode { - Fqdn = _options.Fqdn, + Id = nodeId, + Host = _options.Fqdn, + Port = _options.PublicPort, + PathPrefix = _options.NormalizedPublicPath, Country = _options.CountryCode, Load = 0, Environment = _env.EnvironmentName @@ -56,7 +61,7 @@ await lcgNodes.InsertAsync(new LcgNode if (online.Country != _options.CountryCode || online.Environment != _env.EnvironmentName) { var changeTracker = _redisConnectionProvider.RedisCollection(); - var tracked = await changeTracker.FindByIdAsync(_options.Fqdn); + var tracked = await changeTracker.FindByIdAsync(nodeId); if (tracked is not null) { tracked.Country = _options.CountryCode; @@ -71,7 +76,7 @@ await lcgNodes.InsertAsync(new LcgNode } await _redisConnectionProvider.Connection.ExecuteAsync("EXPIRE", - $"{typeof(LcgNode).FullName}:{_options.Fqdn}", (int)KeepAliveKeyTTL.TotalSeconds); + $"{typeof(LcgNode).FullName}:{nodeId}", (int)KeepAliveKeyTTL.TotalSeconds); } private async Task Loop() diff --git a/LiveControlGateway/Options/LcgOptions.cs b/LiveControlGateway/Options/LcgOptions.cs index bb7bff0a..3ca74612 100644 --- a/LiveControlGateway/Options/LcgOptions.cs +++ b/LiveControlGateway/Options/LcgOptions.cs @@ -17,16 +17,52 @@ public sealed class LcgOptions public const string SectionName = "OpenShock:LCG"; /// - /// FQDN of the LCG + /// FQDN (public host) of the LCG that clients connect to /// [Required(AllowEmptyStrings = false)] public required string Fqdn { get; set; } + /// + /// Public port clients (firmware & browsers) connect to. Defaults to 443. + /// + public ushort PublicPort { get; set; } = 443; + + /// + /// Public path prefix the gateway is served under (e.g. "/gateway"). Empty = root. + /// + public string PublicPath { get; set; } = string.Empty; + /// /// A valid country code by ISO 3166-1 alpha-2 https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 /// [Alpha2CountryCode] public required string CountryCode { get; set; } + + /// + /// Normalized public path prefix: a single leading slash and no trailing slash, or empty for root. + /// ("gateway", "/gateway", "/gateway/" all become "/gateway"; "" / "/" become ""). + /// + public string NormalizedPublicPath + { + get + { + var trimmed = PublicPath.Trim().Trim('/'); + return trimmed.Length == 0 ? string.Empty : "/" + trimmed; + } + } + + /// + /// The gateway's public node id: its full public base as host[:port][/path]. The port and + /// path are only appended when they differ from the defaults (443 / root), so a gateway on the + /// default port and root path keeps the bare-host id it had historically. + /// + public string GetPublicNodeId() + { + var id = Fqdn; + if (PublicPort != 443) id += ":" + PublicPort; + id += NormalizedPublicPath; + return id; + } } /// diff --git a/LiveControlGateway/Program.cs b/LiveControlGateway/Program.cs index 49f8cdea..ec9a6525 100644 --- a/LiveControlGateway/Program.cs +++ b/LiveControlGateway/Program.cs @@ -46,6 +46,6 @@ var app = builder.Build(); -await app.UseCommonOpenShockMiddleware(); +await app.UseCommonOpenShockMiddleware(lcgOptions.PublicPath); await app.RunAsync(); \ No newline at end of file From 1aaed7666136a4d31ba909c7765ad1b746ac98e4 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sat, 4 Jul 2026 17:50:49 +0200 Subject: [PATCH 2/6] feat(docker-compose): rework docker compose files --- .env | 65 ++++++++++------ README.md | 55 ++++++++------ docker-compose.yml | 183 +++++++++++++++++++++++++++------------------ 3 files changed, 188 insertions(+), 115 deletions(-) diff --git a/.env b/.env index 5a1a334b..a9d220a0 100644 --- a/.env +++ b/.env @@ -1,27 +1,48 @@ -# Required variables (uncomment and set values!) -#PG_PASS=someSecurePassword +# OpenShock configuration. These are simple knobs; docker-compose.yml maps them +# onto the OPENSHOCK__* variables the containers read and shares them across every +# service. Anything left unset falls back to the defaults defined in the compose file. -# Compose variables -OPENSHOCK_DOMAIN=openshock.local # your public base domain -OPENSHOCK_GATEWAY_SUBDOMAIN=gateway # subdomain for the included gateway -OPENSHOCK_API_SUBDOMAIN=api # subdomain for the api +# --- Required --- +# Database password (no default, must be set). +PG_PASS=someSecurePassword -#global email config -OPENSHOCK__MAIL__SENDER__NAME=OpenShock System -OPENSHOCK__MAIL__SENDER__EMAIL=system@openshock.app +# --- Host / port / paths --- +# The whole stack runs on one host and one port; services are told apart by path. +# The host clients use to reach the stack. +OPENSHOCK_HOST=openshock.local +# External port for the stack. Leave blank for 443 (standard https). Set e.g. 8080 +# to serve on https://host:8080. Traefik publishes this onto its https entrypoint. +#OPENSHOCK_PORT=8080 +# Path prefix per service. Frontend sits at the root; api and gateway on sub-paths. +# Leave OPENSHOCK_FRONTEND_PATH blank to keep the web UI at the root (recommended). +#OPENSHOCK_FRONTEND_PATH= +OPENSHOCK_API_PATH=/api +OPENSHOCK_GATEWAY_PATH=/gateway -#mail configs. uncomment one of the 2 sections below and make your config changes +# --- Database (optional, defaults shown) --- +#PG_USER=openshock +#PG_DB=openshock -#MailJet -#OPENSHOCK__MAIL__TYPE: MAILJET # MAILJET or SMTP, check Documentation -#OPENSHOCK__MAIL__MAILJET__KEY: mailjetkey -#OPENSHOCK__MAIL__MAILJET__SECRET: mailjetsecret -#OPENSHOCK__MAIL__MAILJET__TEMPLATE__PASSWORDRESET: 9999999 +# --- Feature flags (optional, defaults shown) --- +#OPENSHOCK_TURNSTILE_ENABLE=false +#OPENSHOCK_REGISTRATION_ENABLED=true +#OPENSHOCK_LCG_COUNTRYCODE=DE -#SMTP -OPENSHOCK__MAIL__TYPE=SMTP # MAILJET or SMTP, check Documentation -OPENSHOCK__MAIL__SMTP__HOST=mail.domain.zap -OPENSHOCK__MAIL__SMTP__USERNAME=open@shock.zap -OPENSHOCK__MAIL__SMTP__PASSWORD=SMTPPASSWORD -OPENSHOCK__MAIL__SMTP__ENABLESSL=true -OPENSHOCK__MAIL__SMTP__VERIFYCERTIFICATE=true +# --- E-mail --- +# MAIL_TYPE is required by the app. Leave it as None to run without outbound mail, +# or set it to Smtp / Mailjet and fill in the matching block below. +MAIL_TYPE=None +MAIL_SENDER_NAME=OpenShock System +MAIL_SENDER_EMAIL=system@openshock.app + +# SMTP (used when MAIL_TYPE=Smtp) +#SMTP_HOST=mail.domain.zap +#SMTP_PORT=587 +#SMTP_USERNAME=open@shock.zap +#SMTP_PASSWORD=SMTPPASSWORD +#SMTP_ENABLESSL=true +#SMTP_VERIFYCERTIFICATE=true + +# Mailjet (used when MAIL_TYPE=Mailjet) +#MAILJET_KEY=mailjetkey +#MAILJET_SECRET=mailjetsecret \ No newline at end of file diff --git a/README.md b/README.md index 7a85f316..17e407e2 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,16 @@ -### API Documentation +### API Documentation + You can access our Open API Doc here: https://api.openshock.app/scalar/viewer # Configuration -The API can be configured using the following environment variables: +The API can be configured using the following environment variables. +These variables should be available to all containers in the stack. Preferred way is a .env file. | Variable | Required | Default value | Allowed / Example value | @@ -41,19 +43,19 @@ Preferred way is a .env file. | `OPENSHOCK__MAIL__SENDER__EMAIL` | x | | `system@my-openshock-instance.net` | | `OPENSHOCK__MAIL__SENDER__NAME` | x | | `MyOpenShockInstance System` | | `OPENSHOCK__MAIL__TYPE` | x | | `MAILJET`, `SMTP` | -| `OPENSHOCK__TURNSTILE__ENABLE` | x | | `true`, `false` | -| `OPENSHOCK__LCG__FQDN` | x | | `de1-gateway.my-openshock-instance.net` `de1-gateway.shocklink.net` | -| `OPENSHOCK__LCG__COUNTRYCODE` | x | | `DE` or `XX` as a placeholder / unknown | +| `OPENSHOCK__TURNSTILE__ENABLE` | x | | `true`, `false` | | -Refer to the [Npgsql Connection String](https://www.npgsql.org/doc/connection-string-parameters.html) documentation page for details about `OPENSHOCK__DB_CONN`. -Refer to [StackExchange.Redis Configuration](https://stackexchange.github.io/StackExchange.Redis/Configuration.html) documentation page for details about `OPENSHOCK__REDIS__CONN`. +Refer to the [Npgsql Connection String](https://www.npgsql.org/doc/connection-string-parameters.html) documentation page +for details about `OPENSHOCK__DB_CONN`. +Refer to [StackExchange.Redis Configuration](https://stackexchange.github.io/StackExchange.Redis/Configuration.html) +documentation page for details about `OPENSHOCK__REDIS__CONN`. ## Turnstile When Turnstile enable is set to `true`, the following environment variable is required: | Variable | Required | Default value | Allowed / Example value | -| --------------------------------- | -------- | ------------- | ----------------------- | +|-----------------------------------|----------|---------------|-------------------------| | `OPENSHOCK__TURNSTILE__SITEKEY` | x | | | | `OPENSHOCK__TURNSTILE__SECRETKEY` | x | | | @@ -63,18 +65,17 @@ When Turnstile enable is set to `true`, the following environment variable is re You need these environment variables to use [Mailjet](https://www.mailjet.com/): -| Variable | Required | Default value | Allowed / Example value | -| --------------------------------------------------- | -------- | ------------- | ----------------------- | -| `OPENSHOCK__MAIL__MAILJET__KEY` | x | | | -| `OPENSHOCK__MAIL__MAILJET__SECRET` | x | | | -| `OPENSHOCK__MAIL__MAILJET__TEMPLATE__PASSWORDRESET` | x | | | +| Variable | Required | Default value | Allowed / Example value | +|------------------------------------|----------|---------------|-------------------------| +| `OPENSHOCK__MAIL__MAILJET__KEY` | x | | | +| `OPENSHOCK__MAIL__MAILJET__SECRET` | x | | | ### SMTP You need these environment variables to use SMTP: | Variable | Required | Default value | Allowed / Example value | -| ------------------------------------------ | -------- | ------------- | ---------------------------------- | +|--------------------------------------------|----------|---------------|------------------------------------| | `OPENSHOCK__MAIL__SMTP__HOST` | x | | `mail.my-openshock-instance.net` | | `OPENSHOCK__MAIL__SMTP__PORT` | | `587` | `587` | | `OPENSHOCK__MAIL__SMTP__USERNAME` | x | | `system@my-openshock-instance.net` | @@ -82,25 +83,37 @@ You need these environment variables to use SMTP: | `OPENSHOCK__MAIL__SMTP__ENABLESSL` | | `true` | `true` or `false` | | `OPENSHOCK__MAIL__SMTP__VERIFYCERTIFICATE` | | `true` | `true` or `false` | +## (Live Control) Gateway +These are the environment variables for the live control gateway. +They are only required on the gateway container and are configuration local to the gateway. + +| Variable | Required | Default value | Allowed / Example value | +|-------------------------------|----------|---------------|---------------------------------------------------------------------| +| `OPENSHOCK__LCG__FQDN` | x | | `de1-gateway.my-openshock-instance.net` `de1-gateway.shocklink.net` | +| `OPENSHOCK__LCG__COUNTRYCODE` | x | | `DE` or `XX` as a placeholder / unknown | + + # Deployment / Self Hosting The OpenShock stack consists of the following components: - Postgres as database -- Redis-Stack (with keyspace events KEA) -- The API (container, API) +- Redis-Stack (with keyspace events KEA) (preferably [DragonflyDB](https://github.com/dragonflydb/dragonfly)) +- One or multiple APIs (container, API) - One or multiple gateways (container, LCG) - One or multiple cron daemons (container, CRON) -- [The WebUI](https://github.com/OpenShock/WebUI) +- [Frontend](https://github.com/OpenShock/Frontend) - stateless, can run on edge ## Requirements OpenShock instance needs to be under the same domain name to work correctly. This is due to cookie limitations in browsers. -E.g. -Fontend: https://openshock.app -API: https://api.openshock.app -LCG: https://de1-gateway.openshock.app + +Frontend: `https://openshock.app` +API: `https://api.openshock.app` +LCG: `https://de1-gateway.openshock.app` + +In this case your cookie domain would be `openshock.app`, even if the frontend would be hosted under e.g. `https://frontend.openshock.app` ## Using Docker (provided docker-compose.yml) diff --git a/docker-compose.yml b/docker-compose.yml index 3c778a52..a74f34b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,74 @@ # This file is a minimal plug and play working example of a runnable OpenShock stack. +# +# Configuration lives in the .env file next to this compose file. You edit simple +# KEY=value knobs there (host, port, per-service paths, DB password, mail...). This +# file maps those onto the OPENSHOCK__* / PUBLIC_* config the containers read. +# +# Topology: the whole stack runs on ONE host (OPENSHOCK_HOST) and ONE port +# (OPENSHOCK_PORT, default 443), and the services are told apart by path: +# https://host/ -> frontend +# https://host/api/... -> api (OPENSHOCK_API_PATH) +# https://host/gateway/.. -> gateway (OPENSHOCK_GATEWAY_PATH) +# Because everything is one origin, the login cookie is shared with no extra setup. +# Each app owns its path prefix (UsePathBase); Traefik routes by PathPrefix and does +# NOT strip. Shared OPENSHOCK__* config lives in x-openshock-env, merged into each +# service; the `:-default` fallbacks keep the stack booting with almost no config. + +# --------------------------------------------------------------------------- +# Shared application config. Every OpenShock service (api, cron, lcg) reads the +# same OPENSHOCK__* schema, so it is defined once here and merged into each +# service with `<<: *openshock-env`. Values come from the knobs in .env; the +# `:-default` fallbacks keep the stack booting with almost no configuration. +# The `${OPENSHOCK_PORT:+:...}` bits append ":port" only when a custom port is set, +# so default (443) deployments keep clean https://host URLs. +# --------------------------------------------------------------------------- +x-openshock-env: &openshock-env + # Core infrastructure + OPENSHOCK__DB__CONN: Host=db;Port=5432;Database=${PG_DB:-openshock};Username=${PG_USER:-openshock};Password=${PG_PASS} + OPENSHOCK__REDIS__HOST: dragonfly + + # Frontend URLs — API uses them for cookies/redirects, Cron for e-mail links. + # BASEURL/SHORTURL point at the web UI (host + optional port + frontend path). + # COOKIEDOMAIN is the bare host; since the whole stack is one origin the login + # cookie is shared with every service automatically. + OPENSHOCK__FRONTEND__BASEURL: https://${OPENSHOCK_HOST:-openshock.local}${OPENSHOCK_PORT:+:${OPENSHOCK_PORT}}${OPENSHOCK_FRONTEND_PATH:-} + OPENSHOCK__FRONTEND__SHORTURL: https://${OPENSHOCK_HOST:-openshock.local}${OPENSHOCK_PORT:+:${OPENSHOCK_PORT}}${OPENSHOCK_FRONTEND_PATH:-} + OPENSHOCK__FRONTEND__COOKIEDOMAIN: ${OPENSHOCK_HOST:-openshock.local} + + # Feature flags + OPENSHOCK__TURNSTILE__ENABLE: ${OPENSHOCK_TURNSTILE_ENABLE:-false} + OPENSHOCK__ACCOUNT__REGISTRATIONENABLED: ${OPENSHOCK_REGISTRATION_ENABLED:-true} + + # E-mail (delivered by the Cron service). TYPE is required by the app; leave it + # as None to run without outbound mail, or set MAIL_TYPE=Smtp / Mailjet in .env + # and fill in the matching block below. + OPENSHOCK__MAIL__TYPE: ${MAIL_TYPE:-None} + OPENSHOCK__MAIL__SENDER__NAME: ${MAIL_SENDER_NAME:-OpenShock} + OPENSHOCK__MAIL__SENDER__EMAIL: ${MAIL_SENDER_EMAIL:-no-reply@openshock.local} + # SMTP (used when MAIL_TYPE=Smtp) + OPENSHOCK__MAIL__SMTP__HOST: ${SMTP_HOST:-} + OPENSHOCK__MAIL__SMTP__PORT: ${SMTP_PORT:-587} + OPENSHOCK__MAIL__SMTP__USERNAME: ${SMTP_USERNAME:-} + OPENSHOCK__MAIL__SMTP__PASSWORD: ${SMTP_PASSWORD:-} + OPENSHOCK__MAIL__SMTP__ENABLESSL: ${SMTP_ENABLESSL:-true} + OPENSHOCK__MAIL__SMTP__VERIFYCERTIFICATE: ${SMTP_VERIFYCERTIFICATE:-true} + # Mailjet (used when MAIL_TYPE=Mailjet) + OPENSHOCK__MAIL__MAILJET__KEY: ${MAILJET_KEY:-} + OPENSHOCK__MAIL__MAILJET__SECRET: ${MAILJET_SECRET:-} + +# Common scaffolding shared by the OpenShock app services. +x-openshock-svc: &openshock-svc + restart: unless-stopped + networks: + - openshock + depends_on: + - db + - dragonfly + services: - db: # We need a postgres database, preferably version 15+ - image: postgres:17 + db: # We need a postgres database, preferably version 18+ + image: postgres:18 restart: unless-stopped container_name: openshock-postgres healthcheck: @@ -18,114 +84,86 @@ services: POSTGRES_USER: ${PG_USER:-openshock} POSTGRES_DB: ${PG_DB:-openshock} volumes: - - ./postgres-data:/var/lib/postgresql/data # Data is saved in a folder called postgres-data in the current working directory - - redis: + # PG18+ mounts the parent dir (data lives in ./postgres-data/18/docker). + - ./postgres-data:/var/lib/postgresql + + dragonfly: # Redis-compatible store. Ex = emit expired-key events (used by the app) + image: ghcr.io/dragonflydb/dragonfly:latest + command: "--notify_keyspace_events=Ex" restart: unless-stopped networks: - openshock - image: redis/redis-stack-server:latest - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - start_period: 20s - interval: 30s - retries: 5 - timeout: 3s volumes: - - ./redis-data:/data # Same goes for redis - environment: - - "REDIS_ARGS=--notify-keyspace-events KEA" + - ./dragonfly-data:/data api: + <<: *openshock-svc image: ghcr.io/openshock/api:latest - restart: unless-stopped - networks: - - openshock - depends_on: - - db - - redis - env_file: .env environment: - OPENSHOCK__FRONTEND__BASEURL: https://${OPENSHOCK_DOMAIN:-openshock.local} - OPENSHOCK__FRONTEND__SHORTURL: https://${OPENSHOCK_DOMAIN:-openshock.local} - OPENSHOCK__FRONTEND__COOKIEDOMAIN: ${OPENSHOCK_DOMAIN:-openshock.local} - OPENSHOCK__DB__CONN: Host=db;Port=5432;Database=${PG_USER:-openshock};Username=${PG_USER:-openshock};Password=${PG_PASS} - OPENSHOCK__REDIS__HOST: redis - OPENSHOCK__TURNSTILE__ENABLE: false - OPENSHOCK__ACCOUNT__REGISTRATIONENABLED: true + <<: *openshock-env + # Path prefix the API is served under. Must match the PathPrefix router below. + OPENSHOCK__API__PATHBASE: ${OPENSHOCK_API_PATH:-/api} labels: - "traefik.enable=true" - - "traefik.http.routers.openshock-api.rule=Host(`${OPENSHOCK_API_SUBDOMAIN:-api}.${OPENSHOCK_DOMAIN:-openshock.local}`)" + - "traefik.http.routers.openshock-api.rule=Host(`${OPENSHOCK_HOST:-openshock.local}`) && PathPrefix(`${OPENSHOCK_API_PATH:-/api}`)" - "traefik.http.routers.openshock-api.entrypoints=https" - "traefik.http.routers.openshock-api.tls=true" - "traefik.http.routers.openshock-api.service=openshock-api" - "traefik.http.services.openshock-api.loadbalancer.server.port=80" - - webui: - image: ghcr.io/openshock/webui:latest + + frontend: + image: ghcr.io/openshock/frontend:latest restart: unless-stopped networks: - openshock environment: - OPENSHOCK_NAME: OpenShock - OPENSHOCK_URL: ${OPENSHOCK_DOMAIN:-openshock.local} - OPENSHOCK_SHARE_URL: https://${OPENSHOCK_DOMAIN:-openshock.local} - OPENSHOCK_API_URL: https://${OPENSHOCK_API_SUBDOMAIN:-api}.${OPENSHOCK_DOMAIN:-openshock.local} + PUBLIC_SITE_NAME: OpenShock + PUBLIC_SITE_URL: https://${OPENSHOCK_HOST:-openshock.local}${OPENSHOCK_PORT:+:${OPENSHOCK_PORT}}${OPENSHOCK_FRONTEND_PATH:-} + PUBLIC_SITE_SHORT_URL: https://${OPENSHOCK_HOST:-openshock.local}${OPENSHOCK_PORT:+:${OPENSHOCK_PORT}}${OPENSHOCK_FRONTEND_PATH:-} + PUBLIC_BACKEND_API_URL: https://${OPENSHOCK_HOST:-openshock.local}${OPENSHOCK_PORT:+:${OPENSHOCK_PORT}}${OPENSHOCK_API_PATH:-/api} + # CSP connect-src allow-list for the gateway; the browser opens wss://host[:port]. + PUBLIC_GATEWAY_CSP_WILDCARD: https://${OPENSHOCK_HOST:-openshock.local}${OPENSHOCK_PORT:+:${OPENSHOCK_PORT}} labels: - "traefik.enable=true" - - "traefik.http.routers.openshock-webui.rule=Host(`${OPENSHOCK_DOMAIN:-openshock.local}`)" - - "traefik.http.routers.openshock-webui.entrypoints=https" - - "traefik.http.routers.openshock-webui.tls=true" - - "traefik.http.routers.openshock-webui.service=openshock-webui" - - "traefik.http.routers.openshock-webui.middlewares=osr-s,osr-c,osr-t" - - "traefik.http.services.openshock-webui.loadbalancer.server.port=80" - - "traefik.http.middlewares.osr-s.redirectregex.regex=^https://${OPENSHOCK_DOMAIN:-openshock.local}/s/(.*)" - - "traefik.http.middlewares.osr-s.redirectregex.replacement=https://${OPENSHOCK_DOMAIN:-openshock.local}/#/public/proxy/shares/links/$$1" - - "traefik.http.middlewares.osr-c.redirectregex.regex=^https://${OPENSHOCK_DOMAIN:-openshock.local}/c/(.*)" - - "traefik.http.middlewares.osr-c.redirectregex.replacement=https://${OPENSHOCK_DOMAIN:-openshock.local}/#/public/proxy/shares/code/$$1" - - "traefik.http.middlewares.osr-t.redirectregex.regex=^https://${OPENSHOCK_DOMAIN:-openshock.local}/t/(.*)" - - "traefik.http.middlewares.osr-t.redirectregex.replacement=https://${OPENSHOCK_DOMAIN:-openshock.local}/#/public/proxy/token/$$1" + # Catch-all for the host; the api/gateway/cron PathPrefix routers are more specific and win. + - "traefik.http.routers.openshock-frontend.rule=Host(`${OPENSHOCK_HOST:-openshock.local}`)" + - "traefik.http.routers.openshock-frontend.entrypoints=https" + - "traefik.http.routers.openshock-frontend.tls=true" + - "traefik.http.routers.openshock-frontend.service=openshock-frontend" + - "traefik.http.services.openshock-frontend.loadbalancer.server.port=80" lcg: + <<: *openshock-svc image: ghcr.io/openshock/live-control-gateway:latest - restart: unless-stopped - networks: - - openshock - depends_on: - - db - - redis environment: - OPENSHOCK__REDIS__HOST: redis - OPENSHOCK__DB__CONN: Host=db;Port=5432;Database=${PG_USER:-openshock};Username=${PG_USER:-openshock};Password=${PG_PASS} - OPENSHOCK__LCG__COUNTRYCODE: DE - OPENSHOCK__LCG__FQDN: ${OPENSHOCK_GATEWAY_SUBDOMAIN:-gateway}.${OPENSHOCK_DOMAIN:-openshock.local} + <<: *openshock-env + OPENSHOCK__LCG__COUNTRYCODE: ${OPENSHOCK_LCG_COUNTRYCODE:-DE} + # Public address the gateway advertises to firmware & browsers. PUBLICPATH must + # match the PathPrefix router below (the app serves under it via UsePathBase). + OPENSHOCK__LCG__FQDN: ${OPENSHOCK_HOST:-openshock.local} + OPENSHOCK__LCG__PUBLICPORT: ${OPENSHOCK_PORT:-443} + OPENSHOCK__LCG__PUBLICPATH: ${OPENSHOCK_GATEWAY_PATH:-/gateway} labels: - "traefik.enable=true" - - "traefik.http.routers.openshock-gateway.rule=Host(`${OPENSHOCK_GATEWAY_SUBDOMAIN:-gateway}.${OPENSHOCK_DOMAIN:-openshock.local}`)" + - "traefik.http.routers.openshock-gateway.rule=Host(`${OPENSHOCK_HOST:-openshock.local}`) && PathPrefix(`${OPENSHOCK_GATEWAY_PATH:-/gateway}`)" - "traefik.http.routers.openshock-gateway.entrypoints=https" - "traefik.http.routers.openshock-gateway.tls=true" - "traefik.http.routers.openshock-gateway.service=openshock-gateway" - "traefik.http.services.openshock-gateway.loadbalancer.server.port=80" cron: + <<: *openshock-svc image: ghcr.io/openshock/cron:latest - restart: unless-stopped - networks: - - openshock - depends_on: - - db - - redis environment: - OPENSHOCK__REDIS__HOST: redis - OPENSHOCK__DB__CONN: Host=db;Port=5432;Database=${PG_USER:-openshock};Username=${PG_USER:-openshock};Password=${PG_PASS} + <<: *openshock-env labels: - "traefik.enable=true" - - "traefik.http.routers.openshock-cron.rule=Host(`${OPENSHOCK_DOMAIN:-localhost}`) && PathPrefix(`/hangfire`)" + - "traefik.http.routers.openshock-cron.rule=Host(`${OPENSHOCK_HOST:-openshock.local}`) && PathPrefix(`/hangfire`)" - "traefik.http.routers.openshock-cron.entrypoints=https" - "traefik.http.routers.openshock-cron.tls=true" - "traefik.http.routers.openshock-cron.service=openshock-cron" - "traefik.http.services.openshock-cron.loadbalancer.server.port=780" - + traefik: image: traefik:latest container_name: traefik @@ -140,11 +178,12 @@ services: - openshock ports: - 80:80 - - 443:443 + # Publish the stack port (default 443) onto Traefik's https entrypoint. + - ${OPENSHOCK_PORT:-443}:443 #- 8080:8080 # Traefik Web UI (enabled by --api.insecure=true) volumes: - /etc/localtime:/etc/localtime:ro - /var/run/docker.sock:/var/run/docker.sock:ro networks: - openshock: + openshock: \ No newline at end of file From 1e2a971739e8abd6169ec9f87229dbdf9bd9358d Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sat, 4 Jul 2026 18:23:48 +0200 Subject: [PATCH 3/6] fix: update new mismtached entries --- LiveControlGateway/LcgKeepAlive.cs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/LiveControlGateway/LcgKeepAlive.cs b/LiveControlGateway/LcgKeepAlive.cs index 12bb45ec..d2c8e923 100644 --- a/LiveControlGateway/LcgKeepAlive.cs +++ b/LiveControlGateway/LcgKeepAlive.cs @@ -17,7 +17,7 @@ public sealed class LcgKeepAlive : IHostedService private uint _errorsInRow; - private static readonly TimeSpan KeepAliveKeyTTL = TimeSpan.FromSeconds(35); // 35 seconds + private static readonly TimeSpan KeepAliveKeyTtl = TimeSpan.FromSeconds(35); // 35 seconds private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(15); // 15 seconds /// @@ -53,20 +53,30 @@ await lcgNodes.InsertAsync(new LcgNode Country = _options.CountryCode, Load = 0, Environment = _env.EnvironmentName - }, KeepAliveKeyTTL); + }, KeepAliveKeyTtl); return; } // TODO: Load reporting - if (online.Country != _options.CountryCode || online.Environment != _env.EnvironmentName) + // Refresh whenever any advertised field drifted. This also backfills nodes written by an + // older build: a default gateway keeps the same (bare-host) key across the upgrade, so the + // pre-existing JSON is found here and would otherwise keep Host/Port/PathPrefix missing. + if (online.Country != _options.CountryCode + || online.Environment != _env.EnvironmentName + || online.Host != _options.Fqdn + || online.Port != _options.PublicPort + || online.PathPrefix != _options.NormalizedPublicPath) { var changeTracker = _redisConnectionProvider.RedisCollection(); var tracked = await changeTracker.FindByIdAsync(nodeId); if (tracked is not null) { + tracked.Host = _options.Fqdn; + tracked.Port = _options.PublicPort; + tracked.PathPrefix = _options.NormalizedPublicPath; tracked.Country = _options.CountryCode; tracked.Environment = _env.EnvironmentName; - + await changeTracker.SaveAsync(); _logger.LogInformation("Updated keep alive key in redis {@NewKey}", tracked); } @@ -76,7 +86,7 @@ await lcgNodes.InsertAsync(new LcgNode } await _redisConnectionProvider.Connection.ExecuteAsync("EXPIRE", - $"{typeof(LcgNode).FullName}:{nodeId}", (int)KeepAliveKeyTTL.TotalSeconds); + $"{typeof(LcgNode).FullName}:{nodeId}", (int)KeepAliveKeyTtl.TotalSeconds); } private async Task Loop() From 8133dcbf5b5d8798f3a2bffdb5e54f9ead22af39 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sat, 4 Jul 2026 18:24:00 +0200 Subject: [PATCH 4/6] feat: add tag variable to docker compose --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a74f34b3..52a52854 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,7 +98,7 @@ services: api: <<: *openshock-svc - image: ghcr.io/openshock/api:latest + image: ghcr.io/openshock/api:${OPENSHOCK_TAG:-latest} environment: <<: *openshock-env # Path prefix the API is served under. Must match the PathPrefix router below. @@ -112,7 +112,7 @@ services: - "traefik.http.services.openshock-api.loadbalancer.server.port=80" frontend: - image: ghcr.io/openshock/frontend:latest + image: ghcr.io/openshock/frontend:${OPENSHOCK_FRONTEND_TAG:-latest} restart: unless-stopped networks: - openshock @@ -134,7 +134,7 @@ services: lcg: <<: *openshock-svc - image: ghcr.io/openshock/live-control-gateway:latest + image: ghcr.io/openshock/live-control-gateway:${OPENSHOCK_TAG:-latest} environment: <<: *openshock-env OPENSHOCK__LCG__COUNTRYCODE: ${OPENSHOCK_LCG_COUNTRYCODE:-DE} @@ -153,7 +153,7 @@ services: cron: <<: *openshock-svc - image: ghcr.io/openshock/cron:latest + image: ghcr.io/openshock/cron:${OPENSHOCK_TAG:-latest} environment: <<: *openshock-env labels: From 7a50b2016d8ccc353fc25135bc41c0ae198e8c60 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sat, 4 Jul 2026 18:24:07 +0200 Subject: [PATCH 5/6] feat: tag variable --- .env | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env b/.env index a9d220a0..8de7435d 100644 --- a/.env +++ b/.env @@ -6,6 +6,13 @@ # Database password (no default, must be set). PG_PASS=someSecurePassword +# --- Images --- +# Tag for the backend images (api, gateway, cron) — they share the backend repo's +# versioning. The frontend is a separate repo with its own versions, so it has its +# own tag. Pin to a release for reproducible deploys; both default to `latest`. +#OPENSHOCK_TAG=latest +#OPENSHOCK_FRONTEND_TAG=latest + # --- Host / port / paths --- # The whole stack runs on one host and one port; services are told apart by path. # The host clients use to reach the stack. From 0237125436fef6dcefc088fea28733183c3a61c2 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sat, 4 Jul 2026 18:36:34 +0200 Subject: [PATCH 6/6] fix: flakey email tests --- Cron.IntegrationTests/Tests/EmailOutboxDeliveryTests.cs | 3 +++ Cron.IntegrationTests/Tests/EmailOutboxQueryTests.cs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/Cron.IntegrationTests/Tests/EmailOutboxDeliveryTests.cs b/Cron.IntegrationTests/Tests/EmailOutboxDeliveryTests.cs index 48abae03..32ecf588 100644 --- a/Cron.IntegrationTests/Tests/EmailOutboxDeliveryTests.cs +++ b/Cron.IntegrationTests/Tests/EmailOutboxDeliveryTests.cs @@ -12,6 +12,9 @@ namespace OpenShock.Cron.IntegrationTests.Tests; /// mints the token lazily and renders the template, and the SMTP provider hands it to Mailpit. These are /// the behaviours that used to live (wrongly) in the API integration tests. /// +// Serialized with EmailOutboxQueryTests: this class's delivery job claims due rows under FOR UPDATE, +// whose held locks would otherwise make the query test's FOR UPDATE SKIP LOCKED read skip its row. +[NotInParallel("email-outbox")] public sealed partial class EmailOutboxDeliveryTests { [ClassDataSource(Shared = SharedType.PerTestSession)] diff --git a/Cron.IntegrationTests/Tests/EmailOutboxQueryTests.cs b/Cron.IntegrationTests/Tests/EmailOutboxQueryTests.cs index d69df67d..cebed7af 100644 --- a/Cron.IntegrationTests/Tests/EmailOutboxQueryTests.cs +++ b/Cron.IntegrationTests/Tests/EmailOutboxQueryTests.cs @@ -12,6 +12,10 @@ namespace OpenShock.Cron.IntegrationTests.Tests; /// email_status enum, LIMIT, FOR UPDATE SKIP LOCKED, SELECT * materialization) so a schema rename breaks /// this test rather than only surfacing in the Cron host at runtime. /// +// Serialized with EmailOutboxDeliveryTests: both share the session Postgres, and the delivery job +// claims due rows under FOR UPDATE (oldest-first). Running concurrently, that lock makes this test's +// FOR UPDATE SKIP LOCKED read skip its own (oldest) row. The shared key removes that window. +[NotInParallel("email-outbox")] public sealed class EmailOutboxQueryTests { [ClassDataSource(Shared = SharedType.PerTestSession)]