diff --git a/.dockerignore b/.dockerignore index 31db540..34ceb6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,11 @@ **/bin -**/obj \ No newline at end of file +**/obj +**/.KAST_DATA + +.git +.venv +.vs +graphify-out +src/graphify-out +TestResults +**/artifacts diff --git a/Directory.Packages.props b/Directory.Packages.props index d7dca70..b35b5ae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,26 +6,30 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - - + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile index 9ed0531..669471d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ RUN dotnet restore KAST.slnx # Copy source and publish COPY src/ src/ WORKDIR /src/src/KAST.UI -RUN dotnet publish -c Release -o /app/publish --no-restore /p:MinVerVersionOverride=$APP_VERSION +RUN dotnet publish -c Release -o /app/publish --no-restore /p:MinVerVersionOverride=$APP_VERSION && \ + test -f /app/publish/wwwroot/_framework/blazor.web.js # Runtime image FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime diff --git a/README.md b/README.md index 6641b0c..7916392 100644 --- a/README.md +++ b/README.md @@ -105,18 +105,68 @@ The nightly tag always points to the latest development commit. Download URLs ar ### **Docker (Compose)** +Create a `docker-compose.yml` file: + ```yaml services: kast: image: ghcr.io/foxlider/kast:latest ports: - - "8080:8080" + - "5000:5000" # KAST Web UI + API (TCP) + - "2302-2322:2302-2322/udp" # Arma instance UDP blocks (game + Steam + BattlEye traffic) + # Optional: BattlEye RCon (UDP). Use a dedicated port configured in BEServer_x64.cfg. + # - "23150:23150/udp" volumes: - kast-data:/app/data + - kast-mods:/app/mods + - kast-servers:/app/servers + restart: unless-stopped + volumes: kast-data: + kast-mods: + kast-servers: +``` + +Start KAST and open `http://localhost:5000`: + +```bash +docker compose up -d ``` +#### **Arma 3 ports** + +- Use UDP for Arma ports. +- Publish one UDP range for game instances, for example `2302-2322/udp`. +- Each server instance uses 5 consecutive UDP ports. +- BattlEye RCon also uses UDP, but on its own `RConPort` from `BEServer_x64.cfg`. +- `RConPort` must be different from the game instance ports. + +Per-instance UDP block (base port `n`): + +| Port | Used for | +| --- | --- | +| `n` | Game traffic | +| `n+1` | Steam query | +| `n+2` | Steam traffic | +| `n+3` | Voice over network (VoN) | +| `n+4` | BattlEye traffic | + +Example with default range `2302-2322/udp`: + +| Server | Base port | Reserved UDP ports | +| --- | --- | --- | +| 1 | `2302` | `2302-2306` | +| 2 | `2307` | `2307-2311` | +| 3 | `2312` | `2312-2316` | +| 4 | `2317` | `2317-2321` | + +`2322` is unused in this example. + +If you need remote RCon, publish the dedicated RCon UDP port separately (example: `23150:23150/udp`) and set the same value in `BEServer_x64.cfg`. + +Port `5000/tcp` also serves KAST's management API. Do not expose it directly to the public internet; restrict it with a firewall, VPN, or authenticated reverse proxy. + ## **VERSIONING** KAST uses [MinVer](https://github.com/adamralph/minver) to derive the version from git tags at build time. diff --git a/docker-compose.yml b/docker-compose.yml index 783c05d..509ea95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: container_name: kast ports: - "5000:5000" # Web UI / API - - "2302-2322:2302-2322/udp" # Arma 3 game + Steam query ports - - "2302-2322:2302-2322/tcp" # BattlEye RCON (TCP) + # Arma 3 instance port blocks are UDP-only. Keep configured ports within this range. + - "2302-2322:2302-2322/udp" volumes: - kast-data:/app/data - kast-mods:/app/mods @@ -20,6 +20,8 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__Default=Data Source=/app/data/kast.db + # Server-rendered components must call Kestrel directly; port 80 is not Nginx inside this container. + - Kast__InternalBaseUrl=http://localhost:5000 - Telemetry__Enabled=true - Telemetry__OtlpEndpoint=http://jaeger:4317 depends_on: diff --git a/nginx.conf b/nginx.conf index 48ee092..a5d5a22 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,50 +3,36 @@ events { } http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + upstream kast { server kast:5000; } server { + # Public HTTP entry point: http:///. TLS and HTTP-to-HTTPS redirects are not configured here. listen 80; server_name _; - # Blazor Server requires WebSocket support + # Blazor Server, SignalR, and the API share the same upstream. location / { proxy_pass http://kast; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; - # Blazor Server needs long-lived connections + # SignalR connections can remain open for a long time. proxy_read_timeout 86400s; proxy_send_timeout 86400s; } - - # SignalR hubs - location /hubs/ { - proxy_pass http://kast; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 86400s; - } - - # API endpoints - location /api/ { - proxy_pass http://kast; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } } } diff --git a/src/KAST.UI/Components/Layout/NavMenu.razor b/src/KAST.UI/Components/Layout/NavMenu.razor index 819979b..8725cd9 100644 --- a/src/KAST.UI/Components/Layout/NavMenu.razor +++ b/src/KAST.UI/Components/Layout/NavMenu.razor @@ -2,6 +2,7 @@ @using KAST.Core.Events @inject IServerInstanceService ServerService @inject NavigationManager Nav +@inject IConfiguration Configuration @implements IAsyncDisposable @@ -36,7 +37,7 @@ Nav.LocationChanged += OnLocationChanged; _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/monitoring")) + .WithUrl(GetMonitoringHubUrl()) .WithAutomaticReconnect() .Build(); @@ -58,6 +59,22 @@ private async Task RefreshAsync() => _servers = await ServerService.GetAllInstancesAsync(); + // Preserve any app path base (e.g. /kast) when targeting the monitoring hub. + private Uri GetMonitoringHubUrl() + { + if (Uri.TryCreate(Configuration["Kast:InternalBaseUrl"], UriKind.Absolute, out var internalBaseUri)) + return AppendRelativePath(internalBaseUri, "hubs/monitoring"); + + return AppendRelativePath(new Uri(Nav.BaseUri, UriKind.Absolute), "hubs/monitoring"); + } + + private static Uri AppendRelativePath(Uri baseUri, string relativePath) + { + var builder = new UriBuilder(baseUri); + if (!builder.Path.EndsWith('/')) builder.Path += "/"; + return new Uri(builder.Uri, relativePath); + } + public async ValueTask DisposeAsync() { Nav.LocationChanged -= OnLocationChanged; diff --git a/src/KAST.UI/Components/Pages/ServerEditTabs/MonitorTab.razor b/src/KAST.UI/Components/Pages/ServerEditTabs/MonitorTab.razor index 85ee15a..fd23210 100644 --- a/src/KAST.UI/Components/Pages/ServerEditTabs/MonitorTab.razor +++ b/src/KAST.UI/Components/Pages/ServerEditTabs/MonitorTab.razor @@ -3,6 +3,7 @@ @implements IAsyncDisposable @inject IJSRuntime JS @inject NavigationManager Nav +@inject IConfiguration Configuration @inject KAST.UI.Services.ServerConsoleStore ConsoleStore @@ -111,7 +112,7 @@ private async Task InitializeSignalR() { _hubConnection = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/monitoring")) + .WithUrl(GetMonitoringHubUrl()) .WithAutomaticReconnect() .Build(); @@ -156,6 +157,22 @@ await _hubConnection.InvokeAsync("SubscribeToInstance", Instance.Id); } + // Preserve any app path base (e.g. /kast) when targeting the monitoring hub. + private Uri GetMonitoringHubUrl() + { + if (Uri.TryCreate(Configuration["Kast:InternalBaseUrl"], UriKind.Absolute, out var internalBaseUri)) + return AppendRelativePath(internalBaseUri, "hubs/monitoring"); + + return AppendRelativePath(new Uri(Nav.BaseUri, UriKind.Absolute), "hubs/monitoring"); + } + + private static Uri AppendRelativePath(Uri baseUri, string relativePath) + { + var builder = new UriBuilder(baseUri); + if (!builder.Path.EndsWith('/')) builder.Path += "/"; + return new Uri(builder.Uri, relativePath); + } + private void ClearConsole() { if (_consoleFilter == ConsoleFilter.All) diff --git a/src/KAST.UI/KAST.UI.csproj b/src/KAST.UI/KAST.UI.csproj index faf03f1..0c18ef2 100644 --- a/src/KAST.UI/KAST.UI.csproj +++ b/src/KAST.UI/KAST.UI.csproj @@ -4,6 +4,7 @@ enable enable true + true diff --git a/src/KAST.UI/Program.cs b/src/KAST.UI/Program.cs index afda877..45c24b7 100644 --- a/src/KAST.UI/Program.cs +++ b/src/KAST.UI/Program.cs @@ -8,6 +8,7 @@ using KAST.UI.Components; using KAST.UI.Hubs; using KAST.UI.Services; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using MudBlazor; using MudBlazor.Services; @@ -17,6 +18,15 @@ var builder = WebApplication.CreateBuilder(args); var contentRoot = builder.Environment.ContentRootPath; +if (builder.Environment.IsProduction()) +{ + // Docker persists this path in kast-data so protected browser values survive container recreation. + var dataProtectionKeysDirectory = Path.Join(contentRoot, "data", "keys"); + Directory.CreateDirectory(dataProtectionKeysDirectory); + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysDirectory)); +} + var outputSanitizer = new OutputSanitizer( new OutputSanitizer.VirtualPathRoot(contentRoot, "KAST"), new OutputSanitizer.VirtualPathRoot(ResolveConfiguredPath(contentRoot, builder.Configuration["Kast:ModsDirectory"] ?? "./mods"), "mods"), diff --git a/tests/KAST.Tests/KastApiModsEndToEndTests.cs b/tests/KAST.Tests/KastApiModsEndToEndTests.cs index 19dbdd5..b871af4 100644 --- a/tests/KAST.Tests/KastApiModsEndToEndTests.cs +++ b/tests/KAST.Tests/KastApiModsEndToEndTests.cs @@ -220,8 +220,29 @@ public async ValueTask DisposeAsync() private sealed class NoopAppEventBroadcaster : IAppEventBroadcaster { - public event Action? OnModDownloadProgress; - public event Action? OnModStatusChanged; + public event Action? OnModDownloadProgress + { + add + { + // Test double does not publish download progress notifications. + } + remove + { + // Test double does not publish download progress notifications. + } + } + + public event Action? OnModStatusChanged + { + add + { + // Test double does not publish status change notifications. + } + remove + { + // Test double does not publish status change notifications. + } + } public Task BroadcastDownloadProgressAsync(ModDownloadProgressEvent progress) => Task.CompletedTask; public Task BroadcastModStatusChangedAsync(ModStatusChangedEvent status) => Task.CompletedTask; public Task BroadcastServerStatusChangedAsync(ServerStatusChangedEvent status) => Task.CompletedTask;