diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index 07ab87b..a6ab3c2 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit 07ab87bc65fa52879cadb8a18de60a7c51ac6d78 +Subproject commit a6ab3c254bee92183ac6b3af9405658b0a02e1d2 diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikSamlConfig.cs b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikSamlConfig.cs new file mode 100644 index 0000000..aa00084 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikSamlConfig.cs @@ -0,0 +1,32 @@ +namespace OTSSignsOrchestrator.Core.Models.DTOs; + +/// +/// Holds the IdP metadata extracted from an Authentik SAML provider, +/// used to render the settings-custom.php template. +/// +public class AuthentikSamlConfig +{ + /// IdP entity ID from SAML metadata (typically "authentik"). + public string IdpEntityId { get; set; } = string.Empty; + + /// Base64-encoded X.509 signing certificate (no BEGIN/END markers). + public string IdpX509Cert { get; set; } = string.Empty; + + /// IdP Single Sign-On URL (HTTP-Redirect binding). + public string SsoUrlRedirect { get; set; } = string.Empty; + + /// IdP Single Sign-On URL (HTTP-POST binding). + public string SsoUrlPost { get; set; } = string.Empty; + + /// IdP Single Logout URL (HTTP-Redirect binding). + public string SloUrlRedirect { get; set; } = string.Empty; + + /// IdP Single Logout URL (HTTP-POST binding). + public string SloUrlPost { get; set; } = string.Empty; + + /// Authentik provider primary key (for audit/debugging). + public int ProviderId { get; set; } + + /// Slug used in Authentik application URLs. + public string ApplicationSlug { get; set; } = string.Empty; +} diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs b/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs index 7075819..7ff296e 100644 --- a/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs +++ b/OTSSignsOrchestrator.Core/Models/DTOs/TemplateConfig.cs @@ -4,4 +4,10 @@ public class TemplateConfig { public string Yaml { get; set; } = string.Empty; public DateTime FetchedAt { get; set; } = DateTime.UtcNow; + + /// + /// Local filesystem path to the cached git clone. + /// Used to access additional template files (e.g. settings-custom.php.template). + /// + public string CacheDir { get; set; } = string.Empty; } diff --git a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs new file mode 100644 index 0000000..c149374 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs @@ -0,0 +1,402 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using OTSSignsOrchestrator.Core.Models.DTOs; + +namespace OTSSignsOrchestrator.Core.Services; + +/// +/// Provisions SAML applications in Authentik via its REST API and retrieves +/// IdP metadata (entity ID, signing certificate, SSO/SLO URLs). +/// +/// Workflow per instance: +/// 1. Resolve the authorization + invalidation flow UUIDs (cached or from settings). +/// 2. Create a SAML provider (POST /api/v3/providers/saml/). +/// 3. Create an Authentik application linked to that provider (POST /api/v3/core/applications/). +/// 4. Fetch SAML metadata XML (GET /api/v3/providers/saml/{id}/metadata/). +/// 5. Parse XML to extract entityId, x509cert, SSO/SLO URLs. +/// +public class AuthentikService : IAuthentikService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly SettingsService _settings; + private readonly ILogger _logger; + + // Cache flow UUIDs so we only look them up once per app lifetime. + private string? _cachedAuthorizationFlowUuid; + private string? _cachedInvalidationFlowUuid; + + // XML namespaces used in SAML metadata + private static readonly XNamespace Md = "urn:oasis:names:tc:SAML:2.0:metadata"; + private static readonly XNamespace Ds = "http://www.w3.org/2000/09/xmldsig#"; + + public AuthentikService( + IHttpClientFactory httpFactory, + SettingsService settings, + ILogger logger) + { + _httpFactory = httpFactory; + _settings = settings; + _logger = logger; + } + + // ───────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────── + + public async Task ProvisionSamlAsync( + string instanceAbbrev, + string instanceBaseUrl, + CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Provisioning SAML for instance {Abbrev}", instanceAbbrev); + + var (baseUrl, client) = await CreateAuthenticatedClientAsync(); + var slug = $"ds-{instanceAbbrev}"; + var samlBaseUrl = instanceBaseUrl.TrimEnd('/') + "/saml"; + + // ── 1. Check if application already exists ──────────────────────── + var existingProviderId = await TryGetExistingProviderIdAsync(client, baseUrl, slug, ct); + int providerId; + + if (existingProviderId.HasValue) + { + _logger.LogInformation("[Authentik] Application '{Slug}' already exists (provider={Id}), reusing", slug, existingProviderId.Value); + providerId = existingProviderId.Value; + } + else + { + // ── 2. Resolve flow UUIDs ───────────────────────────────────── + var authFlowUuid = await ResolveFlowUuidAsync(client, baseUrl, + SettingsService.AuthentikAuthorizationFlowSlug, + "default-provider-authorization-implicit-consent", ct); + + var invalidFlowUuid = await ResolveFlowUuidAsync(client, baseUrl, + SettingsService.AuthentikInvalidationFlowSlug, + "default-provider-invalidation-flow", ct); + + // ── 3. Create SAML provider ─────────────────────────────────── + providerId = await CreateSamlProviderAsync(client, baseUrl, + instanceAbbrev, samlBaseUrl, authFlowUuid, invalidFlowUuid, ct); + + // ── 4. Create application linked to provider ────────────────── + await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct); + } + + // ── 5. Fetch and parse metadata ─────────────────────────────────── + var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct); + config.ApplicationSlug = slug; + + _logger.LogInformation( + "[Authentik] SAML provisioned for {Abbrev}: provider={ProviderId}, entityId={EntityId}", + instanceAbbrev, config.ProviderId, config.IdpEntityId); + + return config; + } + + // ───────────────────────────────────────────────────────────────────────── + // HTTP client setup + // ───────────────────────────────────────────────────────────────────────── + + private async Task<(string BaseUrl, HttpClient Client)> CreateAuthenticatedClientAsync() + { + var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl); + var apiKey = await _settings.GetAsync(SettingsService.AuthentikApiKey); + + if (string.IsNullOrWhiteSpace(authentikUrl)) + throw new InvalidOperationException("Authentik URL is not configured. Set it in Settings → Authentik."); + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException("Authentik API Key is not configured. Set it in Settings → Authentik."); + + var baseUrl = authentikUrl.TrimEnd('/'); + var client = _httpFactory.CreateClient("AuthentikApi"); + client.BaseAddress = new Uri(baseUrl); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + return (baseUrl, client); + } + + // ───────────────────────────────────────────────────────────────────────── + // Check for existing application + // ───────────────────────────────────────────────────────────────────────── + + private async Task TryGetExistingProviderIdAsync( + HttpClient client, string baseUrl, string slug, CancellationToken ct) + { + try + { + var resp = await client.GetAsync($"/api/v3/core/applications/?slug={slug}", ct); + if (!resp.IsSuccessStatusCode) return null; + + var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); + if (json?.Results is { Count: > 0 }) + { + var app = json.Results[0]; + return app.Provider; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Could not check for existing application '{Slug}'", slug); + } + + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Flow resolution + // ───────────────────────────────────────────────────────────────────────── + + private async Task ResolveFlowUuidAsync( + HttpClient client, string baseUrl, + string settingsKey, string defaultFlowSlug, + CancellationToken ct) + { + // Check if user has configured a specific flow slug in settings + var configuredSlug = await _settings.GetAsync(settingsKey); + var slug = string.IsNullOrWhiteSpace(configuredSlug) ? defaultFlowSlug : configuredSlug; + + // Return cached value if available + if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug && _cachedAuthorizationFlowUuid != null) + return _cachedAuthorizationFlowUuid; + if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug && _cachedInvalidationFlowUuid != null) + return _cachedInvalidationFlowUuid; + + _logger.LogDebug("[Authentik] Resolving flow UUID for slug '{Slug}'", slug); + + var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={slug}", ct); + resp.EnsureSuccessStatusCode(); + + var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); + if (json?.Results is not { Count: > 0 }) + throw new InvalidOperationException( + $"Authentik flow '{slug}' not found. Ensure the flow exists or configure the correct slug in Settings → Authentik."); + + var uuid = json.Results[0].Pk; + + // Cache for subsequent calls + if (settingsKey == SettingsService.AuthentikAuthorizationFlowSlug) + _cachedAuthorizationFlowUuid = uuid; + else if (settingsKey == SettingsService.AuthentikInvalidationFlowSlug) + _cachedInvalidationFlowUuid = uuid; + + return uuid; + } + + // ───────────────────────────────────────────────────────────────────────── + // SAML Provider creation + // ───────────────────────────────────────────────────────────────────────── + + private async Task CreateSamlProviderAsync( + HttpClient client, string baseUrl, + string abbrev, string samlBaseUrl, + string authFlowUuid, string invalidFlowUuid, + CancellationToken ct) + { + _logger.LogInformation("[Authentik] Creating SAML provider for {Abbrev}", abbrev); + + var payload = new Dictionary + { + ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()} (SAML)", + ["authorization_flow"] = authFlowUuid, + ["invalidation_flow"] = invalidFlowUuid, + ["acs_url"] = $"{samlBaseUrl}/acs", + ["sp_binding"] = "post", + ["issuer"] = "authentik", + ["audience"] = $"{samlBaseUrl}/metadata", + ["default_relay_state"] = "", + ["name_id_mapping"] = (object)null!, // use default + }; + + // Optionally add SLO URL + payload["sls_url"] = $"{samlBaseUrl}/sls"; + payload["sls_binding"] = "redirect"; + + // Optionally attach signing keypair + var signingKpId = await _settings.GetAsync(SettingsService.AuthentikSigningKeypairId); + if (!string.IsNullOrWhiteSpace(signingKpId)) + payload["signing_kp"] = signingKpId; + + var resp = await client.PostAsJsonAsync("/api/v3/providers/saml/", payload, ct); + + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create Authentik SAML provider (HTTP {(int)resp.StatusCode}): {errorBody}"); + } + + var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); + if (result == null) + throw new InvalidOperationException("Authentik returned null when creating SAML provider."); + + _logger.LogInformation("[Authentik] SAML provider created: id={ProviderId}", result.Pk); + return result.Pk; + } + + // ───────────────────────────────────────────────────────────────────────── + // Application creation + // ───────────────────────────────────────────────────────────────────────── + + private async Task CreateApplicationAsync( + HttpClient client, string baseUrl, + string abbrev, string slug, int providerId, + CancellationToken ct) + { + _logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId); + + var payload = new Dictionary + { + ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}", + ["slug"] = slug, + ["provider"] = providerId, + }; + + var resp = await client.PostAsJsonAsync("/api/v3/core/applications/", payload, ct); + + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create Authentik application '{slug}' (HTTP {(int)resp.StatusCode}): {errorBody}"); + } + + _logger.LogInformation("[Authentik] Application '{Slug}' created", slug); + } + + // ───────────────────────────────────────────────────────────────────────── + // Metadata retrieval & parsing + // ───────────────────────────────────────────────────────────────────────── + + private async Task FetchAndParseMetadataAsync( + HttpClient client, string baseUrl, int providerId, CancellationToken ct) + { + _logger.LogDebug("[Authentik] Fetching SAML metadata for provider {ProviderId}", providerId); + + // Request XML metadata (override Accept header for this call) + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v3/providers/saml/{providerId}/metadata/?download"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + + var resp = await client.SendAsync(request, ct); + resp.EnsureSuccessStatusCode(); + + var xml = await resp.Content.ReadAsStringAsync(ct); + return ParseMetadataXml(xml, providerId); + } + + private AuthentikSamlConfig ParseMetadataXml(string xml, int providerId) + { + var doc = XDocument.Parse(xml); + var root = doc.Root + ?? throw new InvalidOperationException("SAML metadata XML has no root element."); + + var config = new AuthentikSamlConfig + { + ProviderId = providerId, + IdpEntityId = root.Attribute("entityID")?.Value ?? "authentik", + }; + + // Extract x509 certificate (signing key) + var certElement = root.Descendants(Ds + "X509Certificate").FirstOrDefault(); + if (certElement != null) + { + // Remove whitespace/newlines from cert — some SAML libs expect a single line + config.IdpX509Cert = certElement.Value.Replace("\n", "").Replace("\r", "").Trim(); + } + + // Extract SSO URLs + foreach (var sso in root.Descendants(Md + "SingleSignOnService")) + { + var binding = sso.Attribute("Binding")?.Value; + var location = sso.Attribute("Location")?.Value; + if (location == null) continue; + + if (binding?.Contains("HTTP-Redirect") == true) + config.SsoUrlRedirect = location; + else if (binding?.Contains("HTTP-POST") == true) + config.SsoUrlPost = location; + } + + // Extract SLO URLs + foreach (var slo in root.Descendants(Md + "SingleLogoutService")) + { + var binding = slo.Attribute("Binding")?.Value; + var location = slo.Attribute("Location")?.Value; + if (location == null) continue; + + if (binding?.Contains("HTTP-Redirect") == true) + config.SloUrlRedirect = location; + else if (binding?.Contains("HTTP-POST") == true) + config.SloUrlPost = location; + } + + _logger.LogDebug( + "[Authentik] Metadata parsed: entityId={EntityId}, ssoRedirect={SsoUrl}, sloRedirect={SloUrl}, certLen={CertLen}", + config.IdpEntityId, config.SsoUrlRedirect, config.SloUrlRedirect, config.IdpX509Cert.Length); + + return config; + } + + // ───────────────────────────────────────────────────────────────────────── + // API response DTOs (internal) + // ───────────────────────────────────────────────────────────────────────── + + private class AuthentikListResponse + { + [JsonPropertyName("pagination")] + public object? Pagination { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } = new(); + } + + private class AuthentikFlow + { + [JsonPropertyName("pk")] + public string Pk { get; set; } = string.Empty; + + [JsonPropertyName("slug")] + public string Slug { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + + private class AuthentikSamlProvider + { + [JsonPropertyName("pk")] + public int Pk { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("url_sso_redirect")] + public string? UrlSsoRedirect { get; set; } + + [JsonPropertyName("url_sso_post")] + public string? UrlSsoPost { get; set; } + + [JsonPropertyName("url_slo_redirect")] + public string? UrlSloRedirect { get; set; } + + [JsonPropertyName("url_slo_post")] + public string? UrlSloPost { get; set; } + } + + private class AuthentikApplication + { + [JsonPropertyName("pk")] + public string Pk { get; set; } = string.Empty; + + [JsonPropertyName("slug")] + public string Slug { get; set; } = string.Empty; + + [JsonPropertyName("provider")] + public int? Provider { get; set; } + } +} diff --git a/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs b/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs index 48fd031..51757f9 100644 --- a/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs +++ b/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs @@ -56,7 +56,8 @@ public class GitTemplateService return new TemplateConfig { Yaml = yaml, - FetchedAt = DateTime.UtcNow + FetchedAt = DateTime.UtcNow, + CacheDir = cacheDir, }; } diff --git a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs new file mode 100644 index 0000000..79c7eda --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs @@ -0,0 +1,24 @@ +using OTSSignsOrchestrator.Core.Models.DTOs; + +namespace OTSSignsOrchestrator.Core.Services; + +/// +/// Provisions SAML applications in Authentik and retrieves IdP metadata +/// needed to render the Xibo SAML settings-custom.php template. +/// +public interface IAuthentikService +{ + /// + /// Creates an Authentik SAML provider and application for the given Xibo instance, + /// then fetches the IdP metadata (entity ID, x509 cert, SSO/SLO URLs). + /// If the application already exists (by slug), returns its existing metadata. + /// + /// Short customer abbreviation (used in naming). + /// Full base URL of the Xibo instance (e.g. https://app.ots-signs.com/demo). + /// Cancellation token. + /// IdP metadata needed for the SAML PHP configuration. + Task ProvisionSamlAsync( + string instanceAbbrev, + string instanceBaseUrl, + CancellationToken ct = default); +} diff --git a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs index c1edd75..b5a6f74 100644 --- a/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs +++ b/OTSSignsOrchestrator.Core/Services/IDockerCliService.cs @@ -97,6 +97,22 @@ public interface IDockerCliService /// Returns parsed log entries sorted by timestamp ascending. /// Task> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200); + + /// + /// Writes a file to an NFS volume by temporarily mounting the export on the Docker host. + /// Used to deploy configuration files (e.g. settings-custom.php) into CMS containers. + /// + /// NFS server hostname or IP. + /// NFS export path (e.g. "/srv/nfs"). + /// Path relative to the export root (e.g. "subfolder/abbrev/cms-custom/settings-custom.php"). + /// File content to write. + /// Optional subfolder within the export. + Task<(bool Success, string? Error)> WriteFileToNfsAsync( + string nfsServer, + string nfsExport, + string relativePath, + string content, + string? nfsExportFolder = null); } public class StackInfo diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs index ab5e919..309b23f 100644 --- a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs +++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using OTSSignsOrchestrator.Core.Configuration; namespace OTSSignsOrchestrator.Core.Services; @@ -106,6 +107,9 @@ public class PostInstanceInitService _logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns"); + // ── 6a. Deploy SAML configuration ───────────────────────────────── + await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct); + // ── 7. Store credentials in Bitwarden ───────────────────────────── _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); @@ -196,6 +200,9 @@ public class PostInstanceInitService _logger.LogInformation("[PostInit] Setting theme to 'otssigns'"); await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns"); + // ── 5a. Deploy SAML configuration ───────────────────────────────── + await DeploySamlConfigurationAsync(abbrev, instanceUrl, settings, ct); + // ── 6. Store admin password in Bitwarden ────────────────────────── _logger.LogInformation("[PostInit] Storing credentials in Bitwarden"); var adminSecretId = await bws.CreateInstanceSecretAsync( @@ -335,6 +342,90 @@ public class PostInstanceInitService }; } + // ───────────────────────────────────────────────────────────────────────── + // SAML configuration deployment + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Provisions a SAML application in Authentik, renders the settings-custom.php template, + /// and writes the rendered file to the instance's NFS-backed cms-custom volume. + /// Errors are logged but do not fail the overall post-init process. + /// + private async Task DeploySamlConfigurationAsync( + string abbrev, + string instanceUrl, + SettingsService settings, + CancellationToken ct) + { + try + { + _logger.LogInformation("[PostInit] Deploying SAML settings-custom.php for {Abbrev}", abbrev); + + using var scope = _services.CreateScope(); + var authentik = scope.ServiceProvider.GetRequiredService(); + var git = scope.ServiceProvider.GetRequiredService(); + var docker = scope.ServiceProvider.GetRequiredService(); + + // ── 1. Fetch template from git repo ─────────────────────────────── + var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); + var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); + if (string.IsNullOrWhiteSpace(repoUrl)) + throw new InvalidOperationException("Git repository URL is not configured."); + + var templateConfig = await git.FetchAsync(repoUrl, repoPat); + var templatePath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template"); + + if (!File.Exists(templatePath)) + { + _logger.LogWarning( + "[PostInit] settings-custom.php.template not found in git repo — skipping SAML deployment"); + return; + } + + var templateContent = await File.ReadAllTextAsync(templatePath, ct); + + // ── 2. Provision Authentik SAML application ─────────────────────── + var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml"; + var samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct); + + // ── 3. Render template ──────────────────────────────────────────── + var rendered = templateContent + .Replace("{{SAML_BASE_URL}}", samlBaseUrl) + .Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata") + .Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig.IdpEntityId) + .Replace("{{AUTHENTIK_SSO_URL}}", samlConfig.SsoUrlRedirect) + .Replace("{{AUTHENTIK_SLO_URL}}", samlConfig.SloUrlRedirect) + .Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig.IdpX509Cert); + + // ── 4. Write rendered file to NFS volume ────────────────────────── + var nfsServer = await settings.GetAsync(SettingsService.NfsServer); + var nfsExport = await settings.GetAsync(SettingsService.NfsExport); + var nfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder); + + if (string.IsNullOrWhiteSpace(nfsServer) || string.IsNullOrWhiteSpace(nfsExport)) + throw new InvalidOperationException("NFS settings are not configured — cannot write SAML config to volume."); + + // Path within the NFS export: {abbrev}/cms-custom/settings-custom.php + var nfsRelativePath = $"{abbrev}/cms-custom/settings-custom.php"; + + var (success, error) = await docker.WriteFileToNfsAsync( + nfsServer, nfsExport, nfsRelativePath, rendered, nfsExportFolder); + + if (!success) + throw new InvalidOperationException($"Failed to write settings-custom.php to NFS: {error}"); + + _logger.LogInformation( + "[PostInit] SAML configuration deployed for {Abbrev} (Authentik provider={ProviderId})", + abbrev, samlConfig.ProviderId); + } + catch (Exception ex) + { + _logger.LogError(ex, "[PostInit] SAML deployment failed for {Abbrev}: {Message}. " + + "Instance will continue without SAML — configure manually if needed.", abbrev, ex.Message); + // Don't rethrow — SAML failure should not block the rest of post-init + } + } + // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs index 6a9de53..1857b3f 100644 --- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs +++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs @@ -22,6 +22,7 @@ public class SettingsService public const string CatPangolin = "Pangolin"; public const string CatNfs = "Nfs"; public const string CatDefaults = "Defaults"; + public const string CatAuthentik = "Authentik"; // ── Key constants ────────────────────────────────────────────────────── // Git @@ -72,6 +73,13 @@ public class SettingsService public const string XiboBootstrapClientId = "Xibo.BootstrapClientId"; public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret"; + // Authentik (SAML IdP provisioning) + public const string AuthentikUrl = "Authentik.Url"; + public const string AuthentikApiKey = "Authentik.ApiKey"; + public const string AuthentikAuthorizationFlowSlug = "Authentik.AuthorizationFlowSlug"; + public const string AuthentikInvalidationFlowSlug = "Authentik.InvalidationFlowSlug"; + public const string AuthentikSigningKeypairId = "Authentik.SigningKeypairId"; + // Instance-specific (keyed by abbreviation) /// /// Builds a per-instance settings key for the MySQL password. diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs index 04e1854..6ec1646 100644 --- a/OTSSignsOrchestrator.Desktop/App.axaml.cs +++ b/OTSSignsOrchestrator.Desktop/App.axaml.cs @@ -138,6 +138,7 @@ public class App : Application services.AddHttpClient(); services.AddHttpClient("XiboApi"); services.AddHttpClient("XiboHealth"); + services.AddHttpClient("AuthentikApi"); // SSH services (singletons — maintain connections) services.AddSingleton(); @@ -156,6 +157,7 @@ public class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(); // ViewModels diff --git a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs index 55aed34..ec8302c 100644 --- a/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs +++ b/OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs @@ -259,6 +259,58 @@ public class SshDockerCliService : IDockerCliService return (false, error); } + public async Task<(bool Success, string? Error)> WriteFileToNfsAsync( + string nfsServer, + string nfsExport, + string relativePath, + string content, + string? nfsExportFolder = null) + { + EnsureHost(); + var exportPath = (nfsExport ?? string.Empty).Trim('/'); + var subFolder = (nfsExportFolder ?? string.Empty).Trim('/'); + var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}"; + + // Ensure parent directory exists, then write content via heredoc + var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}"; + var parentDir = $"$(dirname \"{targetPath}\")"; + + // Escape content for heredoc (replace any literal EOF that might appear in content) + var safeContent = content.Replace("'", "'\\''"); + + var script = $""" + set -e + MNT=$(mktemp -d) + sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT" + sudo mkdir -p {parentDir} + sudo tee "{targetPath}" > /dev/null << 'OTSSIGNS_EOF' + {content} + OTSSIGNS_EOF + sudo umount "$MNT" + rmdir "$MNT" + """; + + _logger.LogInformation( + "Writing file to NFS {Server}:/{Export}{Sub}/{Path} on Docker host {Host}", + nfsServer, exportPath, subPath, relativePath, _currentHost!.Label); + + var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30)); + + if (exitCode == 0) + { + _logger.LogInformation( + "File written to NFS on {Host}: {Server}:/{Export}{Sub}/{Path}", + _currentHost.Label, nfsServer, exportPath, subPath, relativePath); + return (true, null); + } + + var error = (stderr ?? stdout ?? "unknown error").Trim(); + _logger.LogWarning( + "Failed to write file to NFS on {Host}: {Error}", + _currentHost.Label, error); + return (false, error); + } + public async Task ForceUpdateServiceAsync(string serviceName) { EnsureHost(); diff --git a/templates/settings-custom.php.template b/templates/settings-custom.php.template new file mode 100644 index 0000000..167b1e9 --- /dev/null +++ b/templates/settings-custom.php.template @@ -0,0 +1,63 @@ + [ + 'jit' => true, + 'field_to_identify' => 'UserName', + 'libraryQuota' => 1000, + 'homePage' => 'icondashboard.view', + 'slo' => true, + 'mapping' => [ + 'UserID' => '', + 'usertypeid' => '', + 'UserName' => 'http://schemas.goauthentik.io/2021/02/saml/username', + 'email' => 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + ], + 'group' => 'Users', + 'matchGroups' => [ + 'enabled' => false, + 'attribute' => null, + 'extractionRegEx' => null, + ], + ], + 'strict' => true, + 'debug' => true, + 'baseurl' => '{{SAML_BASE_URL}}', + 'idp' => [ + 'entityId' => '{{AUTHENTIK_IDP_ENTITY_ID}}', + 'singleSignOnService' => [ + 'url' => '{{AUTHENTIK_SSO_URL}}', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + 'singleLogoutService' => [ + 'url' => '{{AUTHENTIK_SLO_URL}}', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + 'x509cert' => '{{AUTHENTIK_IDP_X509_CERT}}', + ], + 'sp' => [ + 'entityId' => '{{SAML_SP_ENTITY_ID}}', + 'assertionConsumerService' => [ + 'url' => '{{SAML_BASE_URL}}/acs', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ], + 'singleLogoutService' => [ + 'url' => '{{SAML_BASE_URL}}/sls', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + 'x509cert' => '', + 'privateKey' => '', + ], + 'security' => [ + 'nameIdEncrypted' => false, + 'authnRequestsSigned' => false, + 'logoutRequestSigned' => false, + 'logoutResponseSigned' => false, + 'signMetadata' => false, + 'wantMessagesSigned' => false, + 'wantAssertionsSigned' => false, + 'wantAssertionsEncrypted' => false, + 'wantNameIdEncrypted' => false, + ], +]; diff --git a/template.yml b/templates/template.yml similarity index 100% rename from template.yml rename to templates/template.yml