Skip to content

Commit

Permalink
perform Twitch-API validation for testconfig command, some refactoring
Browse files Browse the repository at this point in the history
  • Loading branch information
Felk committed Aug 22, 2024
1 parent 5036b5b commit dba4a08
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 88 deletions.
12 changes: 10 additions & 2 deletions .github/workflows/deploy.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
# Test that the new executable runs with the existing config.
# Redirect any output just to make sure no json parsing errors or similar can leak secrets.
# Then just replace the executable atomically using mv and restart the service.
if ./core_update testconfig >/dev/null 2>&1 ; then
testconfig_output=$(./core_update testconfig 2>&1)
testconfig_exitcode=$?
if [[ $testconfig_exitcode == 0 ]]; then
\cp core core_deploybackup && \
mv core_update core && \
systemctl --user restart tpp-dualcore && \
echo "Successfully deployed!"
exit 0
elif [[ $testconfig_exitcode == 42 ]]; then
# 42 = Arbitrary exit code to indicate a semantic error, see also Program.cs
echo "Failed to run 'testconfig' for new deployment, a semantic error occurred:"
echo testconfig_output
exit 1
else
echo "Failed to run 'testconfig' for new deployment."
echo "Failed to run 'testconfig' for new deployment, an uncaught exception occurred."
echo "The output is suppressed to avoid leaking sensitive data, but this typically means the config file has syntactic or semantic errors."
exit 1
fi
76 changes: 2 additions & 74 deletions TPP.Core/Chat/TwitchChat.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
Expand All @@ -10,7 +9,6 @@
using TPP.Core.Overlay;
using TPP.Core.Utils;
using TPP.Persistence;
using TwitchLib.Api.Auth;
using User = TPP.Model.User;

namespace TPP.Core.Chat
Expand All @@ -25,7 +23,6 @@ public sealed class TwitchChat : IChat, IChatModeChanger, IExecutor
public readonly TwitchApi TwitchApi;
private readonly string _channelName;
private readonly string _botUsername;
private readonly string _appClientId;
private readonly TwitchChatSender _twitchChatSender;
private readonly TwitchChatModeChanger _twitchChatModeChanger;
private readonly TwitchChatExecutor _twitchChatExecutor;
Expand All @@ -47,7 +44,6 @@ public TwitchChat(
ChannelId = chatConfig.ChannelId;
_channelName = chatConfig.Channel;
_botUsername = chatConfig.Username;
_appClientId = chatConfig.AppClientId;

TwitchApi = new TwitchApi(
loggerFactory,
Expand Down Expand Up @@ -76,78 +72,10 @@ private void MessageReceived(object? sender, MessageEventArgs args)
IncomingMessage?.Invoke(this, args);
}

private enum ScopeType { Bot, Channel, Both }
private record ScopeInfo(string Scope, string NeededFor, ScopeType ScopeType);
/// Mostly copied from TPP.Core's README.md
private static readonly List<ScopeInfo> ScopeInfos =
[
new ScopeInfo("chat:read", "Read messages from chat (via IRC/TMI)", ScopeType.Bot),
new ScopeInfo("chat:edit", "Send messages to chat (via IRC/TMI)", ScopeType.Bot),
new ScopeInfo("user:bot", "Appear in chat as bot", ScopeType.Bot),
new ScopeInfo("user:read:chat", "Read messages from chat. (via EventSub)", ScopeType.Bot),
new ScopeInfo("user:write:chat", "Send messages to chat. (via Twitch API)", ScopeType.Bot),
new ScopeInfo("user:manage:whispers", "Sending and receiving whispers", ScopeType.Bot),
new ScopeInfo("moderator:read:chatters", "Read the chatters list in the channel (e.g. for badge drops)",
ScopeType.Bot),
new ScopeInfo("moderator:read:followers", "Read the followers list (currently old core)", ScopeType.Bot),
new ScopeInfo("moderator:manage:banned_users", "Timeout, ban and unban users (tpp automod, mod commands)",
ScopeType.Bot),
new ScopeInfo("moderator:manage:chat_messages", "Delete chat messages (tpp automod, purge invalid bets)",
ScopeType.Bot),
new ScopeInfo("moderator:manage:chat_settings", "Change chat settings, e.g. emote-only mode (mod commands)",
ScopeType.Bot),
new ScopeInfo("channel:read:subscriptions", "Reacting to incoming subscriptions", ScopeType.Channel)
];
private static readonly Dictionary<string, ScopeInfo> ScopeInfosPerScope = ScopeInfos
.ToDictionary(scopeInfo => scopeInfo.Scope, scopeInfo => scopeInfo);

private async Task ValidateApiTokens()
{
_logger.LogDebug("Validating API access token...");
ValidateAccessTokenResponse botTokenInfo = await TwitchApi.ValidateBot();
_logger.LogInformation(
"Successfully got Twitch API bot access token info! Client-ID: {ClientID}, User-ID: {UserID}, " +
"Login: {Login}, Expires in: {Expires}s, Scopes: {Scopes}", botTokenInfo.ClientId,
botTokenInfo.UserId, botTokenInfo.Login, botTokenInfo.ExpiresIn, botTokenInfo.Scopes);
ValidateAccessTokenResponse channelTokenInfo = await TwitchApi.ValidateChannel();
_logger.LogInformation(
"Successfully got Twitch API channel access token info! Client-ID: {ClientID}, User-ID: {UserID}, " +
"Login: {Login}, Expires in: {Expires}s, Scopes: {Scopes}", channelTokenInfo.ClientId,
channelTokenInfo.UserId, channelTokenInfo.Login, channelTokenInfo.ExpiresIn, channelTokenInfo.Scopes);

// Validate correct usernames
if (!botTokenInfo.Login.Equals(_botUsername, StringComparison.InvariantCultureIgnoreCase))
_logger.LogWarning("Bot token login '{Login}' does not match configured bot username '{Username}'",
botTokenInfo.Login, _botUsername);
if (!channelTokenInfo.Login.Equals(_channelName, StringComparison.InvariantCultureIgnoreCase))
_logger.LogWarning("Channel token login '{Login}' does not match configured channel '{Channel}'",
botTokenInfo.Login, _channelName);

// Validate correct Client-IDs
if (!botTokenInfo.ClientId.Equals(_appClientId, StringComparison.InvariantCultureIgnoreCase))
_logger.LogWarning(
"Bot token Client-ID '{ClientID}' does not match configured App-Client-ID '{AppClientId}'. " +
"Did you create the token using the wrong App-Client-ID?", botTokenInfo.ClientId, _appClientId);
if (!channelTokenInfo.ClientId.Equals(_appClientId, StringComparison.InvariantCultureIgnoreCase))
_logger.LogWarning(
"Channel token Client-ID '{ClientID}' does not match configured App-Client-ID '{AppClientId}'. " +
"Did you create the token using the wrong App-Client-ID?", channelTokenInfo.ClientId, _appClientId);

// Validate Scopes
foreach ((string scope, ScopeInfo scopeInfo) in ScopeInfosPerScope)
{
if (scopeInfo.ScopeType == ScopeType.Bot && !botTokenInfo.Scopes.ToHashSet().Contains(scope))
_logger.LogWarning("Missing Twitch-API scope '{Scope}' (bot), needed for: {NeededFor}",
scope, scopeInfo.NeededFor);
if (scopeInfo.ScopeType == ScopeType.Channel && !channelTokenInfo.Scopes.ToHashSet().Contains(scope))
_logger.LogWarning("Missing Twitch-API scope '{Scope}' (channel), needed for: {NeededFor}",
scope, scopeInfo.NeededFor);
}
}

public async Task Start(CancellationToken cancellationToken)
{
await ValidateApiTokens();
foreach (string problem in await TwitchApi.DetectProblems(_botUsername, _channelName))
_logger.LogWarning("TwitchAPI problem detected: {Problem}", problem);

List<Task> tasks = [];
tasks.Add(TwitchEventSubChat.Start(cancellationToken));
Expand Down
67 changes: 59 additions & 8 deletions TPP.Core/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -11,6 +12,7 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Schema.Generation;
using NodaTime;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.Discord;
Expand Down Expand Up @@ -149,7 +151,8 @@ private static ILoggerFactory BuildLoggerFactory(BaseConfig baseConfig) =>
if (baseConfig.DiscordLoggingConfig != null)
{
builder.AddSerilog(new LoggerConfiguration()
.WriteTo.Discord(baseConfig.DiscordLoggingConfig.WebhookId, baseConfig.DiscordLoggingConfig.WebhookToken)
.WriteTo.Discord(baseConfig.DiscordLoggingConfig.WebhookId,
baseConfig.DiscordLoggingConfig.WebhookToken)
.MinimumLevel.Is(baseConfig.DiscordLoggingConfig.MinLogLevel)
.Filter.ByExcluding(logEvent =>
{
Expand Down Expand Up @@ -183,8 +186,10 @@ private static void Mode(string modeName, string baseConfigFilename, string mode
CancellationTokenSource cts = new();
IWithLifecycle mode = modeName switch
{
"run" => new Runmode(loggerFactory, baseConfig, cts, () => ReadConfig<RunmodeConfig>(modeConfigFilename)),
"match" => new Matchmode(loggerFactory, baseConfig, cts, ReadConfig<MatchmodeConfig>(modeConfigFilename)),
"run" => new Runmode(loggerFactory, baseConfig, cts,
() => ReadConfig<RunmodeConfig>(modeConfigFilename)),
"match" => new Matchmode(loggerFactory, baseConfig, cts,
ReadConfig<MatchmodeConfig>(modeConfigFilename)),
"dualcore" => new DualcoreMode(loggerFactory, baseConfig, cts),
"dummy" => new DummyMode(loggerFactory, baseConfig),
_ => throw new NotSupportedException($"Invalid mode '{modeName}'")
Expand Down Expand Up @@ -227,20 +232,66 @@ private static void TestConfig(
string? mode,
string modeConfigFilename)
{
// just try to read the configs, don't do anything with them
List<string> errors = TestConfigCollectErrors(configFilename, mode, modeConfigFilename);
foreach (string error in errors)
Console.Error.WriteLine(error);
if (errors.Count > 0)
{
Environment.Exit(42); // Arbitrary exit code to indicate a semantic error, see also deploy.sh
}
}

private static List<string> TestConfigCollectErrors(
string configFilename,
string? mode,
string modeConfigFilename)
{
List<string> errors = [];

if (File.Exists(configFilename))
ReadConfig<BaseConfig>(configFilename);
try
{
var baseConfig = ReadConfig<BaseConfig>(configFilename);
ILoggerFactory loggerFactory = BuildLoggerFactory(baseConfig);
foreach (var twitchConnection in baseConfig.Chat.Connections.OfType<ConnectionConfig.Twitch>())
{
var twitchApi = new TwitchApi(loggerFactory, SystemClock.Instance,
twitchConnection.InfiniteAccessToken, twitchConnection.RefreshToken,
twitchConnection.ChannelInfiniteAccessToken, twitchConnection.ChannelRefreshToken,
twitchConnection.AppClientId, twitchConnection.AppClientSecret);
List<string> problems = twitchApi
.DetectProblems(twitchConnection.Username, twitchConnection.Channel).Result;
foreach (string problem in problems)
errors.Add($"TwitchAPI config issue for '{twitchConnection.Name}': {problem}");
}
}
catch (JsonReaderException ex)
{
Console.Error.WriteLine(ex.Message);
errors.Add($"{configFilename}: JSON parsing failed at {ex.LineNumber}:{ex.LinePosition}. " +
$"Error message suppressed to avoid leaking any secrets, but printed to stderr");
}
else
Console.Error.WriteLine(MissingConfigErrorMessage(null, configFilename));
errors.Add(MissingConfigErrorMessage(null, configFilename));

if (mode != null && ModeHasItsOwnConfig(mode))
{
if (File.Exists(modeConfigFilename))
ReadConfig(modeConfigFilename, DefaultConfigs[mode]!.GetType());
try
{
ReadConfig(modeConfigFilename, DefaultConfigs[mode]!.GetType());
}
catch (JsonReaderException ex)
{
Console.Error.WriteLine(ex.Message);
errors.Add($"{modeConfigFilename}: JSON parsing failed at {ex.LineNumber}:{ex.LinePosition}. " +
$"Error message suppressed to avoid leaking any secrets, but printed to stderr");
}
else
Console.Error.WriteLine(MissingConfigErrorMessage(mode, modeConfigFilename));
errors.Add(MissingConfigErrorMessage(mode, modeConfigFilename));
}

return errors;
}

private static void OutputDefaultConfig(string? modeName, ValueObject? outfileArgument)
Expand Down
Loading

0 comments on commit dba4a08

Please sign in to comment.