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
11 changes: 10 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
**/bin
**/obj
**/obj
**/.KAST_DATA

.git
.venv
.vs
graphify-out
src/graphify-out
TestResults
**/artifacts
40 changes: 22 additions & 18 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,30 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="MinVer" Version="7.0.0" />
<PackageVersion Include="MudBlazor" Version="9.4.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="MudBlazor" Version="9.8.0" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.17.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.5" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.53.3" />
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.5" />
<PackageVersion Include="SteamKit2" Version="3.4.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
38 changes: 12 additions & 26 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>/. 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;
}
}
}
19 changes: 18 additions & 1 deletion src/KAST.UI/Components/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@using KAST.Core.Events
@inject IServerInstanceService ServerService
@inject NavigationManager Nav
@inject IConfiguration Configuration
@implements IAsyncDisposable

<MudNavMenu>
Expand Down Expand Up @@ -36,7 +37,7 @@
Nav.LocationChanged += OnLocationChanged;

_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/monitoring"))
.WithUrl(GetMonitoringHubUrl())
.WithAutomaticReconnect()
.Build();

Expand All @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion src/KAST.UI/Components/Pages/ServerEditTabs/MonitorTab.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject NavigationManager Nav
@inject IConfiguration Configuration
@inject KAST.UI.Services.ServerConsoleStore ConsoleStore

<MudGrid Class="mt-3" Spacing="3">
Expand Down Expand Up @@ -111,7 +112,7 @@
private async Task InitializeSignalR()
{
_hubConnection = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/monitoring"))
.WithUrl(GetMonitoringHubUrl())
.WithAutomaticReconnect()
.Build();

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/KAST.UI/KAST.UI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\KAST.Core\KAST.Core.csproj" />
Expand Down
10 changes: 10 additions & 0 deletions src/KAST.UI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"),
Expand Down
25 changes: 23 additions & 2 deletions tests/KAST.Tests/KastApiModsEndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,29 @@ public async ValueTask DisposeAsync()

private sealed class NoopAppEventBroadcaster : IAppEventBroadcaster
{
public event Action<ModDownloadProgressEvent>? OnModDownloadProgress;
public event Action<ModStatusChangedEvent>? OnModStatusChanged;
public event Action<ModDownloadProgressEvent>? OnModDownloadProgress
{
add
{
// Test double does not publish download progress notifications.
}
remove
{
// Test double does not publish download progress notifications.
}
}

public event Action<ModStatusChangedEvent>? 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;
Expand Down
Loading