Harden managed server deployment and authentication

This commit is contained in:
2026-09-15 23:24:11 +02:00
parent 653131b876
commit 5a226ba543
17 changed files with 615 additions and 15 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ dotnet test dotnet/VoiceCat.slnx -c Release --no-build
See `dotnet/README.md` for C# conventions and required native voice/CLI conformance,
and `docs/api-dotnet.md` for managed interfaces. Phase 4 remains in progress;
media-aware reaping is implemented; server administration and audio/client/UI phases remain pending.
media-aware reaping and server administration are implemented. Server deployment hardening
and managed audio/client/Windows cutover are in progress; Apple GUI rewrites follow Windows.
The default development preset is **`dev`** — it builds everything (server + tools + tests)
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
+19
View File
@@ -10,6 +10,25 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
- **Done (2026-09-15): Server deployment hardening checkpoint.** Pushed existing work
through `653131b` to `origin/dotnet/foundations`. Added CLI/environment configuration,
all-interface port 8384 defaults, config/fingerprint commands, local administrator
provisioning with hidden/stdin/environment password input, JSON readiness, an exclusive
instance lock, transport-failure observation and ten-second graceful shutdown.
Password-attempt buckets are shared by IP/account across reconnects, bounded to 4096
keys and enforce configurable burst/refill plus escalating backoff before Argon2.
Added self-contained win-x64 publishing with separate checked runtime lock files;
server publishes without the audio/codec shim. Behavior tests verify precedence,
invalid input, cross-connection/account throttling, readiness, duplicate-instance refusal,
active TLS shutdown and real published-executable provisioning/fingerprint persistence.
**Verified:** 174 managed tests with all conformance/published-server variables enabled;
warning-free Release build, native build/29 CTest tests and permissive package audit.
**Next (user priority):** managed client-core and audio prerequisites, then Windows
WinForms cutover before either Apple GUI. Windows is already C# at the UI layer but
still depends on native protocol/audio. Linux publishing/container/service packaging
and operational soak remain before broad production rollout; do not claim Phase 4
production rollout or GUI parity complete without those platform/behavior checks.
- **Done (2026-09-15): Managed channel and administration checkpoint.** Reaper committed
as `274b850`. Added protected joins and capacity checks, leave-to-Lobby, persisted channel
create/edit/delete with events, parent/cycle validation and Lobby protection. Native
+7
View File
@@ -190,6 +190,13 @@ timeout (15 seconds), idle timeout (45 seconds) and reaper interval (15 seconds)
Zero idle timeout disables reaping; active reaping requires a positive interval.
Invalid options fail before creating credentials, databases or sockets.
Options also configure authentication burst/refill (5 attempts / one per ten seconds).
The bounded address/account limiter runs before Argon2 and survives reconnects within
the process, with escalating failure backoff. `Completion` reports unexpected termination
of listener/media/active-reaper tasks; hosts should observe it and stop on failure.
The executable supports configuration/environment, local account provisioning, JSON
readiness, exclusive instance locking and bounded graceful shutdown; see deployment.md.
Authentication starts users in unprotected Lobby (id 1), subject to its capacity.
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
+55
View File
@@ -1,5 +1,60 @@
# Deployment & Self-Hosting
## Managed server deployment checkpoint
The .NET server preserves protocol v2 and the native schema/credentials. Publish the
Windows self-contained executable (no installed .NET runtime required):
```powershell
./dotnet/publish-server.ps1
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --help
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe account add Operator --admin --data-dir ./voicecat-data
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --data-dir ./voicecat-data --allow-guests false
```
Account add/reset use a hidden password prompt, redirected standard input, or
`VOICECAT_ADMIN_PASSWORD`. Passwords are never accepted as command arguments or logged.
Account delete/list work against the same database, including while the server runs.
Provisioning grants administrator access only through the local command's `--admin`;
in-band account creation remains non-admin. Restrict access to the data directory.
Defaults are `0.0.0.0:8384` TCP+UDP, guest access enabled, 64 connections, 15-second TLS
handshakes, 45-second idle expiry and 15-second sweeps. Override with flags or environment:
| Flag | Environment variable |
|---|---|
| `--data-dir` | `VOICECAT_DATA_DIR` |
| `--bind` | `VOICECAT_BIND_ADDRESS` |
| `--port` | `VOICECAT_BIND_PORT` |
| `--name` | `VOICECAT_SERVER_NAME` |
| `--allow-guests` | `VOICECAT_ALLOW_GUESTS` |
| `--max-connections` | `VOICECAT_MAX_CONNECTIONS` |
| `--handshake-seconds` | `VOICECAT_HANDSHAKE_TIMEOUT_SECONDS` |
| `--idle-seconds` | `VOICECAT_IDLE_TIMEOUT_SECONDS` |
| `--reaper-seconds` | `VOICECAT_REAPER_INTERVAL_SECONDS` |
| `--auth-burst` | `VOICECAT_AUTH_BURST` |
| `--auth-refill-seconds` | `VOICECAT_AUTH_REFILL_SECONDS` |
Command arguments override environment values. `--print-config` validates and prints JSON
without creating files; `--print-fingerprint` prints the persisted leaf-certificate SHA-256
pin. Startup emits one JSON `ready` event with both fingerprints and actual TCP/UDP ports.
Bind accepts IP literals; IPv6 listeners are IPv6-only. Open/forward both protocols.
An exclusive data-directory instance lock prevents duplicate managed server processes.
Ctrl+C and Unix SIGINT/SIGTERM stop all transport tasks; shutdown has a ten-second deadline.
Fatal listener/media/reaper failure exits the host rather than leaving a broken listener.
Password authentication is limited before Argon2 by source address and username across
connections: burst 5, refill one attempt per ten seconds. Starting at three failed attempts,
backoff grows from one to thirty seconds. Success clears backoff but does not restore
tokens. State is bounded to 4096 keys; idle entries retire after ten minutes when full.
Throttle and credential failures share the generic auth error. Limits are process-local.
The publish script uses separate runtime lock files so deployment and development restore
graphs remain reproducible. The checked deployment target is currently Windows x64;
Linux container publishing, service packaging, TOML/reload support and long-running
operational validation remain before broad production rollout. The native deployment
paths and planned operational features below remain available as the migration oracle.
The product goal: someone looks at this and thinks *"oh, I (or my agent) can stand this up
in a few minutes."* Everything below is in service of that. Three install paths, all
**zero-config and encrypted by default**.
+1 -1
View File
@@ -4,7 +4,7 @@
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(MSBuildProjectName)' != 'VoiceCat.Server' and '$(MSBuildProjectName)' != 'VoiceCat.Crypto' and '$(MSBuildProjectName)' != 'VoiceCat.Protocol'">
<None Include="$(MSBuildThisFileDirectory)artifacts/native/licenses/*.txt" Link="licenses/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/voicecat_media.dll" Condition="Exists('$(VoiceCatNativeDirectory)/voicecat_media.dll')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.so" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.so')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+1 -1
View File
@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop'
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
$seen = @{}
foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages.lock.json -Recurse)) {
foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter 'packages*.lock.json' -Recurse)) {
$lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
foreach ($framework in $lock.dependencies.PSObject.Properties) {
+7
View File
@@ -0,0 +1,7 @@
param(
[string]$Runtime = 'win-x64',
[string]$Output = "$PSScriptRoot/artifacts/server/$Runtime"
)
$ErrorActionPreference = 'Stop'
dotnet publish "$PSScriptRoot/src/VoiceCat.Server/VoiceCat.Server.csproj" -c Release -r $Runtime --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:RestoreLockedMode=true "-p:NuGetLockFilePath=packages.publish.$Runtime.lock.json" -o $Output
if ($LASTEXITCODE -ne 0) { throw 'Managed server publish failed.' }
@@ -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/win-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/win-x64": {}
}
}
@@ -0,0 +1,57 @@
namespace VoiceCat.Server;
// Bounds password work before Argon2. Both source address and account share the limit
// across connections; failed attempts cannot bypass it by reconnecting.
internal sealed class AuthenticationLimiter(VoiceServerOptions options, TimeProvider clock)
{
private readonly object gate = new();
private readonly Dictionary<string, Bucket> buckets = new(StringComparer.Ordinal);
private const int MaximumKeys = 4096;
internal bool TryAcquire(string address, string username)
{
lock (gate)
{
long now = clock.GetTimestamp();
string[] keys = ["ip:" + address, "user:" + username];
if (buckets.Count > MaximumKeys - 2)
foreach (var key in buckets.Where(pair => clock.GetElapsedTime(pair.Value.Updated, now) > TimeSpan.FromMinutes(10)).Select(pair => pair.Key).ToArray()) buckets.Remove(key);
foreach (string key in keys)
{
if (!buckets.TryGetValue(key, out Bucket? bucket))
{
if (buckets.Count >= MaximumKeys) return false;
buckets.Add(key, bucket = new(options.AuthenticationBurst, now));
}
double elapsed = Math.Max(0, clock.GetElapsedTime(bucket.Updated, now).TotalSeconds);
bucket.Tokens = Math.Min(options.AuthenticationBurst, bucket.Tokens + elapsed / options.AuthenticationRefillInterval.TotalSeconds);
bucket.Updated = now;
if (bucket.Tokens < 1 || now < bucket.BlockedUntil) return false;
}
foreach (string key in keys) buckets[key].Tokens--;
return true;
}
}
internal void Record(string address, string username, bool success)
{
lock (gate)
{
foreach (string key in new[] { "ip:" + address, "user:" + username })
{
if (!buckets.TryGetValue(key, out Bucket? bucket)) continue;
bucket.Failures = success ? 0 : Math.Min(8, bucket.Failures + 1);
bucket.BlockedUntil = bucket.Failures < 3 ? 0 :
clock.GetTimestamp() + checked((long)(Math.Min(30, 1 << (bucket.Failures - 3)) * (double)clock.TimestampFrequency));
}
}
}
private sealed class Bucket(double tokens, long updated)
{
internal double Tokens = tokens;
internal long Updated = updated;
internal long BlockedUntil;
internal int Failures;
}
}
+1 -10
View File
@@ -1,12 +1,3 @@
using System.Net;
using VoiceCat.Server;
string directory = args.Length > 0 ? args[0] : "voicecat-data";
int port = args.Length > 1 ? int.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture) : 7443;
using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; stop.Cancel(); };
await using var server = new VoiceServer(directory, new IPEndPoint(IPAddress.Loopback, port));
server.ConnectionFailed += exception => Console.Error.WriteLine($"Connection closed: {exception.Message}");
Console.WriteLine($"VoiceCat managed control server listening on {server.EndPoint}");
try { await Task.Delay(Timeout.Infinite, stop.Token); }
catch (OperationCanceledException) { }
return await ServerCommand.RunAsync(args, Console.Out, Console.Error);
+148
View File
@@ -0,0 +1,148 @@
using System.Globalization;
using System.Net;
using System.Runtime.InteropServices;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
namespace VoiceCat.Server;
internal sealed record ServerConfiguration(string Directory, string BindAddress, int Port, VoiceServerOptions Options);
internal static class ServerCommand
{
internal static ServerConfiguration Parse(string[] args, Func<string, string?> environment)
{
var values = new Dictionary<string, string>(StringComparer.Ordinal);
string[] names = ["data-dir", "bind", "port", "name", "allow-guests", "max-connections", "handshake-seconds", "idle-seconds", "reaper-seconds", "auth-burst", "auth-refill-seconds"];
string[] variables = ["DATA_DIR", "BIND_ADDRESS", "BIND_PORT", "SERVER_NAME", "ALLOW_GUESTS", "MAX_CONNECTIONS", "HANDSHAKE_TIMEOUT_SECONDS", "IDLE_TIMEOUT_SECONDS", "REAPER_INTERVAL_SECONDS", "AUTH_BURST", "AUTH_REFILL_SECONDS"];
for (int i = 0; i < names.Length; i++) if (environment("VOICECAT_" + variables[i]) is string value) values[names[i]] = value;
int positional = 0;
for (int i = 0; i < args.Length; i++)
{
string argument = args[i];
if (argument is "--print-config" or "--print-fingerprint" or "--admin") continue;
if (argument == "account") { i += i + 1 < args.Length && args[i + 1] == "list" ? 1 : 2; continue; }
if (!argument.StartsWith("--", StringComparison.Ordinal))
{
if (args.Contains("account")) throw new ArgumentException("Unexpected account argument; passwords are not command arguments.");
if (positional >= 2) throw new ArgumentException("Unexpected argument.");
values[positional++ == 0 ? "data-dir" : "port"] = argument;
continue;
}
string key = argument[2..];
if (!names.Contains(key) || ++i >= args.Length) throw new ArgumentException("Unknown option or missing value: " + argument);
values[key] = args[i];
}
string Get(string key, string fallback) => values.GetValueOrDefault(key, fallback);
int Number(string key, int fallback) => int.Parse(Get(key, fallback.ToString(CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture);
var options = new VoiceServerOptions
{
Name = Get("name", "VoiceCat Server"), AllowGuests = bool.Parse(Get("allow-guests", "true")), MaximumConnections = Number("max-connections", 64),
HandshakeTimeout = TimeSpan.FromSeconds(Number("handshake-seconds", 15)), IdleTimeout = TimeSpan.FromSeconds(Number("idle-seconds", 45)),
ReaperInterval = TimeSpan.FromSeconds(Number("reaper-seconds", 15)), AuthenticationBurst = Number("auth-burst", 5),
AuthenticationRefillInterval = TimeSpan.FromSeconds(Number("auth-refill-seconds", 10))
};
options.Validate();
int port = Number("port", 8384);
if (port is < 0 or > 65535) throw new ArgumentException("Port must be between 0 and 65535.");
string bind = Get("bind", "0.0.0.0");
if (!IPAddress.TryParse(bind, out _)) throw new ArgumentException("Bind address must be an IPv4 or IPv6 literal.");
return new(Path.GetFullPath(Get("data-dir", "voicecat-data")), bind, port, options);
}
internal static async Task<int> RunAsync(string[] args, TextWriter output, TextWriter error, CancellationToken cancellationToken = default)
{
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.");
return 0;
}
try
{
ServerConfiguration config = Parse(args, Environment.GetEnvironmentVariable);
if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; }
CreateDataDirectory(config.Directory);
int accountIndex = Array.IndexOf(args, "account");
if (accountIndex >= 0) return await AccountAsync(args, accountIndex, config, output, cancellationToken).ConfigureAwait(false);
if (args.Contains("--print-fingerprint"))
{
using var credentials = ServerCredentials.LoadOrCreate(config.Directory, config.Options.Name);
await output.WriteLineAsync(credentials.CertificateFingerprint);
return 0;
}
using var instanceLock = new FileStream(Path.Combine(config.Directory, ".server.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; stop.Cancel(); };
Console.CancelKeyPress += cancel;
using var terminate = OperatingSystem.IsWindows() ? null : PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; stop.Cancel(); });
using var interrupt = OperatingSystem.IsWindows() ? null : PosixSignalRegistration.Create(PosixSignal.SIGINT, context => { context.Cancel = true; stop.Cancel(); });
VoiceServer? server = null;
try
{
server = new(config.Directory, new(IPAddress.Parse(config.BindAddress), config.Port), config.Options);
server.ConnectionFailed += exception => error.WriteLine(JsonSerializer.Serialize(new { @event = "connection_closed", type = exception.GetType().Name }));
using var credentials = ServerCredentials.LoadOrCreate(config.Directory, config.Options.Name);
await output.WriteLineAsync(JsonSerializer.Serialize(new { @event = "ready", address = server.EndPoint.Address.ToString(), port = server.EndPoint.Port, udp_port = server.MediaEndPoint.Port,
certificate_fingerprint = credentials.CertificateFingerprint, identity_fingerprint = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(credentials.Identity.PublicKey)) }));
Task stopped = Task.Delay(Timeout.Infinite, stop.Token);
if (await Task.WhenAny(stopped, server.Completion).ConfigureAwait(false) == server.Completion) await server.Completion.ConfigureAwait(false);
return 0;
}
finally
{
Console.CancelKeyPress -= cancel;
stop.Cancel();
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)
{
await error.WriteLineAsync("VoiceCat command failed: " + exception.GetType().Name + ". Check configuration, data files and port availability.");
return 1;
}
}
private static void CreateDataDirectory(string directory)
{
if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory);
else System.IO.Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
private static async Task<int> AccountAsync(string[] args, int index, ServerConfiguration config, TextWriter output, CancellationToken cancellationToken)
{
if (index + 1 >= args.Length) throw new ArgumentException("Account operation required.");
string operation = args[index + 1];
using var store = new AccountStore(Path.Combine(config.Directory, "voicecat.db"));
if (operation == "list")
{
foreach (Account account in store.ListAccounts()) await output.WriteLineAsync(JsonSerializer.Serialize(new { account.Username, account.IsAdmin, account.CreatedAt, account.LastLogin }));
return 0;
}
if (index + 2 >= args.Length || args[index + 2].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException("Username required.");
string username = args[index + 2];
if (operation == "delete") return store.DeleteAccount(username) ? 0 : 1;
if (operation is not ("add" or "reset")) throw new ArgumentException("Unknown account operation.");
string password = Environment.GetEnvironmentVariable("VOICECAT_ADMIN_PASSWORD") ?? ReadPassword();
if (operation == "add") await store.CreateAccountAsync(username, password, args.Contains("--admin"), cancellationToken).ConfigureAwait(false);
else if (!await store.ResetPasswordAsync(username, password, cancellationToken).ConfigureAwait(false)) return 1;
await output.WriteLineAsync("Account updated.");
return 0;
}
private static string ReadPassword()
{
if (Console.IsInputRedirected) return Console.ReadLine() ?? throw new ArgumentException("Password input required.");
Console.Error.Write("Password: ");
var characters = new List<char>();
while (true)
{
ConsoleKeyInfo key = Console.ReadKey(true);
if (key.Key == ConsoleKey.Enter) break;
if (key.Key == ConsoleKey.Backspace) { if (characters.Count != 0) characters.RemoveAt(characters.Count - 1); }
else if (!char.IsControl(key.KeyChar) && characters.Count < 1024) characters.Add(key.KeyChar);
}
Console.Error.WriteLine();
return new string(characters.ToArray());
}
}
@@ -29,6 +29,7 @@ internal sealed class MediaRelay : IAsyncDisposable
private readonly byte[] input = new byte[65535];
private readonly MediaFanout fanout = new();
private readonly Task receiving;
internal Task Completion => receiving;
public IPEndPoint EndPoint { get; }
public event Action<Exception>? Failed;
+18 -2
View File
@@ -21,6 +21,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
private readonly string name;
private readonly VoiceServerOptions options;
private readonly TimeProvider clock;
private readonly AuthenticationLimiter authenticationLimiter;
private readonly CancellationTokenSource shutdown = new();
private readonly object gate = new();
private readonly Dictionary<ulong, Session> sessions = [];
@@ -35,6 +36,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
public IPEndPoint MediaEndPoint => media.EndPoint;
public event Action<Exception>? ConnectionFailed;
public Task Completion { get; }
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
@@ -45,6 +47,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
options.Validate();
this.options = options;
clock = timeProvider ?? TimeProvider.System;
authenticationLimiter = new(options, clock);
allowGuests = options.AllowGuests;
name = options.Name;
credentials = ServerCredentials.LoadOrCreate(directory, name);
@@ -68,6 +71,16 @@ public sealed partial class VoiceServer : IAsyncDisposable
}
accepting = AcceptAsync();
reaping = ReapAsync();
Completion = MonitorAsync();
}
private async Task MonitorAsync()
{
Task first = await Task.WhenAny(options.IdleTimeout == TimeSpan.Zero
? [accepting, media.Completion] : new[] { accepting, reaping, media.Completion }).ConfigureAwait(false);
if (shutdown.IsCancellationRequested) return;
await first.ConfigureAwait(false);
throw new IOException("A server transport loop stopped unexpectedly.");
}
private async Task AcceptAsync()
@@ -213,9 +226,12 @@ public sealed partial class VoiceServer : IAsyncDisposable
bool admin = false;
if (request.Guest is not null && allowGuests && request.Guest.Nickname.Length <= 128)
user = new() { Nickname = request.Guest.Nickname.Length == 0 ? "Guest" : request.Guest.Nickname, IsGuest = true, ChannelId = 1 };
else if (request.Password is not null && request.Password.Username.Length <= 128 && request.Password.Password.Length <= 1024 && !accounts.IsBanned("username", request.Password.Username))
else if (request.Password is not null && request.Password.Username.Length <= 128 && request.Password.Password.Length <= 1024 &&
authenticationLimiter.TryAcquire(session.Address, request.Password.Username))
{
Account? account = await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
Account? account = accounts.IsBanned("username", request.Password.Username) ? null :
await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
authenticationLimiter.Record(session.Address, request.Password.Username, account is not null);
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
}
shutdown.Token.ThrowIfCancellationRequested();
@@ -8,11 +8,15 @@ public sealed record VoiceServerOptions
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
public TimeSpan ReaperInterval { get; init; } = TimeSpan.FromSeconds(15);
public int AuthenticationBurst { get; init; } = 5;
public TimeSpan AuthenticationRefillInterval { get; init; } = TimeSpan.FromSeconds(10);
internal void Validate()
{
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
ArgumentOutOfRangeException.ThrowIfLessThan(MaximumConnections, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(AuthenticationBurst, 1);
if (AuthenticationRefillInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(AuthenticationRefillInterval));
if (HandshakeTimeout <= TimeSpan.Zero || HandshakeTimeout.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(HandshakeTimeout));
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
if (ReaperInterval < TimeSpan.Zero || ReaperInterval.TotalMilliseconds > uint.MaxValue - 1 || IdleTimeout > TimeSpan.Zero && ReaperInterval == TimeSpan.Zero)
@@ -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/win-x64": {
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
}
}
}
}
@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Server;
using VoiceCat.Server.Data;
using VoiceCat.Server.Transport;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ProductionServerTests
{
[PublishedServerFact]
public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-published-" + Guid.NewGuid().ToString("N"));
async Task<string> Run(params string[] arguments)
{
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER")!)
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
start.Environment["VOICECAT_ADMIN_PASSWORD"] = "published test password";
foreach (string argument in arguments.Concat(new[] { "--data-dir", directory })) start.ArgumentList.Add(argument);
using var process = System.Diagnostics.Process.Start(start)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
await process.WaitForExitAsync(timeout.Token);
string log = await stdout + await stderr;
Assert.True(process.ExitCode == 0, log); Assert.DoesNotContain("published test password", log);
return log;
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
try
{
await Run("account", "add", "Operator", "--admin");
Assert.Contains("Operator", await Run("account", "list"));
using (var store = new AccountStore(Path.Combine(directory, "voicecat.db")))
Assert.True((await store.AuthenticateAsync("Operator", "published test password"))!.IsAdmin);
string fingerprint = (await Run("--print-fingerprint")).Trim();
Assert.Equal(64, fingerprint.Length);
Assert.Equal(fingerprint, (await Run("--print-fingerprint")).Trim());
await Run("account", "reset", "Operator"); await Run("account", "delete", "Operator");
Assert.DoesNotContain("Operator", await Run("account", "list"));
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
private sealed class PublishedServerFactAttribute : FactAttribute
{
public PublishedServerFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER"))) Skip = "Set VOICECAT_PUBLISHED_SERVER to the self-contained executable.";
}
}
[Fact]
public void ConfigurationHonorsEnvironmentAndCommandPrecedenceWithoutCreatingFiles()
{
var env = new Dictionary<string, string> { ["VOICECAT_BIND_PORT"] = "9000", ["VOICECAT_ALLOW_GUESTS"] = "false", ["VOICECAT_SERVER_NAME"] = "Environment" };
var config = ServerCommand.Parse(["--port", "0", "--name", "Command", "--bind", "::1", "--idle-seconds", "0"], key => env.GetValueOrDefault(key));
Assert.Equal(0, config.Port); Assert.Equal("Command", config.Options.Name);
Assert.False(config.Options.AllowGuests); Assert.Equal("::1", config.BindAddress);
Assert.Equal(TimeSpan.Zero, config.Options.IdleTimeout);
Assert.Equal(8384, ServerCommand.Parse([], _ => null).Port);
Assert.Equal("0.0.0.0", ServerCommand.Parse([], _ => null).BindAddress);
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port", "65536"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--bind", "example.com"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--unencrypted", "true"], _ => null));
Assert.Throws<ArgumentOutOfRangeException>(() => ServerCommand.Parse(["--auth-burst", "0"], _ => null));
Assert.EndsWith("admin-data", ServerCommand.Parse(["account", "list", "--data-dir", "admin-data"], _ => null).Directory);
}
[Fact]
public void AuthenticationLimitsAreSharedAcrossConnectionsAndAddressesWithBackoff()
{
var clock = new TestClock();
var limiter = new AuthenticationLimiter(new() { AuthenticationBurst = 5 }, clock);
for (int i = 0; i < 3; i++) { Assert.True(limiter.TryAcquire("a", "user")); limiter.Record("a", "user", false); }
Assert.False(limiter.TryAcquire("a", "other"));
Assert.False(limiter.TryAcquire("b", "user"));
clock.Advance(1);
Assert.True(limiter.TryAcquire("b", "user")); limiter.Record("b", "user", false);
clock.Advance(1); Assert.False(limiter.TryAcquire("c", "user"));
clock.Advance(1); Assert.True(limiter.TryAcquire("c", "user")); limiter.Record("c", "user", true);
Assert.False(limiter.TryAcquire("d", "user")); // Success does not restore spent tokens.
clock.Advance(10); Assert.True(limiter.TryAcquire("d", "user"));
}
[Fact]
public async Task PasswordThrottleSurvivesReconnectAndRefillsBeforeSuccessfulLogin()
{
var clock = new TestClock();
await using var fixture = new ServerFixture(options: new() { AuthenticationBurst = 1 }, timeProvider: clock);
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await store.CreateAccountAsync("Member", "secret");
async Task<bool> Login(string password)
{
await using var client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); await client.ReadUntilAsync(e => e.ServerHello is not null);
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = password } } });
return (await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok;
}
Assert.False(await Login("wrong")); Assert.False(await Login("secret"));
clock.Advance(10); Assert.True(await Login("secret"));
}
[Fact]
public async Task HostPublishesReadinessPreventsDuplicateInstancesAndShutsDownActiveTls()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-host-" + Guid.NewGuid().ToString("N"));
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var output = new ReadyWriter(); var error = new StringWriter();
Task<int> running = ServerCommand.RunAsync(["--data-dir", directory, "--bind", "127.0.0.1", "--port", "0"], output, error, stop.Token);
try
{
using JsonDocument ready = JsonDocument.Parse(await output.Ready.Task.WaitAsync(stop.Token));
int port = ready.RootElement.GetProperty("port").GetInt32();
Assert.Equal(port, ready.RootElement.GetProperty("udp_port").GetInt32());
Assert.Equal(64, ready.RootElement.GetProperty("certificate_fingerprint").GetString()!.Length);
Assert.Equal(1, await ServerCommand.RunAsync(["--data-dir", directory, "--port", "0"], new StringWriter(), new StringWriter()));
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port), stop.Token);
string fingerprint = ready.RootElement.GetProperty("certificate_fingerprint").GetString()!;
await using var client = new Client(new TlsControlConnection(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
await client.LoginAsync("Host test");
stop.Cancel(); Assert.Equal(0, await running.WaitAsync(TimeSpan.FromSeconds(10)));
Assert.Equal("", error.ToString());
}
finally { stop.Cancel(); await running; Directory.Delete(directory, true); }
}
private sealed class ReadyWriter : StringWriter
{
internal TaskCompletionSource<string> Ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override Task WriteLineAsync(string? value) { if (value?.Contains("\"ready\"", StringComparison.Ordinal) == true) Ready.TrySetResult(value); return base.WriteLineAsync(value); }
}
private sealed class TestClock : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => timestamp;
internal void Advance(int seconds) => timestamp += seconds * 1000;
}
}