diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..300223d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,73 @@ +# Project Guidelines — OTS Signs Orchestrator + +## Architecture + +Layered MVVM desktop app for provisioning and managing Xibo CMS instances on Docker Swarm. + +- **OTSSignsOrchestrator.Core** — class library: services, EF Core data access, models, configuration. Reusable across UIs. +- **OTSSignsOrchestrator.Desktop** — Avalonia 11 UI: views, view models, DI setup. References Core. +- **templates/** — Docker Compose + PHP templates with `{{PLACEHOLDER}}` substitution. + +### Key patterns +- Services injected via `IServiceProvider` (registered in `App.axaml.cs` → `ConfigureServices()`) +- Singletons: stateful services (SSH connections, Docker CLI). Transient: stateless logic. +- Configuration via `IOptions` bound from `appsettings.json` (see `AppOptions.cs` for all sections). +- Bitwarden Secrets Manager is the source of truth for all sensitive config. `SettingsService` caches in-memory. +- Local SQLite DB (`otssigns-desktop.db`) stores SSH hosts + operation logs. Credentials encrypted via Data Protection API. + +### External integrations +Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN. + +## Build and Test + +```bash +# Build +dotnet build OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj + +# Run +dotnet run --project OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj + +# No test suite currently — no xUnit/NUnit projects +``` + +- .NET 9.0, Avalonia 11.2.3, CommunityToolkit.Mvvm 8.4 +- Runtime identifiers: `linux-x64`, `win-x64`, `osx-x64`, `osx-arm64` +- EF Core migrations in `OTSSignsOrchestrator.Core/Migrations/` + +## Conventions + +### ViewModels +- Inherit `ObservableObject` (CommunityToolkit.Mvvm). Use `[ObservableProperty]` for bindable fields and `[RelayCommand]` for commands. +- React to changes via `partial void OnXxxChanged(T value)` methods generated by the toolkit. +- Resolve services from `IServiceProvider` in constructors. Navigation via `MainWindowViewModel.CurrentView`. +- Confirmation dialogs use `Func> ConfirmAsync` property — wired by the View. + +### Views (Avalonia XAML) +- Compiled bindings enabled (`x:CompileBindings="True"`). DataTemplates in `MainWindow.axaml` map ViewModel types to View UserControls. +- Layout: DockPanel with status bar (bottom), sidebar nav (left), dynamic ContentControl (center). +- Style: Fluent theme, dark palette (`#0C0C14` accents). + +### Services +- Interface + implementation pattern for testable services (`IXiboApiService`, `IDockerCliService`, etc.). +- `SshDockerCliService` is a singleton — **must call `SetHost(host)` before each operation** in loops. +- All long operations are `async Task`. Use `IsBusy` + `StatusMessage` properties for UI feedback. + +### Naming +- Customer abbreviation: exactly 3 lowercase letters (`^[a-z]{3}$`). +- Stack name: `{abbrev}-cms-stack`. Service: `{abbrev}-web`. DB: `{abbrev}_cms_db`. +- Secret names built via `AppConstants` helpers (e.g., `CustomerMysqlPasswordSecretName(abbrev)`). +- `AppConstants.SanitizeName()` filters to `[a-z0-9_-]`. + +### Data layer +- Entities in `Core/Models/Entities/`, DTOs in `Core/Models/DTOs/`. +- `XiboContext` applies unique index on `SshHost.Label` and encrypts credential fields. +- Add new migrations via: `dotnet ef migrations add --project OTSSignsOrchestrator.Core --startup-project OTSSignsOrchestrator.Desktop` + +## Pitfalls + +- **SSH singleton state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures. +- **Bitwarden cache**: After creating secrets during deployment, call `FlushCacheAsync()` before reading them back. +- **Data Protection keys**: Stored in `%APPDATA%/OTSSignsOrchestrator/keys`. If lost, encrypted SSH passwords are unrecoverable. +- **Docker volumes are sticky**: Failed deploys leave volumes with old NFS driver options. Use `PurgeStaleVolumes: true` to force fresh volumes (causes data loss). +- **No saga/rollback**: Instance creation spans Git → MySQL → Docker → Xibo. Partial failures leave orphaned resources; cleanup is manual via `OperationLog`. +- **Template CIFS→NFS compat**: Old `{{CIFS_*}}` tokens still render correctly as NFS equivalents. diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index 0cc0da3..9663c2a 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit 0cc0da3c6e596f18c5697f2eb11450d72a18f7c3 +Subproject commit 9663c2ade81c9b8bd40c6310647adef3762d2162 diff --git a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs index 4e1d748..4bd9a59 100644 --- a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs @@ -691,36 +691,63 @@ public class AuthentikService : IAuthentikService { _logger.LogInformation("[Authentik] Creating usertypeid property mapping"); - var expression = @"return ""1"" if user.groups.all() | selectattr(""name"", ""equalto"", ""OTS IT"") else """""; + // Use proper Authentik Python syntax for group membership check + var expression = @" +# Check if user is in OTS IT admin group +admin_groups = [g.name for g in user.groups.all()] +return '1' if 'OTS IT' in admin_groups else '' +"; var payload = new Dictionary { ["name"] = "saml-usertypeid", - ["saml_name"] = "usertypeid", ["expression"] = expression, }; var jsonBody = JsonSerializer.Serialize(payload); - var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + _logger.LogDebug("[Authentik] Creating property mapping with payload: {Payload}", jsonBody); - var resp = await client.PostAsync("/api/v3/propertymappings/provider/saml/", content, ct); - - if (!resp.IsSuccessStatusCode) + // Try multiple endpoints (API versions vary) + var endpoints = new[] { - var errorBody = await resp.Content.ReadAsStringAsync(ct); - _logger.LogWarning("[Authentik] Failed to create usertypeid mapping (HTTP {Status}): {Error}", - (int)resp.StatusCode, errorBody); - return null; + "/api/v3/propertymappings/provider/saml/", + "/api/v3/propertymappings/saml/", + "/api/v3/propertymappings/", + }; + + foreach (var endpoint in endpoints) + { + try + { + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync(endpoint, content, ct); + + _logger.LogDebug("[Authentik] POST {Endpoint} returned HTTP {Status}", endpoint, (int)resp.StatusCode); + + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(ct); + _logger.LogDebug("[Authentik] Error response: {Error}", errorBody); + continue; + } + + var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); + if (result?.Pk != null) + { + _logger.LogInformation("[Authentik] Created usertypeid mapping: {Id} ({Name}) via {Endpoint}", + result.Pk, result.Name, endpoint); + return result.Pk; + } + + _logger.LogDebug("[Authentik] Created usertypeid mapping but response had no Pk"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Error creating usertypeid mapping via {Endpoint}", endpoint); + } } - var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); - if (result?.Pk != null) - { - _logger.LogInformation("[Authentik] Created usertypeid mapping: {Id}", result.Pk); - return result.Pk; - } - - _logger.LogWarning("[Authentik] Created usertypeid mapping but response had no Pk"); + _logger.LogWarning("[Authentik] Could not create usertypeid mapping via any endpoint"); return null; } catch (Exception ex) @@ -761,26 +788,46 @@ public class AuthentikService : IAuthentikService { foreach (var mapEl in mapsProp.EnumerateArray()) { - var mapId = mapEl.ValueKind == JsonValueKind.String - ? mapEl.GetString() - : (mapEl.TryGetProperty("pk", out var pkProp) - ? pkProp.GetString() - : null); + string? mapId = null; + + // Handle both string IDs and object IDs + if (mapEl.ValueKind == JsonValueKind.String) + { + mapId = mapEl.GetString(); + } + else if (mapEl.ValueKind == JsonValueKind.Object && mapEl.TryGetProperty("pk", out var pkProp)) + { + mapId = pkProp.GetString(); + } if (!string.IsNullOrEmpty(mapId) && !mappings.Contains(mapId)) + { mappings.Add(mapId); + _logger.LogDebug("[Authentik] Found existing mapping: {Id}", mapId); + } } } // ── 3. Add new mapping if not already present ────────────────── if (!mappings.Contains(mappingId)) + { mappings.Add(mappingId); + _logger.LogInformation("[Authentik] Adding usertypeid mapping {Id} to provider", mappingId); + } + else + { + _logger.LogInformation("[Authentik] Usertypeid mapping {Id} already attached to provider", mappingId); + return; + } // ── 4. Patch provider with updated mappings ─────────────────── var patchPayload = JsonSerializer.Serialize(new Dictionary { ["property_mappings"] = mappings, }); + _logger.LogDebug("[Authentik] Patching provider {Id} with mappings: {Mappings}", + providerId, string.Join(", ", mappings)); + var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/") { diff --git a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs index be85272..0106b26 100644 --- a/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs +++ b/OTSSignsOrchestrator.Core/Services/ComposeRenderService.cs @@ -7,7 +7,6 @@ namespace OTSSignsOrchestrator.Core.Services; /// Renders a Docker Compose file by loading a template from the git repo and substituting /// all {{PLACEHOLDER}} tokens with values from RenderContext. /// The template file expected in the repo is template.yml. -/// Call to obtain the canonical template to commit to your repo. /// public class ComposeRenderService { @@ -148,156 +147,6 @@ public class ComposeRenderService return folders; } - /// - /// Returns the canonical template.yml content with all placeholders. - /// Commit this file to the root of your template git repository. - /// - public static string GetTemplateYaml() => TemplateYaml; - - // ── Canonical template ────────────────────────────────────────────────── - - public const string TemplateYaml = - """ - # Customer: {{CUSTOMER_NAME}} - version: "3.9" - - services: - - {{ABBREV}}-web: - image: {{CMS_IMAGE}} - environment: - CMS_USE_MEMCACHED: "true" - MEMCACHED_HOST: memcached - MYSQL_HOST: {{MYSQL_HOST}} - MYSQL_PORT: "{{MYSQL_PORT}}" - MYSQL_DATABASE: {{MYSQL_DATABASE}} - MYSQL_USER: {{MYSQL_USER}} - MYSQL_PASSWORD: {{MYSQL_PASSWORD}} - CMS_SERVER_NAME: {{CMS_SERVER_NAME}} - CMS_SMTP_SERVER: {{SMTP_SERVER}} - CMS_SMTP_USERNAME: {{SMTP_USERNAME}} - CMS_SMTP_PASSWORD: {{SMTP_PASSWORD}} - CMS_SMTP_USE_TLS: {{SMTP_USE_TLS}} - CMS_SMTP_USE_STARTTLS: {{SMTP_USE_STARTTLS}} - CMS_SMTP_REWRITE_DOMAIN: {{SMTP_REWRITE_DOMAIN}} - CMS_SMTP_HOSTNAME: {{SMTP_HOSTNAME}} - CMS_SMTP_FROM_LINE_OVERRIDE: {{SMTP_FROM_LINE_OVERRIDE}} - CMS_PHP_POST_MAX_SIZE: {{PHP_POST_MAX_SIZE}} - CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}} - CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}" - secrets: - - {{ABBREV}}-cms-db-user - - global_mysql_host - - global_mysql_port - volumes: - - {{ABBREV}}-cms-custom:/var/www/cms/custom - - {{ABBREV}}-cms-backup:/var/www/backup - - {{THEME_HOST_PATH}}:/var/www/cms/web/theme/custom - - {{ABBREV}}-cms-library:/var/www/cms/library - - {{ABBREV}}-cms-userscripts:/var/www/cms/web/userscripts - - {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs - ports: - - "{{HOST_HTTP_PORT}}:80" - healthcheck: - test: ["CMD-SHELL", "curl -fsS --max-time 5 http://web:80/about | grep -Eo 'v?[0-9]+(\\.[0-9]+)+' >/dev/null || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - {{ABBREV}}-net: - aliases: - - web - deploy: - restart_policy: - condition: any - resources: - limits: - memory: 1G - - {{ABBREV}}-memcached: - image: {{MEMCACHED_IMAGE}} - command: [memcached, -m, "15"] - networks: - {{ABBREV}}-net: - aliases: - - memcached - deploy: - restart_policy: - condition: any - resources: - limits: - memory: 100M - - {{ABBREV}}-quickchart: - image: {{QUICKCHART_IMAGE}} - networks: - {{ABBREV}}-net: - aliases: - - quickchart - deploy: - restart_policy: - condition: any - - {{ABBREV}}-newt: - image: {{NEWT_IMAGE}} - environment: - PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}} - NEWT_ID: {{NEWT_ID}} - NEWT_SECRET: {{NEWT_SECRET}} - depends_on: - - {{ABBREV}}-web - networks: - {{ABBREV}}-net: {} - deploy: - restart_policy: - condition: any - - networks: - {{ABBREV}}-net: - driver: overlay - attachable: "false" - - volumes: - {{ABBREV}}-cms-custom: - driver: local - driver_opts: - type: nfs - device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-custom" - o: "{{NFS_OPTS}}" - {{ABBREV}}-cms-backup: - driver: local - driver_opts: - type: nfs - device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-backup" - o: "{{NFS_OPTS}}" - {{ABBREV}}-cms-library: - driver: local - driver_opts: - type: nfs - device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-library" - o: "{{NFS_OPTS}}" - {{ABBREV}}-cms-userscripts: - driver: local - driver_opts: - type: nfs - device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-userscripts" - o: "{{NFS_OPTS}}" - {{ABBREV}}-cms-ca-certs: - driver: local - driver_opts: - type: nfs - device: "{{NFS_DEVICE_PREFIX}}/{{ABBREV}}/cms-ca-certs" - o: "{{NFS_OPTS}}" - - secrets: - {{ABBREV}}-cms-db-user: - external: true - global_mysql_host: - external: true - global_mysql_port: - external: true - """; } /// Context object with all inputs needed to render a Compose file. diff --git a/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs b/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs index 51757f9..c8b69de 100644 --- a/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs +++ b/OTSSignsOrchestrator.Core/Services/GitTemplateService.cs @@ -49,7 +49,7 @@ public class GitTemplateService var yamlPath = FindFile(cacheDir, "template.yml"); if (yamlPath == null) - throw new FileNotFoundException("template.yml not found in repository root. Commit the template file produced by ComposeRenderService.GetTemplateYaml() to the repo root."); + throw new FileNotFoundException("template.yml not found in repository root. Ensure template.yml is committed to the root of your template git repository."); var yaml = await File.ReadAllTextAsync(yamlPath); diff --git a/templates/settings-custom.php.template b/templates/settings-custom.php.template index 9833448..e6e674e 100644 --- a/templates/settings-custom.php.template +++ b/templates/settings-custom.php.template @@ -2,20 +2,28 @@ /** * SAML Authentication Configuration with Group-Based Admin Assignment * - * Group-Based Admin Assignment: + * Group-Based Admin Assignment (AUTOMATIC): * ──────────────────────────────────────────────────────────────────────── - * To make members of specific Authentik groups admins in Xibo: + * The OTSSignsOrchestrator AUTOMATICALLY creates and configures: * - * 1. In Authentik, create a custom property mapping for your SAML provider: - * - Name: saml-usertypeid - * - Expression: Return "1" if user in admin group, else return empty string - * - Example: return "1" if user.groups.all() | selectattr("name", "equalto", "OTS IT") else "" + * 1. A custom property mapping "saml-usertypeid" in Authentik that: + * - Returns "1" for users in the "OTS IT" group + * - Returns empty string for other users * - * 2. Attach this mapping to the SAML provider via the API or UI + * 2. Attaches this mapping to the SAML provider so the attribute is sent in SAML responses * - * 3. The usertypeid mapping below will read this attribute from the SAML response + * 3. Configures Xibo to read the usertypeid SAML attribute (see 'usertypeid' mapping below) * - * 4. On JIT provisioning, Xibo will assign users with usertypeid=1 as super-admins + * Result: OTS IT members are automatically assigned as super-admins on SSO login. + * Other users are assigned normal permissions. + * + * If OTS IT members are NOT getting admin access: + * ──────────────────────────────────────────────────────────────────────── + * 1. Check Authentik logs for "saml-usertypeid" mapping creation errors + * 2. Verify the mapping exists: Authentik → Customization → Property Mappings + * 3. Verify it's attached to the SAML provider: Authentik → Providers → SAML → Properties + * 4. Enable Xibo SAML debug logging below ('debug' => true) and check xibo.log after login + * 5. Check SAML assertion in browser dev tools (Network tab, catch SAML response) * * Excluded Groups: * ──────────────────────────────────────────────────────────────────────── @@ -35,9 +43,10 @@ $samlSettings = [ 'slo' => true, 'mapping' => [ 'UserID' => '', - // usertypeid: Set to 1 (super-admin) for members of admin groups. - // Requires a custom SAML property mapping in Authentik (see notes above). - 'usertypeid' => 'http://schemas.goauthentik.io/2021/02/saml/usertypeid', + // usertypeid: Set to 1 (super-admin) for members of OTS IT group. + // The saml-usertypeid custom property mapping (auto-created during provisioning) + // outputs this attribute. Xibo's JIT provisioning reads it and auto-assigns admins. + 'usertypeid' => 'saml-usertypeid', 'UserName' => 'http://schemas.goauthentik.io/2021/02/saml/username', 'email' => 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', ], diff --git a/templates/template.yml b/templates/template.yml index 222872b..b04c049 100644 --- a/templates/template.yml +++ b/templates/template.yml @@ -43,6 +43,8 @@ services: aliases: - web deploy: + placement: + constraints: [node.role == worker] restart_policy: condition: any resources: @@ -57,6 +59,8 @@ services: aliases: - memcached deploy: + placement: + constraints: [node.role == worker] restart_policy: condition: any resources: @@ -70,6 +74,8 @@ services: aliases: - quickchart deploy: + placement: + constraints: [node.role == worker] restart_policy: condition: any @@ -84,6 +90,8 @@ services: networks: {{ABBREV}}-net: {} deploy: + placement: + constraints: [node.role == worker] restart_policy: condition: any