Harden managed server deployment and authentication
This commit is contained in:
@@ -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,
|
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;
|
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)
|
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
|
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
||||||
|
|||||||
+19
@@ -10,6 +10,25 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ 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
|
- **Done (2026-09-15): Managed channel and administration checkpoint.** Reaper committed
|
||||||
as `274b850`. Added protected joins and capacity checks, leave-to-Lobby, persisted channel
|
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
|
create/edit/delete with events, parent/cycle validation and Lobby protection. Native
|
||||||
|
|||||||
@@ -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.
|
Zero idle timeout disables reaping; active reaping requires a positive interval.
|
||||||
Invalid options fail before creating credentials, databases or sockets.
|
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.
|
Authentication starts users in unprotected Lobby (id 1), subject to its capacity.
|
||||||
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
|
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
|
||||||
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
|
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
# Deployment & Self-Hosting
|
# 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
|
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
|
in a few minutes."* Everything below is in service of that. Three install paths, all
|
||||||
**zero-config and encrypted by default**.
|
**zero-config and encrypted by default**.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
|
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
|
||||||
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
|
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
|
||||||
</PropertyGroup>
|
</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="$(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)/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" />
|
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.so" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.so')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
|
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
|
||||||
$seen = @{}
|
$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
|
$lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json
|
||||||
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
|
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
|
||||||
foreach ($framework in $lock.dependencies.PSObject.Properties) {
|
foreach ($framework in $lock.dependencies.PSObject.Properties) {
|
||||||
|
|||||||
@@ -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,12 +1,3 @@
|
|||||||
using System.Net;
|
|
||||||
using VoiceCat.Server;
|
using VoiceCat.Server;
|
||||||
|
|
||||||
string directory = args.Length > 0 ? args[0] : "voicecat-data";
|
return await ServerCommand.RunAsync(args, Console.Out, Console.Error);
|
||||||
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) { }
|
|
||||||
|
|||||||
@@ -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 byte[] input = new byte[65535];
|
||||||
private readonly MediaFanout fanout = new();
|
private readonly MediaFanout fanout = new();
|
||||||
private readonly Task receiving;
|
private readonly Task receiving;
|
||||||
|
internal Task Completion => receiving;
|
||||||
public IPEndPoint EndPoint { get; }
|
public IPEndPoint EndPoint { get; }
|
||||||
public event Action<Exception>? Failed;
|
public event Action<Exception>? Failed;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
private readonly string name;
|
private readonly string name;
|
||||||
private readonly VoiceServerOptions options;
|
private readonly VoiceServerOptions options;
|
||||||
private readonly TimeProvider clock;
|
private readonly TimeProvider clock;
|
||||||
|
private readonly AuthenticationLimiter authenticationLimiter;
|
||||||
private readonly CancellationTokenSource shutdown = new();
|
private readonly CancellationTokenSource shutdown = new();
|
||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private readonly Dictionary<ulong, Session> sessions = [];
|
private readonly Dictionary<ulong, Session> sessions = [];
|
||||||
@@ -35,6 +36,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
|
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
|
||||||
public IPEndPoint MediaEndPoint => media.EndPoint;
|
public IPEndPoint MediaEndPoint => media.EndPoint;
|
||||||
public event Action<Exception>? ConnectionFailed;
|
public event Action<Exception>? ConnectionFailed;
|
||||||
|
public Task Completion { get; }
|
||||||
|
|
||||||
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
|
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
|
||||||
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
|
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
|
||||||
@@ -45,6 +47,7 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
options.Validate();
|
options.Validate();
|
||||||
this.options = options;
|
this.options = options;
|
||||||
clock = timeProvider ?? TimeProvider.System;
|
clock = timeProvider ?? TimeProvider.System;
|
||||||
|
authenticationLimiter = new(options, clock);
|
||||||
allowGuests = options.AllowGuests;
|
allowGuests = options.AllowGuests;
|
||||||
name = options.Name;
|
name = options.Name;
|
||||||
credentials = ServerCredentials.LoadOrCreate(directory, name);
|
credentials = ServerCredentials.LoadOrCreate(directory, name);
|
||||||
@@ -68,6 +71,16 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
accepting = AcceptAsync();
|
accepting = AcceptAsync();
|
||||||
reaping = ReapAsync();
|
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()
|
private async Task AcceptAsync()
|
||||||
@@ -213,9 +226,12 @@ public sealed partial class VoiceServer : IAsyncDisposable
|
|||||||
bool admin = false;
|
bool admin = false;
|
||||||
if (request.Guest is not null && allowGuests && request.Guest.Nickname.Length <= 128)
|
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 };
|
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; }
|
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
|
||||||
}
|
}
|
||||||
shutdown.Token.ThrowIfCancellationRequested();
|
shutdown.Token.ThrowIfCancellationRequested();
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ public sealed record VoiceServerOptions
|
|||||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
|
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
|
||||||
public TimeSpan ReaperInterval { get; init; } = TimeSpan.FromSeconds(15);
|
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()
|
internal void Validate()
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
|
||||||
ArgumentOutOfRangeException.ThrowIfLessThan(MaximumConnections, 1);
|
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 (HandshakeTimeout <= TimeSpan.Zero || HandshakeTimeout.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(HandshakeTimeout));
|
||||||
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
|
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
|
||||||
if (ReaperInterval < TimeSpan.Zero || ReaperInterval.TotalMilliseconds > uint.MaxValue - 1 || IdleTimeout > TimeSpan.Zero && ReaperInterval == TimeSpan.Zero)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user