Files
voice-cat/dotnet/src/VoiceCat.Server/VoiceServer.cs
T

266 lines
13 KiB
C#
Raw Normal View History

using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using Google.Protobuf;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using VoiceCat.Server.Transport;
using Voicecat.V1;
namespace VoiceCat.Server;
public sealed class VoiceServer : IAsyncDisposable
{
private readonly Socket listener;
private readonly ServerCredentials credentials;
private readonly AccountStore accounts;
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
private readonly bool allowGuests;
private readonly string name;
private readonly CancellationTokenSource shutdown = new();
private readonly object gate = new();
private readonly Dictionary<ulong, Session> sessions = [];
private readonly List<Task> connections = [];
private ulong nextSession;
private uint nextUser;
private readonly Task accepting;
private int disposed;
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
public event Action<Exception>? ConnectionFailed;
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
{
this.allowGuests = allowGuests;
this.name = name;
credentials = ServerCredentials.LoadOrCreate(directory, name);
try
{
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
channels = accounts.LoadChannels();
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(endpoint);
listener.Listen(64);
}
catch
{
listener?.Dispose();
accounts?.Dispose();
credentials.Dispose();
shutdown.Dispose();
throw;
}
accepting = AcceptAsync();
}
private async Task AcceptAsync()
{
try
{
while (!shutdown.IsCancellationRequested)
{
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
lock (gate)
{
if (sessions.Count >= 64) { socket.Dispose(); continue; }
socket.NoDelay = true;
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token);
var session = new Session(++nextSession, connection, address);
sessions.Add(session.Id, session);
connections.RemoveAll(task => task.IsCompleted);
connections.Add(HandleAsync(session));
}
}
}
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
}
private async Task HandleAsync(Session session)
{
try
{
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
{
if (envelope.Ping is not null)
{
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
continue;
}
if (envelope.Disconnect is not null) { session.Connection.CompleteWrites(); break; }
if (!session.HelloReceived)
{
if (envelope.ClientHello?.ProtoVersion != 2 || accounts.IsBanned("ip", session.Address))
{
Reject(session, "Unsupported protocol version or banned address.");
break;
}
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
if (allowGuests) hello.AuthMethods.Add("guest");
hello.AuthMethods.Add("password");
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
session.HelloReceived = true;
continue;
}
if (session.User is null)
{
if (envelope.AuthRequest is null) { Reject(session, "Authentication required."); break; }
await AuthenticateAsync(session, envelope.RequestId, envelope.AuthRequest).ConfigureAwait(false);
continue;
}
switch (envelope.BodyCase)
{
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId); break;
case Envelope.BodyOneofCase.SubscribeVoice:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, VoiceSubscriptionResult = new() { Error = "Managed media relay is not implemented yet." } });
break;
default:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
break;
}
}
await session.Connection.Completion.ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
if (!shutdown.IsCancellationRequested && exception is not OperationCanceledException) ConnectionFailed?.Invoke(exception);
}
finally
{
lock (gate)
{
sessions.Remove(session.Id);
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } });
}
await session.Connection.DisposeAsync().ConfigureAwait(false);
}
}
private static void Reject(Session session, string reason)
{
session.Connection.TrySend(new() { Disconnect = new() { Code = 1, Reason = reason } });
session.Connection.CompleteWrites();
}
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
{
User? user = null;
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))
{
Account? account = await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
}
shutdown.Token.ThrowIfCancellationRequested();
session.Connection.CancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
if (user is null || lobby is null || lobby.PasswordProtected || lobby.MaxUsers != 0 && sessions.Values.Count(peer => peer.User?.ChannelId == 1) >= lobby.MaxUsers)
{
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() { Error = "Invalid credentials or lobby unavailable." } });
return;
}
user.Id = checked(++nextUser);
session.User = user;
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
{
Ok = true, SessionId = session.Id, Self = user.Clone(),
Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }
} });
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
SendSnapshot(session);
}
}
private void SendSnapshot(Session session)
{
lock (gate)
{
var snapshot = new ServerStateSnapshot();
snapshot.Channels.Add(channels.Select(channel => channel.Clone()));
snapshot.Users.Add(sessions.Values.Where(peer => peer.User is not null).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { ServerState = snapshot });
}
}
private void Join(Session session, ulong requestId, uint channelId)
{
lock (gate)
{
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
if (channel is null || channel.PasswordProtected || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
{
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
return;
}
session.User!.ChannelId = channelId;
var result = new JoinChannelResult { Ok = true, ChannelId = channelId, Audio = channel.Audio.Clone() };
result.Members.Add(sessions.Values.Where(peer => peer.User?.ChannelId == channelId).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = result });
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User.Clone() } });
}
}
private void RelayText(Session sender, TextMessage message)
{
lock (gate)
{
bool permitted = Encoding.UTF8.GetByteCount(message.Body) <= 4096 && message.ClientMsgId.Length <= 128 &&
(message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && message.TargetId == sender.User!.ChannelId ||
message.Scope == TextScope.TextPrivate && sessions.Values.Any(peer => peer.User?.Id == message.TargetId));
if (permitted)
{
var relay = message.Clone();
relay.SenderId = sender.User!.Id;
relay.SentAtUnixMs = checked((ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
var envelope = new Envelope { TextMessage = relay };
foreach (Session recipient in sessions.Values.Where(peer => peer.User is not null))
if (message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && recipient.User!.ChannelId == message.TargetId ||
message.Scope == TextScope.TextPrivate && (recipient.User!.Id == message.TargetId || recipient.Id == sender.Id))
recipient.Connection.TrySend(envelope);
}
sender.Connection.TrySend(new() { TextMessageAck = new() { ClientMsgId = message.ClientMsgId, Ok = permitted } });
}
}
private void Broadcast(Envelope envelope, ulong excluded = 0)
{
foreach (Session recipient in sessions.Values.Where(peer => peer.Id != excluded && peer.User is not null)) recipient.Connection.TrySend(envelope);
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
shutdown.Cancel();
listener.Dispose();
try
{
await accepting.ConfigureAwait(false);
Task[] pending;
lock (gate) pending = connections.ToArray();
await Task.WhenAll(pending).ConfigureAwait(false);
}
finally
{
accounts.Dispose();
credentials.Dispose();
shutdown.Dispose();
}
}
private sealed class Session(ulong id, TlsControlConnection connection, string address)
{
public ulong Id { get; } = id;
public TlsControlConnection Connection { get; } = connection;
public string Address { get; } = address;
public bool HelloReceived { get; set; }
public User? User { get; set; }
}
}