From 12878f177fcada2897c6e5740ef7beb44b01987e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 13 Aug 2024 21:56:03 -0400 Subject: [PATCH] Adds version reporting telemetry --- .github/workflows/ci-pipeline.yml | 40 +++ README.md | 6 + .../Configuration/TelemetryConfiguration.cs | 33 +++ src/Tgstation.Server.Host/Core/Application.cs | 2 + .../Core/VersionReportingService.cs | 234 ++++++++++++++++++ .../TelemetryAppSerializedKeyAttribute.cs | 33 +++ .../Setup/SetupWizard.cs | 32 +++ .../Tgstation.Server.Host.csproj | 23 +- src/Tgstation.Server.Host/appsettings.yml | 4 + .../Setup/TestSetupWizard.cs | 9 +- .../Live/LiveTestingServer.cs | 1 + 11 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 src/Tgstation.Server.Host/Configuration/TelemetryConfiguration.cs create mode 100644 src/Tgstation.Server.Host/Core/VersionReportingService.cs create mode 100644 src/Tgstation.Server.Host/Properties/TelemetryAppSerializedKeyAttribute.cs diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 54007f37f3..375fb7dc40 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -555,6 +555,8 @@ jobs: database-type: [ 'SqlServer', 'Sqlite', 'PostgresSql', 'MariaDB', 'MySql' ] watchdog-type: [ 'Basic', 'Advanced' ] configuration: [ 'Debug', 'Release' ] + env: + TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt runs-on: windows-latest steps: - name: Wait for LocalDB Connection # Do this first because we don't want to find out it's failing later @@ -642,9 +644,18 @@ jobs: - name: Restore run: dotnet restore + - name: Setup Telemetry Key File + shell: bash + run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Build run: dotnet build -c ${{ matrix.configuration }} tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj + - name: Delete Telemetry Key File + shell: bash + if: always() + run: rm ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Cache BYOND .zips uses: actions/cache@v4 id: cache-byond @@ -786,6 +797,8 @@ jobs: database-type: [ 'Sqlite', 'PostgresSql', 'MariaDB', 'MySql' ] watchdog-type: [ 'Basic', 'Advanced' ] configuration: [ 'Debug', 'Release' ] + env: + TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt runs-on: ubuntu-latest steps: - name: Disable ptrace_scope @@ -848,9 +861,16 @@ jobs: - name: Restore run: dotnet restore + - name: Setup Telemetry Key File + run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Build run: dotnet build -c ${{ matrix.configuration }}NoWindows tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj + - name: Delete Telemetry Key File + if: always() + run: rm ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Cache BYOND .zips uses: actions/cache@v4 id: cache-byond @@ -1194,6 +1214,8 @@ jobs: needs: start-ci-run-gate runs-on: ubuntu-latest if: (!(cancelled() || failure()) && needs.start-ci-run-gate.result == 'success') + env: + TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt steps: - name: Install Native Dependencies run: | @@ -1244,6 +1266,9 @@ jobs: - name: Grab Most Recent Changelog run: curl -L https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml -o changelog.yml + - name: Setup Telemetry Key File + run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Execute Build Script (Unsigned) if: (!(github.event_name == 'push' && contains(github.event.head_commit.message, '[TGSDeploy]') && (github.event.ref == 'refs/heads/master' || github.event.ref == 'refs/heads/dev'))) run: sudo -E build/package/deb/build_package.sh @@ -1258,6 +1283,10 @@ jobs: gpg --verify tgstation-server_${{ env.TGS_VERSION }}-1_amd64.changes gpg --verify tgstation-server_${{ env.TGS_VERSION }}-1_amd64.buildinfo + - name: Delete Telemetry Key File + if: always() + run: rm ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Test Install run: | sudo mkdir /etc/tgstation-server @@ -1298,6 +1327,8 @@ jobs: needs: start-ci-run-gate runs-on: windows-latest if: (!(cancelled() || failure()) && needs.start-ci-run-gate.result == 'success') + env: + TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt steps: - name: Install winget uses: Cyberboss/install-winget@v1 @@ -1331,9 +1362,18 @@ jobs: - name: Restore run: dotnet restore + - name: Setup Telemetry Key File + shell: bash + run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Build Host run: dotnet build -c Release src/Tgstation.Server.Host/Tgstation.Server.Host.csproj + - name: Delete Telemetry Key File + shell: bash + if: always() + run: rm ${{ env.TGS_TELEMETRY_KEY_FILE }} + - name: Build Service run: dotnet build -c Release src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj diff --git a/README.md b/README.md index 794543dc85..c130e2d76b 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,12 @@ The following providers use the `ServerUrl` setting: - Keycloak - InvisionCommunity +- `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list + +- `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository. + +- `Telemetry:VersionReportingRepositoryId`: The repository telemetry is sent to. For security reasons, this is not the main TGS repo. See the [tgstation-server-deployments](https://github.com/tgstation/tgstation-server-deployments) repository for more information. + ### Database Configuration If using a MariaDB/MySQL server, our client library [recommends you set 'utf8mb4' as your default charset](https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql#1-recommended-server-charset) disregard at your own risk. diff --git a/src/Tgstation.Server.Host/Configuration/TelemetryConfiguration.cs b/src/Tgstation.Server.Host/Configuration/TelemetryConfiguration.cs new file mode 100644 index 0000000000..7ec0361759 --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/TelemetryConfiguration.cs @@ -0,0 +1,33 @@ +namespace Tgstation.Server.Host.Configuration +{ + /// + /// Configuration options for telemetry. + /// + public sealed class TelemetryConfiguration + { + /// + /// The key for the the resides in. + /// + public const string Section = "Telemetry"; + + /// + /// The default value of . + /// + private const long DefaultVersionReportingRepositoryId = 841149827; // https://github.com/tgstation/tgstation-server-deployments + + /// + /// If version reporting telemetry is disabled. + /// + public bool DisableVersionReporting { get; set; } + + /// + /// The friendly name used on GitHub deployments for version reporting. If only the server will be shown. + /// + public string? ServerFriendlyName { get; set; } + + /// + /// The GitHub repository ID used for version reporting. + /// + public long? VersionReportingRepositoryId { get; set; } = DefaultVersionReportingRepositoryId; + } +} diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 36970000c5..7e981066c0 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -143,6 +143,7 @@ namespace Tgstation.Server.Host.Core services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); + services.UseStandardConfig(Configuration); // enable options which give us config reloading services.AddOptions(); @@ -423,6 +424,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); + services.AddHostedService(); services.AddFileDownloader(); services.AddGitHub(); diff --git a/src/Tgstation.Server.Host/Core/VersionReportingService.cs b/src/Tgstation.Server.Host/Core/VersionReportingService.cs new file mode 100644 index 0000000000..7e03e12951 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/VersionReportingService.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Octokit; + +using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.IO; +using Tgstation.Server.Host.Properties; +using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Utils; +using Tgstation.Server.Host.Utils.GitHub; + +namespace Tgstation.Server.Host.Core +{ + /// + /// Handles TGS version reporting, if enabled. + /// + sealed class VersionReportingService : BackgroundService + { + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// The for the . + /// + readonly IIOManager ioManager; + + /// + /// The for the . + /// + readonly IAsyncDelayer asyncDelayer; + + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly TelemetryConfiguration telemetryConfiguration; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The containing the value of . + /// The value of . + public VersionReportingService( + IGitHubClientFactory gitHubClientFactory, + IIOManager ioManager, + IAsyncDelayer asyncDelayer, + IAssemblyInformationProvider assemblyInformationProvider, + IOptions telemetryConfigurationOptions, + ILogger logger) + { + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + telemetryConfiguration = telemetryConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(telemetryConfigurationOptions)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (telemetryConfiguration.DisableVersionReporting) + { + logger.LogDebug("Version telemetry disabled"); + return; + } + + if (!telemetryConfiguration.VersionReportingRepositoryId.HasValue) + { + logger.LogError("Version reporting repository is misconfigured. Telemetry cannot be sent!"); + return; + } + + var attribute = TelemetryAppSerializedKeyAttribute.Instance; + if (attribute == null) + { + logger.LogDebug("TGS build configuration does not allow for version telemetry"); + return; + } + + logger.LogDebug("Starting..."); + + try + { + var telemetryIdFile = ioManager.ResolvePath( + ioManager.ConcatPath( + ioManager.GetPathInLocalDirectory(assemblyInformationProvider), + "telemetry.id")); + + Guid telemetryId; + if (!await ioManager.FileExists(telemetryIdFile, stoppingToken)) + { + telemetryId = Guid.NewGuid(); + await ioManager.WriteAllBytes(telemetryIdFile, Encoding.UTF8.GetBytes(telemetryId.ToString()), stoppingToken); + logger.LogInformation("Generated telemetry ID {telemetryId} and wrote to {file}", telemetryId, telemetryIdFile); + } + else + { + var contents = await ioManager.ReadAllBytes(telemetryIdFile, stoppingToken); + + string guidStr; + try + { + guidStr = Encoding.UTF8.GetString(contents); + } + catch (Exception ex) + { + logger.LogError(ex, "Cannot decode telemetry ID from installation file ({path}). Telemetry will not be sent!", telemetryIdFile); + return; + } + + if (!Guid.TryParse(guidStr, out telemetryId)) + { + logger.LogError("Cannot parse telemetry ID from installation file ({path}). Telemetry will not be sent!", telemetryIdFile); + return; + } + } + + var nextDelayHours = await TryReportVersion( + telemetryId, + attribute.SerializedKey, + telemetryConfiguration.VersionReportingRepositoryId.Value, + stoppingToken) + ? 24 + : 1; + + logger.LogDebug("Next version report in {hours} hours", nextDelayHours); + await asyncDelayer.Delay(TimeSpan.FromHours(nextDelayHours), stoppingToken); + } + catch (OperationCanceledException ex) + { + logger.LogTrace(ex, "Exiting..."); + } + catch (Exception ex) + { + logger.LogError(ex, "Crashed!"); + } + } + + /// + /// Make an attempt to report the current to the configured GitHub repository. + /// + /// The telemetry for the installation. + /// The serialized authentication for the . + /// The ID of the repository to send telemetry to. + /// The for the operation. + /// A resulting in if telemetry was reported successfully, otherwise. + async ValueTask TryReportVersion(Guid telemetryId, string serializedPem, long repositoryId, CancellationToken cancellationToken) + { + logger.LogDebug("Sending version telemetry..."); + + var serverFriendlyName = telemetryConfiguration.ServerFriendlyName; + if (String.IsNullOrWhiteSpace(serverFriendlyName)) + serverFriendlyName = null; + + logger.LogTrace( + "Repository ID: {repoId}, Server friendly name: {friendlyName}", + repositoryId, + serverFriendlyName == null + ? "(null)" + : $"\"{serverFriendlyName}\""); + try + { + var gitHubClient = await gitHubClientFactory.CreateInstallationClient( + serializedPem, + repositoryId, + cancellationToken); + + if (gitHubClient == null) + { + logger.LogWarning("Could not create GitHub client to connect to repository ID {repoId}!", repositoryId); + return false; + } + + // remove this lookup once https://github.com/octokit/octokit.net/pull/2960 is merged and released + var repository = await gitHubClient.Repository.Get(repositoryId); + + logger.LogTrace("Repository ID {id} resolved to {owner}/{name}", repositoryId, repository.Owner.Name, repository.Name); + + var inputs = new Dictionary + { + { "telemetry_id", telemetryId.ToString() }, + { "tgs_semver", assemblyInformationProvider.Version.Semver().ToString() }, + }; + + if (serverFriendlyName != null) + inputs.Add("server_friendly_name", serverFriendlyName); + + await gitHubClient.Actions.Workflows.CreateDispatch( + repository.Owner.Login, + repository.Name, + ".github/workflows/tgs_deployments_telemetry.yml", + new CreateWorkflowDispatch("main") + { + Inputs = inputs, + }); + + logger.LogTrace("Telemetry sent successfully"); + + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to report version!"); + return false; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Properties/TelemetryAppSerializedKeyAttribute.cs b/src/Tgstation.Server.Host/Properties/TelemetryAppSerializedKeyAttribute.cs new file mode 100644 index 0000000000..6d2ff1be5b --- /dev/null +++ b/src/Tgstation.Server.Host/Properties/TelemetryAppSerializedKeyAttribute.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; + +namespace Tgstation.Server.Host.Properties +{ + /// + /// Attribute for bundling the GitHub App serialized private key used for version telemetry. + /// + [AttributeUsage(AttributeTargets.Assembly)] + sealed class TelemetryAppSerializedKeyAttribute : Attribute + { + /// + /// Return the 's instance of the . + /// + public static TelemetryAppSerializedKeyAttribute? Instance => Assembly + .GetExecutingAssembly() + .GetCustomAttribute(); + + /// + /// The serialized GitHub App Client ID and private key. + /// + public string SerializedKey { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public TelemetryAppSerializedKeyAttribute(string serializedKey) + { + SerializedKey = serializedKey ?? throw new ArgumentNullException(nameof(serializedKey)); + } + } +} diff --git a/src/Tgstation.Server.Host/Setup/SetupWizard.cs b/src/Tgstation.Server.Host/Setup/SetupWizard.cs index 04cc54a3bf..387f2afe7a 100644 --- a/src/Tgstation.Server.Host/Setup/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Setup/SetupWizard.cs @@ -950,6 +950,31 @@ namespace Tgstation.Server.Host.Setup }; } + /// + /// Prompts the user to create a . + /// + /// The for the operation. + /// A resulting in the new . + async ValueTask ConfigureTelemetry(CancellationToken cancellationToken) + { + bool enableReporting = await PromptYesNo("Enable version telemetry? This anonymously reports the TGS version in use.", true, cancellationToken); + + string? serverFriendlyName = null; + if (enableReporting) + { + await console.WriteAsync("(Optional) Publically associate your reported version with a friendly name:", false, cancellationToken); + serverFriendlyName = await console.ReadLineAsync(false, cancellationToken); + if (String.IsNullOrWhiteSpace(serverFriendlyName)) + serverFriendlyName = null; + } + + return new TelemetryConfiguration + { + DisableVersionReporting = !enableReporting, + ServerFriendlyName = serverFriendlyName, + }; + } + /// /// Saves a given set to . /// @@ -961,6 +986,7 @@ namespace Tgstation.Server.Host.Setup /// The to save. /// The to save. /// The to save. + /// The to save. /// The for the operation. /// A representing the running operation. async ValueTask SaveConfiguration( @@ -972,6 +998,7 @@ namespace Tgstation.Server.Host.Setup ElasticsearchConfiguration? elasticsearchConfiguration, ControlPanelConfiguration controlPanelConfiguration, SwarmConfiguration? swarmConfiguration, + TelemetryConfiguration? telemetryConfiguration, CancellationToken cancellationToken) { newGeneralConfiguration.ApiPort = hostingPort ?? GeneralConfiguration.DefaultApiPort; @@ -984,6 +1011,7 @@ namespace Tgstation.Server.Host.Setup { ElasticsearchConfiguration.Section, elasticsearchConfiguration }, { ControlPanelConfiguration.Section, controlPanelConfiguration }, { SwarmConfiguration.Section, swarmConfiguration }, + { TelemetryConfiguration.Section, telemetryConfiguration }, }; var versionConverter = new VersionConverter(); @@ -1055,6 +1083,8 @@ namespace Tgstation.Server.Host.Setup var swarmConfiguration = await ConfigureSwarm(cancellationToken); + var telemetryConfiguration = await ConfigureTelemetry(cancellationToken); + await console.WriteAsync(null, true, cancellationToken); await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken); @@ -1067,6 +1097,7 @@ namespace Tgstation.Server.Host.Setup elasticSearchConfiguration, controlPanelConfiguration, swarmConfiguration, + telemetryConfiguration, cancellationToken); } @@ -1184,6 +1215,7 @@ namespace Tgstation.Server.Host.Setup AllowAnyOrigin = true, }, null, + null, cancellationToken); } else diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 94f3fcccb7..cdee07bb4a 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -45,16 +45,33 @@ - + <_Parameter1>$(TgsConfigVersion) <_Parameter2>$(TgsInteropVersion) <_Parameter3>$(TgsWebpanelVersion) <_Parameter4>$(TgsHostWatchdogVersion) <_Parameter5>$(TgsMariaDBRedistVersion) - + + + + + + - + + + + + + + <_Parameter1>@(SerializedTelemetryKey) + + + diff --git a/src/Tgstation.Server.Host/appsettings.yml b/src/Tgstation.Server.Host/appsettings.yml index 526f38ab38..68ab2bf729 100644 --- a/src/Tgstation.Server.Host/appsettings.yml +++ b/src/Tgstation.Server.Host/appsettings.yml @@ -78,3 +78,7 @@ Swarm: # Should be left empty if using swarm mode is not desired # PublicAddress: # The public address of the swarm node # ControllerAddress: # Required on non-controller nodes. The internal address of the swarm controller's API'. Should be left empty on the controller itself # UpdateRequiredNodeCount: # The number of nodes expected to be in the swarm before initiating an update. This should count every server irrespective of whether or not they are the controller MINUS 1 +Telemetry: + DisableVersionReporting: false # Prevents you installation and the version you're using from being reported on the source repository's deployments list + ServerFriendlyName: null # Sets a friendly name for your server in reported telemetry. Must be unique. First come first serve + VersionReportingRepositoryId: 841149827 # GitHub repostiory ID where the tgs_version_telemetry workflow can be found diff --git a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs index 823036a10b..cd6e5c86a0 100644 --- a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs @@ -191,6 +191,8 @@ namespace Tgstation.Server.Host.Setup.Tests "y", // swarm config "n", + // telemetry config + "n", //saved, now for second run //this time use defaults amap String.Empty, @@ -230,6 +232,9 @@ namespace Tgstation.Server.Host.Setup.Tests "privatekey", "n", "http://controller.com", + // telemetry config + "y", + "telemetry name", //third run, we already hit all the code coverage so just get through it String.Empty, nameof(DatabaseType.MariaDB), @@ -267,7 +272,9 @@ namespace Tgstation.Server.Host.Setup.Tests "https://controllerinternal.com", "https://controllerpublic.com", "privatekey", - "y" + "y", + // telemetry config + "n", }; var inputPos = 0; diff --git a/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs b/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs index 645edac147..f9ed0b926a 100644 --- a/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs +++ b/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs @@ -155,6 +155,7 @@ namespace Tgstation.Server.Tests.Live $"General:OpenDreamGitUrl={OpenDreamUrl}", $"Security:TokenExpiryMinutes=120", // timeouts are useless for us $"General:OpenDreamSuppressInstallOutput={TestingUtils.RunningInGitHubActions}", + "Telemetry:DisableVersionReporting=true", }; swarmArgs = new List();