diff --git a/README.md b/README.md index ed43da7fb1..a03445fd6b 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,8 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will - `FileLogging:LogLevel`: Can be one of `Trace`, `Debug`, `Information`, `Warning`, `Error`, or `Critical`. Restricts what is put into the log files. Currently `Debug` is reccommended for help with error reporting. +- `FileLogging:ProviderNetworkDebug`: Boolean controlling whether or not Chat bot providers should log their raw network traffic. Currently only applies to IrcProvider. + - `Kestrel:Endpoints:Http:Url`: The URL (i.e. interface and ports) your application should listen on. General use case should be `http://localhost:` for restricted local connections. See the Remote Access section for configuring public access to the World Wide Web. This doesn't need to be changed using the docker setup and should be mapped with the `-p` option instead - `Database:DatabaseType`: Can be one of `SqlServer`, `MariaDB`, `MySql`, `PostgresSql`, or `Sqlite`. diff --git a/build/Version.props b/build/Version.props index 9c784ed4d7..e1726021b4 100644 --- a/build/Version.props +++ b/build/Version.props @@ -4,7 +4,7 @@ 6.10.0 - 5.2.0 + 5.3.0 10.10.0 7.0.0 16.0.0 diff --git a/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs b/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs index b4cb64a811..18e8a14df6 100644 --- a/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs +++ b/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs @@ -88,6 +88,7 @@ namespace Tgstation.Server.Api.Models case IrcPasswordType.NickServ: case IrcPasswordType.Sasl: case IrcPasswordType.Server: + case IrcPasswordType.Oper: PasswordType = passwordType; break; default: diff --git a/src/Tgstation.Server.Api/Models/IrcPasswordType.cs b/src/Tgstation.Server.Api/Models/IrcPasswordType.cs index add54b1565..9a0fca38c4 100644 --- a/src/Tgstation.Server.Api/Models/IrcPasswordType.cs +++ b/src/Tgstation.Server.Api/Models/IrcPasswordType.cs @@ -19,5 +19,10 @@ /// Use NickServ authentication. /// NickServ, + + /// + /// Use OPER authentication. + /// + Oper, } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index ad83a80b18..0eedc7c949 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -13,6 +13,7 @@ using Newtonsoft.Json; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -42,11 +43,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// public override string BotMention => client.Nickname; - /// - /// The client. - /// - readonly IrcFeatures client; - /// /// Address of the server to connect to. /// @@ -57,6 +53,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly ushort port; + /// + /// Wether or not this IRC client is to use ssl. + /// + readonly bool ssl; + /// /// IRC nickname. /// @@ -82,6 +83,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly Dictionary queryChannelIdMap; + /// + /// The obtained from constructor, used for the CTCP version string. + /// + readonly IAssemblyInformationProvider assemblyInfo; + + /// + /// The for the . + /// + readonly FileLoggingConfiguration loggingConfiguration; + + /// + /// The client. + /// + IrcFeatures client; + /// /// The used for . /// @@ -92,11 +108,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// ulong channelIdCounter; - /// - /// If we are disconnecting. - /// - bool disconnecting; - /// /// Initializes a new instance of the class. /// @@ -105,15 +116,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// The for the . /// The to get the from. /// The for the . + /// The for the . public IrcProvider( IJobManager jobManager, IAsyncDelayer asyncDelayer, ILogger logger, IAssemblyInformationProvider assemblyInformationProvider, - Models.ChatBot chatBot) + Models.ChatBot chatBot, + FileLoggingConfiguration loggingConfiguration) : base(jobManager, asyncDelayer, logger, chatBot) { ArgumentNullException.ThrowIfNull(assemblyInformationProvider); + ArgumentNullException.ThrowIfNull(loggingConfiguration); var builder = chatBot.CreateConnectionStringBuilder(); if (builder == null || !builder.Valid || builder is not IrcConnectionStringBuilder ircBuilder) @@ -121,33 +135,16 @@ namespace Tgstation.Server.Host.Components.Chat.Providers address = ircBuilder.Address!; port = ircBuilder.Port!.Value; + ssl = ircBuilder.UseSsl!.Value; nickname = ircBuilder.Nickname!; password = ircBuilder.Password!; passwordType = ircBuilder.PasswordType; - client = new IrcFeatures - { - SupportNonRfc = true, - CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...", - AutoRejoin = true, - AutoRejoinOnKick = true, - AutoRelogin = true, - AutoRetry = false, - AutoReconnect = false, - ActiveChannelSyncing = true, - AutoNickHandling = true, - CtcpVersion = assemblyInformationProvider.VersionString, - UseSsl = ircBuilder.UseSsl!.Value, - }; - if (ircBuilder.UseSsl.Value) - client.ValidateServerCertificate = true; // dunno if it defaults to that or what + assemblyInfo = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + this.loggingConfiguration = loggingConfiguration ?? throw new ArgumentNullException(nameof(loggingConfiguration)); - client.OnChannelMessage += Client_OnChannelMessage; - client.OnQueryMessage += Client_OnQueryMessage; - - /*client.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line); - client.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line);*/ + client = InstantiateClient(); channelIdMap = new Dictionary(); queryChannelIdMap = new Dictionary(); @@ -369,12 +366,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// protected override async ValueTask Connect(CancellationToken cancellationToken) { - disconnecting = false; cancellationToken.ThrowIfCancellationRequested(); try { await Task.Factory.StartNew( - () => client.Connect(address, port), + () => + { + client = InstantiateClient(); + client.Connect(address, port); + }, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current) @@ -382,70 +382,53 @@ namespace Tgstation.Server.Host.Components.Chat.Providers cancellationToken.ThrowIfCancellationRequested(); + listenTask = Task.Factory.StartNew( + () => + { + Logger.LogTrace("Starting blocking listen..."); + try + { + client.Listen(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "IRC Main Listen Exception!"); + } + + Logger.LogTrace("Exiting listening task..."); + }, + cancellationToken, + DefaultIOManager.BlockingTaskCreationOptions, + TaskScheduler.Current); + Logger.LogTrace("Authenticating ({passwordType})...", passwordType); switch (passwordType) { case IrcPasswordType.Server: - client.Login(nickname, nickname, 0, nickname, password); + client.RfcPass(password); + await Login(client, nickname, cancellationToken); break; case IrcPasswordType.NickServ: - client.Login(nickname, nickname, 0, nickname); + await Login(client, nickname, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password)); break; case IrcPasswordType.Sasl: await SaslAuthenticate(cancellationToken); break; + case IrcPasswordType.Oper: + await Login(client, nickname, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + client.RfcOper(nickname, password, Priority.Critical); + break; case null: - client.Login(nickname, nickname, 0, nickname); + await Login(client, nickname, cancellationToken); break; default: throw new InvalidOperationException($"Invalid IrcPasswordType: {passwordType.Value}"); } cancellationToken.ThrowIfCancellationRequested(); - Logger.LogTrace("Processing initial messages..."); - await NonBlockingListen(cancellationToken); - - var nickCheckCompleteTcs = new TaskCompletionSource(); - using (cancellationToken.Register(() => nickCheckCompleteTcs.TrySetCanceled(cancellationToken))) - { - listenTask = Task.Factory.StartNew( - async () => - { - Logger.LogTrace("Entering nick check loop"); - while (!disconnecting && client.IsConnected && client.Nickname != nickname) - { - client.ListenOnce(true); - if (disconnecting || !client.IsConnected) - break; - await NonBlockingListen(cancellationToken); - - // ensure we have the correct nick - if (client.GetIrcUser(nickname) == null) - client.RfcNick(nickname); - } - - nickCheckCompleteTcs.TrySetResult(); - - Logger.LogTrace("Starting blocking listen..."); - try - { - client.Listen(); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "IRC Main Listen Exception!"); - } - - Logger.LogTrace("Exiting listening task..."); - }, - cancellationToken, - DefaultIOManager.BlockingTaskCreationOptions, - TaskScheduler.Current); - - await nickCheckCompleteTcs.Task; - } Logger.LogTrace("Connection established!"); } @@ -487,6 +470,44 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } } + /// + /// Register the client on the network. + /// + /// IRC client. + /// Nickname. + /// Cancellation token. + /// that resolves when registration has been completed. + /// If the IRC server fails to respond. + async ValueTask Login(IrcFeatures client, string nickname, CancellationToken cancellationToken) + { + var promise = new TaskCompletionSource(); + + void Callback(object? sender, EventArgs e) + { + Logger.LogTrace("IRC Registered."); + promise.TrySetResult(e); + } + + client.OnRegistered += Callback; + + client.Login(nickname, nickname, 0, nickname); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + + try + { + await promise.Task.WaitAsync(cts.Token); + client.OnRegistered -= Callback; + } + catch (OperationCanceledException) + { + if (client.IsConnected) + client.Disconnect(); + throw new JobException("Timed out waiting for IRC Registration"); + } + } + /// /// Handle an IRC message. /// @@ -667,8 +688,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers Logger.LogTrace("Hard disconnect"); - disconnecting = true; - // This call blocks permanently randomly sometimes // Frankly I don't give a shit var disconnectTask = Task.Factory.StartNew( @@ -693,5 +712,48 @@ namespace Tgstation.Server.Host.Components.Chat.Providers listenTask ?? Task.CompletedTask), AsyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken)); } + + /// + /// Creates a new instance of the IRC client. + /// Reusing the same client after a disconnection seems to cause issues. + /// + /// The client to use. + IrcFeatures InstantiateClient() + { + var newClient = new IrcFeatures + { + SupportNonRfc = true, + CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...", + AutoRejoin = true, + AutoRejoinOnKick = true, + AutoRelogin = false, + AutoRetry = false, + AutoReconnect = false, + ActiveChannelSyncing = true, + AutoNickHandling = true, + CtcpVersion = assemblyInfo.VersionString, + UseSsl = ssl, + EnableUTF8Recode = true, + }; + if (ssl) + newClient.ValidateServerCertificate = true; // dunno if it defaults to that or what + + newClient.OnChannelMessage += Client_OnChannelMessage; + newClient.OnQueryMessage += Client_OnQueryMessage; + + if (loggingConfiguration.ProviderNetworkDebug) + { + newClient.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line); + newClient.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line); + } + + newClient.OnError += (sender, e) => + { + Logger.LogError("IRC ERROR: {error}", e.ErrorMessage); + newClient.Disconnect(); + }; + + return newClient; + } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs index ed3608b92d..41113d196f 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs @@ -40,6 +40,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly GeneralConfiguration generalConfiguration; + /// + /// The for the . + /// + readonly FileLoggingConfiguration loggingConfiguration; + /// /// Initializes a new instance of the class. /// @@ -48,18 +53,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// The value of . /// The value of . /// The containing the value of . + /// The containing the value of . public ProviderFactory( IJobManager jobManager, IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, ILoggerFactory loggerFactory, - IOptions generalConfigurationOptions) + IOptions generalConfigurationOptions, + IOptions loggingConfigurationOptions) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + loggingConfiguration = loggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions)); } /// @@ -73,7 +81,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers asyncDelayer, loggerFactory.CreateLogger(), assemblyInformationProvider, - settings), + settings, + loggingConfiguration), ChatProvider.Discord => new DiscordProvider( jobManager, asyncDelayer, diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 534daf8ebb..6bd98f2039 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -29,6 +29,11 @@ namespace Tgstation.Server.Host.Configuration /// public bool Disable { get; set; } + /// + /// If Chat Providers should log their network traffic. Normally disabled because it is too noisy. + /// + public bool ProviderNetworkDebug { get; set; } + /// /// The minimum to display in logs. /// diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs index 3f1e050219..bc1ecbf0a4 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs @@ -9,6 +9,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.System; @@ -22,15 +23,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests [TestMethod] public async Task TestConstructionAndDisposal() { - Assert.ThrowsException(() => new IrcProvider(null, null, null, null, null)); + Assert.ThrowsException(() => new IrcProvider(null, null, null, null, null, null)); var mockJobManager = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, null, null, null, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, null, null, null, null, null)); var mockAsyncDelayer = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null, null)); var mockLogger = new Mock>(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null, null)); var mockAss = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null, null)); var mockBot = new ChatBot { @@ -38,8 +39,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Instance = new Models.Instance(), Provider = ChatProvider.Irc }; + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, null)); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot)); + var mockLogConf = new FileLoggingConfiguration(); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, mockLogConf)); mockBot.ConnectionString = new IrcConnectionStringBuilder { @@ -49,7 +52,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Port = 6667 }.ToString(); - await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot).DisposeAsync(); + await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, mockLogConf).DisposeAsync(); } static ValueTask InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (ValueTask)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken }); @@ -78,16 +81,30 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests .Setup(x => x.WaitForJobCompletion(It.IsNotNull(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.FromResult(true)); var mockJobManager = mockSetup.Object; - await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger(), Mock.Of(), new ChatBot + + var chatBot = new ChatBot { ConnectionString = actualToken, Provider = ChatProvider.Irc, Instance = new Models.Instance(), - }); + }; + + await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger(), Mock.Of(), chatBot, new FileLoggingConfiguration()); Assert.IsFalse(provider.Connected); await InvokeConnect(provider); Assert.IsTrue(provider.Connected); + await Task.Delay(2000); // IRC servers do not like it when you connect and disconnect in rapid succession + + await provider.Disconnect(default); + Assert.IsFalse(provider.Connected); + + await Task.Delay(2000); // same as above + + await InvokeConnect(provider); + await Task.Delay(2000); // make sure it stays connected after a reconnect attempt + Assert.IsTrue(provider.Connected); + await provider.Disconnect(default); Assert.IsFalse(provider.Connected); }