Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 55 additions & 27 deletions .env
Original file line number Diff line number Diff line change
@@ -1,27 +1,55 @@
# Required variables (uncomment and set values!)
#PG_PASS=someSecurePassword

# 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

#global email config
OPENSHOCK__MAIL__SENDER__NAME=OpenShock System
OPENSHOCK__MAIL__SENDER__EMAIL=system@openshock.app

#mail configs. uncomment one of the 2 sections below and make your config changes

#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

#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
# 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.

# --- Required ---
# 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.
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

# --- Database (optional, defaults shown) ---
#PG_USER=openshock
#PG_DB=openshock

# --- Feature flags (optional, defaults shown) ---
#OPENSHOCK_TURNSTILE_ENABLE=false
#OPENSHOCK_REGISTRATION_ENABLED=true
#OPENSHOCK_LCG_COUNTRYCODE=DE

# --- 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
67 changes: 64 additions & 3 deletions API.IntegrationTests/Tests/LcgAssignmentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -162,9 +165,67 @@ private async Task AddGateways(string[] availableGateways, string? environmentOv
await lcgNodesCollection.InsertAsync(testGateways);
}

private async Task<HttpResponseMessage> 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<IRedisConnectionProvider>();
var webHostEnvironment = context.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
var lcgNodesCollection = redisConnectionProvider.RedisCollection<LcgNode>(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<LcgNodeResponseV2>();
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<LcgNodeResponseV2>();
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<HttpResponseMessage> 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);

Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Device/AssignLCG.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task<IActionResult> GetLiveControlGateway([FromServices] ILCGNodePr

return LegacyDataOk(new LcgNodeResponse
{
Fqdn = closestNode.Fqdn,
Fqdn = closestNode.Host,
Country = closestNode.Country
});
}
Expand Down
6 changes: 3 additions & 3 deletions API/Controller/Device/AssignLCGV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ public async Task<IActionResult> 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
});
}
Expand Down
55 changes: 45 additions & 10 deletions API/Controller/Devices/DevicesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -229,35 +230,69 @@ public async Task<IActionResult> GetPairCode([FromRoute] Guid deviceId)
/// <response code="200">LCG node was found and device is online</response>
/// <response code="404">Device does not exist or does not belong to you</response>
/// <response code="404">Device is not online</response>
/// <response code="412">Device is online but not connected to a LCG node, you might need to upgrade your firmware to use this feature</response>
/// <response code="500">Internal server error, lcg node could not be found</response>
[HttpGet("{deviceId}/lcg")]
[ProducesResponseType<LegacyDataResponse<LcgResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound, DeviceIsNotOnline
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status412PreconditionFailed, MediaTypeNames.Application.ProblemJson)] // DeviceNotConnectedToGateway
[MapToApiVersion("1")]
public async Task<IActionResult> 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
});
}

/// <summary>
/// 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).
/// </summary>
/// <response code="200">Successfully retrieved live control gateway info</response>
/// <response code="404">Device does not exist or is not online</response>
[HttpGet("{deviceId}/lcg")]
[ProducesResponseType<LcgResponseV2>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound, DeviceIsNotOnline
[MapToApiVersion("2")]
public async Task<IActionResult> 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
});
}

/// <summary>
/// Shared lookup for the LCG node a hub is currently connected to. Returns the node, or an
/// <see cref="OpenShockProblem"/> when the caller lacks access, the hub is offline, or it has
/// no gateway.
/// </summary>
private async Task<OneOf<LcgNode, OpenShockProblem>> 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<DeviceOnline>();
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<LcgNode>();
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;
}
}
6 changes: 5 additions & 1 deletion API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("OpenShock:Api:PathBase");

await app.UseCommonOpenShockMiddleware(apiPathBase);

if (!databaseOptions.SkipMigration)
{
Expand Down
18 changes: 18 additions & 0 deletions Common/Models/LcgResponseV2.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace OpenShock.Common.Models;

public sealed class LcgResponseV2
{
/// <summary>Public host of the gateway the hub is connected to.</summary>
public required string Host { get; init; }

/// <summary>Public port to connect to (default 443).</summary>
public required ushort Port { get; init; }

/// <summary>
/// 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 (<c>/1/ws/live/{hubId}</c>) itself.
/// </summary>
public required string PathPrefix { get; init; }

public required string Country { get; init; }
}
21 changes: 20 additions & 1 deletion Common/OpenShockMiddlewareHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,20 @@ public static class OpenShockMiddlewareHelper
ForwardedForHeaderName = "CF-Connecting-IP"
};

public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this WebApplication app)
public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this WebApplication app, string? pathBase = null)
{
var metricsOptions = app.Services.GetRequiredService<MetricsOptions>();
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);
Expand Down Expand Up @@ -93,6 +102,16 @@ public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this
return app;
}

/// <summary>
/// Normalizes a configured path base into a valid <see cref="PathString"/>: ensures a single
/// leading slash and strips any trailing slash (so "api", "/api", "/api/" all become "/api").
/// </summary>
private static string NormalizePathBase(string pathBase)
{
var trimmed = pathBase.Trim().Trim('/');
return trimmed.Length == 0 ? "/" : "/" + trimmed;
}

public static async Task<IApplicationBuilder> ApplyPendingOpenShockMigrations(this IApplicationBuilder app, DatabaseOptions options)
{
using var scope = app.ApplicationServices.CreateScope();
Expand Down
24 changes: 19 additions & 5 deletions Common/Redis/LcgNode.cs
Original file line number Diff line number Diff line change
@@ -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; }
/// <summary>
/// Composite public node id (the Redis key): the gateway's full public base as
/// <c>host[:port][/path]</c>. 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.
/// </summary>
[RedisIdField] public required string Id { get; set; }

/// <summary>Public host clients connect to.</summary>
public required string Host { get; set; }

/// <summary>Public port clients connect to (default 443).</summary>
public required ushort Port { get; set; }

/// <summary>Public path prefix the gateway is served under, e.g. "/gateway" (empty = root).</summary>
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";

}
}
3 changes: 3 additions & 0 deletions Cron.IntegrationTests/Tests/EmailOutboxDeliveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
// 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<CronApplicationFactory>(Shared = SharedType.PerTestSession)]
Expand Down
Loading
Loading