// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security.Claims; using System.Text.Json.Serialization; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Horde.Acls; using HordeServer.Accounts; using HordeServer.Acls; using HordeServer.Configuration; using HordeServer.Dashboard; using HordeServer.Plugins; using HordeServer.Server.Notices; using HordeServer.ServiceAccounts; using HordeServer.Utilities; namespace HordeServer.Server { using JsonObject = System.Text.Json.Nodes.JsonObject; /// /// Global configuration /// [JsonSchema("https://unrealengine.com/horde/global")] [JsonSchemaCatalog("Horde Globals", "Horde global configuration file", new[] { "globals.json", "*.global.json" })] [ConfigDoc("Globals.json", "[Horde](../../../README.md) > [Configuration](../../Config.md)", "Config/Schema/Globals.md")] [ConfigIncludeRoot] [ConfigMacroScope] public class GlobalConfig { /// /// Global server settings object /// [JsonIgnore] public ServerSettings ServerSettings { get; private set; } = null!; /// /// Unique identifier for this config revision. Useful to detect changes. /// [JsonIgnore] public string Revision { get; set; } = String.Empty; /// /// Version number for the server. Values are indicated by the . /// public int Version { get; set; } /// /// Version number for the server, as an enum. /// [JsonIgnore] public ConfigVersion VersionEnum => (ConfigVersion)Version; /// /// Other paths to include /// public List Include { get; set; } = new List(); /// /// Macros within the global scope /// public List Macros { get; set; } = new List(); /// /// Settings for the dashboard /// public DashboardConfig Dashboard { get; set; } = new DashboardConfig(); /// /// List of scheduled downtime /// public List Downtime { get; set; } = new List(); /// /// Plugin config objects /// public PluginConfigCollection Plugins { get; set; } = new PluginConfigCollection(); /// /// General parameters for other tools. Can be queried through the api/v1/parameters endpoint. /// public JsonObject Parameters { get; set; } = new JsonObject(); /// /// Access control list /// public AclConfig Acl { get; set; } = new AclConfig(); /// /// Accessor for the ACL scope lookup /// [JsonIgnore] public IReadOnlyDictionary AclScopes => _aclLookup; private readonly Dictionary _aclLookup = new Dictionary(); /// public bool Authorize(AclAction action, ClaimsPrincipal user) => Acl.Authorize(action, user); /// /// Called after the config file has been read /// public async Task PostLoadAsync(ServerSettings serverSettings, IReadOnlyList loadedPlugins, IEnumerable defaultAclModifiers) { ServerSettings = serverSettings; AclAction[] aclActions = AclConfig.GetActions( [ typeof(AccountAclAction), typeof(ServiceAccountAclAction), typeof(NoticeAclAction), typeof(ServerAclAction), typeof(AdminAclAction), ]); AclConfig defaultAcl = CreateRootAcl(defaultAclModifiers); Acl.PostLoad(defaultAcl, defaultAcl.ScopeName, aclActions); // Ensure that all plugins have an entry in the global config so they can register their ACLs foreach (ILoadedPlugin loadedPlugin in loadedPlugins) { if (!Plugins.TryGetValue(loadedPlugin.Name, out _)) { IPluginConfig pluginConfig = (IPluginConfig)Activator.CreateInstance(loadedPlugin.GlobalConfigType)!; Plugins.Add(loadedPlugin.Name, pluginConfig); } } IReadOnlyCollection pluginConfigs; if (loadedPlugins.Count == 0) { pluginConfigs = Plugins.Values; } else { List sortedPluginConfigs = []; HashSet seen = []; foreach (ILoadedPlugin loadedPlugin in PluginCollection.GetTopologicalSort(loadedPlugins)) { sortedPluginConfigs.Add(Plugins[loadedPlugin.Name]); seen.Add(loadedPlugin.Name); } // Add configs that do not have a plugin foreach (KeyValuePair kvp in Plugins) { if (!seen.Contains(kvp.Key)) { sortedPluginConfigs.Add(kvp.Value); } } pluginConfigs = sortedPluginConfigs; } PluginConfigOptions pluginConfigOptions = new PluginConfigOptions(VersionEnum, Plugins.Values, Acl); foreach (IPluginConfig pluginConfig in pluginConfigs) { await pluginConfig.PostLoadAsync(pluginConfigOptions); } foreach (ScheduledDowntime downtime in Downtime) { downtime.PostLoad(); } _aclLookup.Clear(); BuildAclScopeLookup(Acl, _aclLookup); } /// /// Creates the default root ACL /// static AclConfig CreateRootAcl(IEnumerable defaultAclModifiers) { DefaultAclBuilder defaultAclBuilder = new DefaultAclBuilder(); defaultAclBuilder.AddDefaultReadAction(ServerAclAction.IssueBearerToken); foreach (IDefaultAclModifier defaultAclModifier in defaultAclModifiers) { defaultAclModifier.Apply(defaultAclBuilder); } AclConfig defaultAcl = defaultAclBuilder.Build(); defaultAcl.PostLoad(null, AclScopeName.Root, []); return defaultAcl; } static void BuildAclScopeLookup(AclConfig acl, Dictionary aclLookup) { aclLookup.Add(acl.ScopeName, acl); if (acl.LegacyScopeNames != null) { foreach (AclScopeName legacyScopeName in acl.LegacyScopeNames) { aclLookup.Add(legacyScopeName, acl); } } if (acl.Children != null) { foreach (AclConfig childAcl in acl.Children) { BuildAclScopeLookup(childAcl, aclLookup); } } } /// /// Authorizes a user to perform a given action /// /// Name of the scope to auth against /// Configuration for the scope public bool TryGetAclScope(AclScopeName scopeName, [NotNullWhen(true)] out AclConfig? scopeConfig) => _aclLookup.TryGetValue(scopeName, out scopeConfig); /// /// Authorizes a user to perform a given action /// /// Name of the scope to auth against /// The action being performed /// The principal to validate public bool Authorize(AclScopeName scopeName, AclAction action, ClaimsPrincipal user) => _aclLookup.TryGetValue(scopeName, out AclConfig? scopeConfig) && scopeConfig.Authorize(action, user); IReadOnlyList? _cachedGroupClaims; /// /// Gets all the valid claims referenced by ACL entries within the config object. /// public IReadOnlyList GetValidAccountGroupClaims() { if (_cachedGroupClaims == null) { HashSet groups = new HashSet(StringComparer.OrdinalIgnoreCase); FindGroupClaimsFromObject(Acl, groups); _cachedGroupClaims = groups.ToArray(); } return _cachedGroupClaims; } static void FindGroupClaimsFromObject(AclConfig config, HashSet groups) { if (config.Entries != null) { foreach (AclEntryConfig entry in config.Entries) { AclClaimConfig claim = entry.Claim; if (claim.Type.Equals(HordeClaimTypes.Group, StringComparison.OrdinalIgnoreCase)) { groups.Add(claim.Value); } } } if (config.Children != null) { foreach (AclConfig childConfig in config.Children) { FindGroupClaimsFromObject(childConfig, groups); } } } } /// /// How frequently the maintenance window repeats /// public enum ScheduledDowntimeFrequency { /// /// Once /// Once, /// /// Every day /// Daily, /// /// Every week /// Weekly, } /// /// Settings for the maintenance window /// public class ScheduledDowntime { /// /// Start time /// public DateTimeOffset StartTime { get; set; } /// /// Finish time /// public DateTimeOffset FinishTime { get; set; } /// /// Duration of the maintenance window. An alternative to FinishTime. /// public TimeSpan? Duration { get; set; } /// /// The name of time zone to set the Coordinated Universal Time (UTC) offset for StartTime and FinishTime. /// public string? TimeZone { get; set; } /// /// Frequency that the window repeats /// public ScheduledDowntimeFrequency Frequency { get; set; } = ScheduledDowntimeFrequency.Once; internal void PostLoad() { if (TimeZone != null) { TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(TimeZone); TimeSpan offset = timeZoneInfo.GetUtcOffset(StartTime); StartTime = new DateTimeOffset(StartTime.Year, StartTime.Month, StartTime.Day, StartTime.Hour, StartTime.Minute, StartTime.Second, offset); FinishTime = new DateTimeOffset(FinishTime.Year, FinishTime.Month, FinishTime.Day, FinishTime.Hour, FinishTime.Minute, FinishTime.Second, offset); } if (Duration != null) { FinishTime = StartTime + Duration.Value; } else { Duration = FinishTime - StartTime; } } /// /// Gets the next scheduled downtime /// /// The current time /// Start and finish time public (DateTimeOffset StartTime, DateTimeOffset FinishTime) GetNext(DateTimeOffset now) { TimeSpan offset = TimeSpan.Zero; if (Frequency == ScheduledDowntimeFrequency.Daily) { double days = (now - StartTime).TotalDays; if (days >= 1.0) { days -= days % 1.0; } offset = TimeSpan.FromDays(days); } else if (Frequency == ScheduledDowntimeFrequency.Weekly) { double days = (now - StartTime).TotalDays; if (days >= 7.0) { days -= days % 7.0; } offset = TimeSpan.FromDays(days); } return (StartTime + offset, FinishTime + offset); } /// /// Determines if this schedule is active /// /// The current time /// True if downtime is active public bool IsActive(DateTimeOffset now) { if (Frequency == ScheduledDowntimeFrequency.Once) { return now >= StartTime && now < FinishTime; } else if (Frequency == ScheduledDowntimeFrequency.Daily) { double days = (now - StartTime).TotalDays; if (days < 0.0) { return false; } else { return (days % 1.0) < (FinishTime - StartTime).TotalDays; } } else if (Frequency == ScheduledDowntimeFrequency.Weekly) { double days = (now - StartTime).TotalDays; if (days < 0.0) { return false; } else { return (days % 7.0) < (FinishTime - StartTime).TotalDays; } } else { return false; } } } }