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