using System.Globalization; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Security.Authentication; using System.Security.Cryptography; using System.Text.Json; using VoiceCat.Crypto; using VoiceCat.Server.Data; using VoiceCat.Transport; namespace VoiceCat.Server; internal sealed record ServerConfiguration(string Directory, string BindAddress, int Port, VoiceServerOptions Options); internal static class ServerCommand { internal static ServerConfiguration Parse(string[] args, Func environment) { var values = new Dictionary(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 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\n--health-check HOST:PORT [--expect-fingerprint SHA256]\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported."); return 0; } try { int healthIndex = Array.IndexOf(args, "--health-check"); if (healthIndex >= 0) { if (healthIndex + 1 >= args.Length) throw new ArgumentException("Health endpoint required."); string? expected = null; int fingerprintIndex = Array.IndexOf(args, "--expect-fingerprint"); if (fingerprintIndex >= 0) expected = fingerprintIndex + 1 < args.Length ? args[fingerprintIndex + 1] : throw new ArgumentException("Expected fingerprint required."); return await HealthCheckAsync(args[healthIndex + 1], expected, output, cancellationToken).ConfigureAwait(false); } ServerConfiguration config = Parse(args, Environment.GetEnvironmentVariable); if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; } CreateDataDirectory(config.Directory); 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 SocketException or AuthenticationException or CryptographicException or Microsoft.Data.Sqlite.SqliteException or TimeoutException || exception is OperationCanceledException && !cancellationToken.IsCancellationRequested) { await error.WriteLineAsync("VoiceCat command failed: " + exception.GetType().Name + ". Check configuration, data files and port availability."); return 1; } } private static async Task HealthCheckAsync(string endpoint, string? expectedFingerprint, TextWriter output, CancellationToken cancellationToken) { int separator = endpoint.LastIndexOf(':'); if (separator < 1 || !ushort.TryParse(endpoint[(separator + 1)..], out ushort port)) throw new ArgumentException("Health endpoint must be HOST:PORT."); string host = endpoint[..separator].Trim('[', ']'); byte[]? expected = null; if (expectedFingerprint is not null) { try { expected = Convert.FromHexString(expectedFingerprint); } catch (FormatException) { throw new ArgumentException("Expected fingerprint must be hexadecimal."); } if (expected.Length != 32) throw new ArgumentException("Expected fingerprint must be SHA-256."); } string? actual = null; using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(5)); using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(host, port, deadline.Token).ConfigureAwait(false); using var tls = TlsSession.CreateClient(fingerprint => { actual = fingerprint; return true; }); await using var connection = new TlsControlConnection(socket, tls, deadline.Token, TimeSpan.FromSeconds(5)); using MediaSessionCrypto crypto = await connection.TakeMediaCryptoAsync(deadline.Token).ConfigureAwait(false); if (actual is null || expected is not null && !CryptographicOperations.FixedTimeEquals(Convert.FromHexString(actual), expected)) return 1; await output.WriteLineAsync(JsonSerializer.Serialize(new { status = "healthy", certificate_fingerprint = actual })); return 0; } private static void CreateDataDirectory(string directory) { if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory); else System.IO.Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } private static async Task 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(); 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()); } }