Package managed server for Linux production
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 17:08:17 +02:00
parent 48c754aed9
commit 7be93e81a1
15 changed files with 384 additions and 119 deletions
+41
View File
@@ -0,0 +1,41 @@
param(
[string]$HostName = "127.0.0.1",
[int]$Port = 8384,
[double]$Minutes = 30,
[int]$Pairs = 2,
[string]$CliDll = "$PSScriptRoot/src/VoiceCat.Cli/bin/Release/net10.0/VoiceCat.Cli.dll"
)
$ErrorActionPreference = "Stop"
if ($Minutes -le 0 -or $Pairs -lt 1) { throw "Minutes and Pairs must be positive." }
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$work = [IO.Path]::GetFullPath((Join-Path $tempRoot ("voicecat-soak-" + [Guid]::NewGuid().ToString("N"))))
if (-not $work.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or (Split-Path $work -Leaf) -notlike 'voicecat-soak-*') { throw "Unsafe soak workspace path." }
[IO.Directory]::CreateDirectory($work) | Out-Null
$deadline = [DateTime]::UtcNow.AddMinutes($Minutes)
$cycles = 0
try {
while ([DateTime]::UtcNow -lt $deadline) {
$processes = @()
for ($pair = 0; $pair -lt $Pairs; $pair++) {
$stamp = "$cycles-$pair"
foreach ($side in 0,1) {
$name = "Soak-$stamp-$side"; $send = "message-$stamp-$side"; $expect = "message-$stamp-$([int](1-$side))"
$start = [Diagnostics.ProcessStartInfo]::new("dotnet")
$start.UseShellExecute = $false; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true; $start.CreateNoWindow = $true
$arguments = @($CliDll,"--host",$HostName,"--port","$Port","--nickname",$name,"--pins",(Join-Path $work "$name.pins"),"--trust-first","--voice","--expect-voice","--send-text",$send,"--expect-text",$expect,"--start-delay-ms","1000","--timeout-seconds","20")
$start.Arguments = ($arguments | ForEach-Object { '"' + $_.Replace('"','\"') + '"' }) -join ' '
$processes += [Diagnostics.Process]::Start($start)
}
}
foreach ($process in $processes) {
$stdout = $process.StandardOutput.ReadToEndAsync(); $stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(30000)) { $process.Kill($true); throw "Soak client timed out." }
if ($process.ExitCode -ne 0) { throw "Soak client failed: $($stderr.Result) $($stdout.Result)" }
$process.Dispose()
}
$cycles++
Write-Host "Completed soak cycle $cycles ($($Pairs * 2) clients)."
}
Write-Host "Soak passed: $cycles cycles, $($cycles * $Pairs * 2) client sessions."
}
finally { if (Test-Path $work) { Remove-Item -LiteralPath $work -Recurse -Force } }
@@ -0,0 +1,31 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/linux-x64": {}
}
}
@@ -0,0 +1,26 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.36.1, )",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.83.0, )",
"resolved": "2.83.0",
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
}
},
"net10.0/linux-x64": {}
}
}
+38 -2
View File
@@ -1,9 +1,13 @@
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using VoiceCat.Transport;
namespace VoiceCat.Server;
@@ -55,11 +59,20 @@ internal static class ServerCommand
{
if (args.Contains("--help"))
{
await output.WriteLineAsync("VoiceCat TLS/UDP server\n--data-dir PATH --bind IP --port PORT --name NAME --allow-guests true|false\n--max-connections N --handshake-seconds N --idle-seconds N --reaper-seconds N\n--auth-burst N --auth-refill-seconds N --print-config --print-fingerprint\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported.");
await output.WriteLineAsync("VoiceCat TLS/UDP server\n--data-dir PATH --bind IP --port PORT --name NAME --allow-guests true|false\n--max-connections N --handshake-seconds N --idle-seconds N --reaper-seconds N\n--auth-burst N --auth-refill-seconds N --print-config --print-fingerprint\n--health-check HOST:PORT [--expect-fingerprint SHA256]\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported.");
return 0;
}
try
{
int healthIndex = Array.IndexOf(args, "--health-check");
if (healthIndex >= 0)
{
if (healthIndex + 1 >= args.Length) throw new ArgumentException("Health endpoint required.");
string? expected = null;
int fingerprintIndex = Array.IndexOf(args, "--expect-fingerprint");
if (fingerprintIndex >= 0) expected = fingerprintIndex + 1 < args.Length ? args[fingerprintIndex + 1] : throw new ArgumentException("Expected fingerprint required.");
return await HealthCheckAsync(args[healthIndex + 1], expected, output, cancellationToken).ConfigureAwait(false);
}
ServerConfiguration config = Parse(args, Environment.GetEnvironmentVariable);
if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; }
CreateDataDirectory(config.Directory);
@@ -96,13 +109,36 @@ internal static class ServerCommand
if (server is not null) await server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is ArgumentException or FormatException or OverflowException or IOException or InvalidOperationException or System.Net.Sockets.SocketException or Microsoft.Data.Sqlite.SqliteException or TimeoutException)
catch (Exception exception) when (exception is ArgumentException or FormatException or OverflowException or IOException or InvalidOperationException or SocketException or AuthenticationException or CryptographicException or Microsoft.Data.Sqlite.SqliteException or TimeoutException || exception is OperationCanceledException && !cancellationToken.IsCancellationRequested)
{
await error.WriteLineAsync("VoiceCat command failed: " + exception.GetType().Name + ". Check configuration, data files and port availability.");
return 1;
}
}
private static async Task<int> HealthCheckAsync(string endpoint, string? expectedFingerprint, TextWriter output, CancellationToken cancellationToken)
{
int separator = endpoint.LastIndexOf(':');
if (separator < 1 || !ushort.TryParse(endpoint[(separator + 1)..], out ushort port)) throw new ArgumentException("Health endpoint must be HOST:PORT.");
string host = endpoint[..separator].Trim('[', ']');
byte[]? expected = null;
if (expectedFingerprint is not null)
{
try { expected = Convert.FromHexString(expectedFingerprint); } catch (FormatException) { throw new ArgumentException("Expected fingerprint must be hexadecimal."); }
if (expected.Length != 32) throw new ArgumentException("Expected fingerprint must be SHA-256.");
}
string? actual = null;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(5));
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, deadline.Token).ConfigureAwait(false);
using var tls = TlsSession.CreateClient(fingerprint => { actual = fingerprint; return true; });
await using var connection = new TlsControlConnection(socket, tls, deadline.Token, TimeSpan.FromSeconds(5));
using MediaSessionCrypto crypto = await connection.TakeMediaCryptoAsync(deadline.Token).ConfigureAwait(false);
if (actual is null || expected is not null && !CryptographicOperations.FixedTimeEquals(Convert.FromHexString(actual), expected)) return 1;
await output.WriteLineAsync(JsonSerializer.Serialize(new { status = "healthy", certificate_fingerprint = actual }));
return 0;
}
private static void CreateDataDirectory(string directory)
{
if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory);
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
@@ -0,0 +1,90 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.Data.Sqlite.Core": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Direct",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/linux-x64": {
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
}
}
}
}
@@ -12,6 +12,20 @@ namespace VoiceCat.Tests;
public class ProductionServerTests
{
[Fact]
public async Task HealthCheckPerformsTlsHandshakeAndCanPinCertificate()
{
await using var fixture = new ServerFixture();
using var credentials = ServerCredentials.LoadOrCreate(fixture.Directory, "VoiceCat Server");
var output = new StringWriter(); var error = new StringWriter();
string endpoint = "127.0.0.1:" + fixture.Server.EndPoint.Port;
Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint], output, error));
Assert.Contains("\"status\":\"healthy\"", output.ToString());
output.GetStringBuilder().Clear();
Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", credentials.CertificateFingerprint], output, error));
Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", new string('0', 64)], output, error));
Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", "127.0.0.1:1"], output, error));
}
[PublishedServerFact]
public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput()
{