2026-09-15 23:24:11 +02:00
using System.Globalization ;
using System.Net ;
2026-09-16 17:08:17 +02:00
using System.Net.Sockets ;
2026-09-15 23:24:11 +02:00
using System.Runtime.InteropServices ;
2026-09-16 17:08:17 +02:00
using System.Security.Authentication ;
using System.Security.Cryptography ;
2026-09-15 23:24:11 +02:00
using System.Text.Json ;
using VoiceCat.Crypto ;
using VoiceCat.Server.Data ;
2026-09-16 17:08:17 +02:00
using VoiceCat.Transport ;
2026-09-15 23:24:11 +02:00
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" ))
{
2026-09-16 17:08:17 +02:00
await output . WriteLineAsync ( "VoiceCat TLS/UDP server\n--data-dir PATH --bind IP --port PORT --name NAME --allow-guests true|false\n--max-connections N --handshake-seconds N --idle-seconds N --reaper-seconds N\n--auth-burst N --auth-refill-seconds N --print-config --print-fingerprint\n--health-check HOST:PORT [--expect-fingerprint SHA256]\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported." );
2026-09-15 23:24:11 +02:00
return 0 ;
}
try
{
2026-09-16 17:08:17 +02:00
int healthIndex = Array . IndexOf ( args , "--health-check" );
if ( healthIndex >= 0 )
{
if ( healthIndex + 1 >= args . Length ) throw new ArgumentException ( "Health endpoint required." );
string? expected = null ;
int fingerprintIndex = Array . IndexOf ( args , "--expect-fingerprint" );
if ( fingerprintIndex >= 0 ) expected = fingerprintIndex + 1 < args . Length ? args [ fingerprintIndex + 1 ] : throw new ArgumentException ( "Expected fingerprint required." );
return await HealthCheckAsync ( args [ healthIndex + 1 ], expected , output , cancellationToken ). ConfigureAwait ( false );
}
2026-09-15 23:24:11 +02:00
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 );
}
}
2026-09-16 17:08:17 +02:00
catch ( Exception exception ) when ( exception is ArgumentException or FormatException or OverflowException or IOException or InvalidOperationException or SocketException or AuthenticationException or CryptographicException or Microsoft . Data . Sqlite . SqliteException or TimeoutException || exception is OperationCanceledException && ! cancellationToken . IsCancellationRequested )
2026-09-15 23:24:11 +02:00
{
await error . WriteLineAsync ( "VoiceCat command failed: " + exception . GetType (). Name + ". Check configuration, data files and port availability." );
return 1 ;
}
}
2026-09-16 17:08:17 +02:00
private static async Task < int > HealthCheckAsync ( string endpoint , string? expectedFingerprint , TextWriter output , CancellationToken cancellationToken )
{
int separator = endpoint . LastIndexOf ( ':' );
if ( separator < 1 || ! ushort . TryParse ( endpoint [( separator + 1 )..], out ushort port )) throw new ArgumentException ( "Health endpoint must be HOST:PORT." );
string host = endpoint [.. separator ]. Trim ( '[' , ']' );
byte []? expected = null ;
if ( expectedFingerprint is not null )
{
try { expected = Convert . FromHexString ( expectedFingerprint ); } catch ( FormatException ) { throw new ArgumentException ( "Expected fingerprint must be hexadecimal." ); }
if ( expected . Length != 32 ) throw new ArgumentException ( "Expected fingerprint must be SHA-256." );
}
string? actual = null ;
using var deadline = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ); deadline . CancelAfter ( TimeSpan . FromSeconds ( 5 ));
using var socket = new Socket ( SocketType . Stream , ProtocolType . Tcp );
await socket . ConnectAsync ( host , port , deadline . Token ). ConfigureAwait ( false );
using var tls = TlsSession . CreateClient ( fingerprint => { actual = fingerprint ; return true ; });
await using var connection = new TlsControlConnection ( socket , tls , deadline . Token , TimeSpan . FromSeconds ( 5 ));
using MediaSessionCrypto crypto = await connection . TakeMediaCryptoAsync ( deadline . Token ). ConfigureAwait ( false );
if ( actual is null || expected is not null && ! CryptographicOperations . FixedTimeEquals ( Convert . FromHexString ( actual ), expected )) return 1 ;
await output . WriteLineAsync ( JsonSerializer . Serialize ( new { status = "healthy" , certificate_fingerprint = actual }));
return 0 ;
}
2026-09-15 23:24:11 +02:00
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 ());
}
}