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
@@ -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=="
}
}
}
}