Compare commits
8 Commits
main
...
c6d46098dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d46098dd | ||
|
|
c2e03de8bb | ||
|
|
150549a20d | ||
|
|
9493bdb9df | ||
|
|
56d48b6062 | ||
|
|
2aaa0442b2 | ||
|
|
90eb649940 | ||
|
|
a1c987ff21 |
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# OTSSignsOrchestrator.Server — required environment variables
|
||||||
|
# Copy to .env and fill in real values.
|
||||||
|
|
||||||
|
ConnectionStrings__OrchestratorDb=Host=localhost;Port=5432;Database=orchestrator_dev;Username=ots;Password=devpassword
|
||||||
|
Stripe__WebhookSecret=whsec_...
|
||||||
|
Stripe__SecretKey=sk_test_...
|
||||||
|
Jwt__Key=change-me-to-a-random-256-bit-key
|
||||||
|
Authentik__BaseUrl=https://auth.example.com
|
||||||
|
Authentik__ApiToken=
|
||||||
|
SendGrid__ApiKey=SG....
|
||||||
|
OTS_SIGNS_SERVER_URL=http://localhost:5000
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Gitea Actions workflow: build Docker image and push to a container registry
|
|
||||||
# Place secrets in the repository settings: REGISTRY (host[:port]), IMAGE_NAME, DOCKER_USERNAME, DOCKER_PASSWORD
|
|
||||||
|
|
||||||
name: Build and Publish Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
# Use an appropriate runner that has Docker available (self-hosted runner)
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
# run everything in a single shell step to keep tag calculation simple
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
REGISTRY="${{ secrets.REGISTRY }}"
|
|
||||||
IMAGE_NAME="${{ secrets.IMAGE_NAME }}"
|
|
||||||
DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}"
|
|
||||||
DOCKER_PASSWORD="${{ secrets.DOCKER_PASSWORD }}"
|
|
||||||
|
|
||||||
if [ -z "$REGISTRY" ] || [ -z "$IMAGE_NAME" ]; then
|
|
||||||
echo "Missing required secrets: REGISTRY and IMAGE_NAME must be set." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAG=$(git rev-parse --short HEAD)
|
|
||||||
IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
|
|
||||||
LATEST="$REGISTRY/$IMAGE_NAME:latest"
|
|
||||||
|
|
||||||
echo "Logging in to $REGISTRY"
|
|
||||||
echo "$DOCKER_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
|
|
||||||
|
|
||||||
echo "Building $IMAGE (and tagging as latest)"
|
|
||||||
docker build -t "$IMAGE" -t "$LATEST" .
|
|
||||||
|
|
||||||
echo "Pushing $IMAGE"
|
|
||||||
docker push "$IMAGE"
|
|
||||||
|
|
||||||
echo "Pushing $LATEST"
|
|
||||||
docker push "$LATEST"
|
|
||||||
|
|
||||||
env:
|
|
||||||
# secrets are available via ${{ secrets.<name> }} in Gitea Actions
|
|
||||||
REGISTRY: ${{ secrets.REGISTRY }}
|
|
||||||
IMAGE_NAME: ${{ secrets.IMAGE_NAME }}
|
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
123
.github/copilot-instructions.md
vendored
Normal file
123
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# 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<T>` 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.
|
||||||
|
|
||||||
|
### Scope & file discipline
|
||||||
|
**The Server project is net-new — keep concerns separated.**
|
||||||
|
- Never modify `OTSSignsOrchestrator.Core` or `OTSSignsOrchestrator.Desktop` unless the prompt explicitly says to.
|
||||||
|
- When in doubt, add new code to `OTSSignsOrchestrator.Server`.
|
||||||
|
- Never modify `XiboContext.cs` without explicit instruction.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
#### Xibo API rules — non-negotiable
|
||||||
|
- `GET /api/application` is **BLOCKED**. Only POST and DELETE exist.
|
||||||
|
- All group endpoints are `/api/group`, never `/api/usergroup`.
|
||||||
|
- Feature assignment is `POST /api/group/{id}/acl`, NOT `/features`.
|
||||||
|
- Xibo paginates at 10 items by default. **Always pass `length=200`** and use `GetAllPagesAsync` for every list call. Missing this causes silent data truncation.
|
||||||
|
- OAuth2 client secret is returned **ONCE** in the `POST /api/application` response. Capture it immediately — **it cannot be retrieved again**.
|
||||||
|
|
||||||
|
#### Stripe webhooks — idempotency is mandatory
|
||||||
|
- Every Stripe webhook handler must check `OrchestratorDbContext.StripeEvents` for the `stripe_event_id` before processing anything.
|
||||||
|
- Insert the `StripeEvent` row first, then process the webhook. This is not optional — duplicate webhook delivery is guaranteed by Stripe.
|
||||||
|
|
||||||
|
#### No AI autonomy in infrastructure actions
|
||||||
|
- Never generate any endpoint or method that sends a message, makes an external call, or takes infrastructure action without an explicit operator-initiated `Job` record being created first.
|
||||||
|
- All automated actions flow through the `ProvisioningWorker` job queue.
|
||||||
|
|
||||||
|
## 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/`
|
||||||
|
|
||||||
|
### Test coverage non-negotiables
|
||||||
|
Unit tests are **required** for:
|
||||||
|
- Evidence hashing and tamper detection
|
||||||
|
- AI context assembly
|
||||||
|
- Pattern detection ruleset engine
|
||||||
|
- `AbbreviationService` uniqueness logic
|
||||||
|
- Stripe webhook idempotency
|
||||||
|
|
||||||
|
Integration tests **require** Testcontainers with a real PostgreSQL 16 instance — **no SQLite substitutions**.
|
||||||
|
|
||||||
|
## 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<string, string, Task<bool>> ConfirmAsync` property — wired by the View.
|
||||||
|
|
||||||
|
### Avalonia threading — critical for stability
|
||||||
|
All SignalR message handlers and background thread continuations that touch `ObservableProperty` or `ObservableCollection` **MUST** be wrapped in:
|
||||||
|
```csharp
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... });
|
||||||
|
```
|
||||||
|
**Failure to do this causes silent cross-thread exceptions in Avalonia.** Never suggest direct property assignment from a non-UI thread.
|
||||||
|
|
||||||
|
### 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_-]`.
|
||||||
|
|
||||||
|
### Credential handling
|
||||||
|
Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret. Log credentials to `JobStep` output **ONLY** as a last-resort break-glass fallback, and mark it explicitly as emergency recovery data in the log.
|
||||||
|
|
||||||
|
### Code generation verification
|
||||||
|
After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext<FleetHub>`.** Do not stub steps as TODO — implement them fully or flag explicitly that the step requires external infrastructure access that cannot be completed in this context.
|
||||||
|
|
||||||
|
### 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 <Name> --project OTSSignsOrchestrator.Core --startup-project OTSSignsOrchestrator.Desktop`
|
||||||
|
|
||||||
|
### Immutability enforcement
|
||||||
|
**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories. Add an explicit comment on each repository class:
|
||||||
|
```csharp
|
||||||
|
// IMMUTABLE — no update or delete operations permitted.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Ignore appsettings development files (if you keep secrets locally)
|
# Ignore appsettings development files (if you keep secrets locally)
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
.template-cache/
|
||||||
1
.template-cache/053604496cfa3867
Submodule
1
.template-cache/053604496cfa3867
Submodule
Submodule .template-cache/053604496cfa3867 added at eaf06cf624
Submodule .template-cache/2dc03e2b2b45fef3 updated: a6ab3c254b...9663c2ade8
@@ -47,6 +47,32 @@ public class DatabaseOptions
|
|||||||
public string Provider { get; set; } = "Sqlite";
|
public string Provider { get; set; } = "Sqlite";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bitwarden Secrets Manager connection settings.
|
||||||
|
/// Stored in appsettings.json so they can bootstrap the connection before any other settings are loaded.
|
||||||
|
/// </summary>
|
||||||
|
public class BitwardenOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Bitwarden";
|
||||||
|
|
||||||
|
public string IdentityUrl { get; set; } = "https://identity.bitwarden.com";
|
||||||
|
public string ApiUrl { get; set; } = "https://api.bitwarden.com";
|
||||||
|
|
||||||
|
/// <summary>Machine account access token (sensitive — may be set via environment variable).</summary>
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Project where config secrets are created/listed. Required.</summary>
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional separate project for instance-level secrets (DB passwords, Newt credentials, etc.).
|
||||||
|
/// When empty, instance secrets are stored in the default <see cref="ProjectId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string InstanceProjectId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public class InstanceDefaultsOptions
|
public class InstanceDefaultsOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "InstanceDefaults";
|
public const string SectionName = "InstanceDefaults";
|
||||||
@@ -56,7 +82,7 @@ public class InstanceDefaultsOptions
|
|||||||
public string? TemplateRepoPat { get; set; }
|
public string? TemplateRepoPat { get; set; }
|
||||||
|
|
||||||
/// <summary>Template for CMS server hostname. Use {abbrev} as placeholder.</summary>
|
/// <summary>Template for CMS server hostname. Use {abbrev} as placeholder.</summary>
|
||||||
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
|
public string CmsServerNameTemplate { get; set; } = "app.ots-signs.com";
|
||||||
|
|
||||||
public string SmtpServer { get; set; } = string.Empty;
|
public string SmtpServer { get; set; } = string.Empty;
|
||||||
public string SmtpUsername { get; set; } = string.Empty;
|
public string SmtpUsername { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ public class XiboContext : DbContext
|
|||||||
|
|
||||||
public DbSet<SshHost> SshHosts => Set<SshHost>();
|
public DbSet<SshHost> SshHosts => Set<SshHost>();
|
||||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -50,12 +49,5 @@ public class XiboContext : DbContext
|
|||||||
entity.HasIndex(e => e.StackName);
|
entity.HasIndex(e => e.StackName);
|
||||||
entity.HasIndex(e => e.Operation);
|
entity.HasIndex(e => e.Operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- AppSetting ---
|
|
||||||
modelBuilder.Entity<AppSetting>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(e => e.Key);
|
|
||||||
entity.HasIndex(e => e.Category);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs
generated
Normal file
125
OTSSignsOrchestrator.Core/Migrations/20260225135644_DropAppSettings.Designer.cs
generated
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(XiboContext))]
|
||||||
|
[Migration("20260225135644_DropAppSettings")]
|
||||||
|
partial class DropAppSettings
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("DurationMs")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StackName")
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Operation");
|
||||||
|
|
||||||
|
b.HasIndex("StackName");
|
||||||
|
|
||||||
|
b.HasIndex("Timestamp");
|
||||||
|
|
||||||
|
b.ToTable("OperationLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Host")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("KeyPassphrase")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool?>("LastTestSuccess")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTestedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Port")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PrivateKeyPath")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("UseKeyAuth")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Label")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SshHosts");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DropAppSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppSettings");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||||
|
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
Value = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppSettings", x => x.Key);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppSettings_Category",
|
||||||
|
table: "AppSettings",
|
||||||
|
column: "Category");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,34 +17,6 @@ namespace OTSSignsOrchestrator.Core.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||||
|
|
||||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Category")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<bool>("IsSensitive")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasMaxLength(4000)
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Key");
|
|
||||||
|
|
||||||
b.HasIndex("Category");
|
|
||||||
|
|
||||||
b.ToTable("AppSettings");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
17
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs
Normal file
17
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikFlowItem.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an Authentik flow for display in the Settings UI.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthentikFlowItem
|
||||||
|
{
|
||||||
|
public string Pk { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Designation { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display text for ComboBox: "slug — Name".</summary>
|
||||||
|
public string DisplayText => $"{Slug} — {Name}";
|
||||||
|
|
||||||
|
public override string ToString() => DisplayText;
|
||||||
|
}
|
||||||
18
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs
Normal file
18
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an Authentik group for display and sync operations.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthentikGroupItem
|
||||||
|
{
|
||||||
|
public string Pk { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public int MemberCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display text for UI: "Group Name (N members)".</summary>
|
||||||
|
public string DisplayText => MemberCount > 0
|
||||||
|
? $"{Name} ({MemberCount} member{(MemberCount == 1 ? "" : "s")})"
|
||||||
|
: Name;
|
||||||
|
|
||||||
|
public override string ToString() => DisplayText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an Authentik certificate keypair for display in the Settings UI.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthentikKeypairItem
|
||||||
|
{
|
||||||
|
public string Pk { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display text for ComboBox.</summary>
|
||||||
|
public string DisplayText => $"{Name} ({Pk[..Math.Min(8, Pk.Length)]})";
|
||||||
|
|
||||||
|
public override string ToString() => DisplayText;
|
||||||
|
}
|
||||||
32
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikSamlConfig.cs
Normal file
32
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikSamlConfig.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the IdP metadata extracted from an Authentik SAML provider,
|
||||||
|
/// used to render the settings-custom.php template.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthentikSamlConfig
|
||||||
|
{
|
||||||
|
/// <summary>IdP entity ID from SAML metadata (typically "authentik").</summary>
|
||||||
|
public string IdpEntityId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64-encoded X.509 signing certificate (no BEGIN/END markers).</summary>
|
||||||
|
public string IdpX509Cert { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>IdP Single Sign-On URL (HTTP-Redirect binding).</summary>
|
||||||
|
public string SsoUrlRedirect { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>IdP Single Sign-On URL (HTTP-POST binding).</summary>
|
||||||
|
public string SsoUrlPost { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>IdP Single Logout URL (HTTP-Redirect binding).</summary>
|
||||||
|
public string SloUrlRedirect { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>IdP Single Logout URL (HTTP-POST binding).</summary>
|
||||||
|
public string SloUrlPost { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Authentik provider primary key (for audit/debugging).</summary>
|
||||||
|
public int ProviderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Slug used in Authentik application URLs.</summary>
|
||||||
|
public string ApplicationSlug { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -37,4 +37,11 @@ public class CreateInstanceDto
|
|||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? NfsExtraOptions { get; set; }
|
public string? NfsExtraOptions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, any existing Docker volumes with the same stack prefix are removed before
|
||||||
|
/// deploying, so fresh volumes are created from the current compose driver_opts.
|
||||||
|
/// Defaults to false to avoid accidental data loss on re-deploys.
|
||||||
|
/// </summary>
|
||||||
|
public bool PurgeStaleVolumes { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,10 @@ public class DeploymentResultDto
|
|||||||
public int ExitCode { get; set; }
|
public int ExitCode { get; set; }
|
||||||
public long DurationMs { get; set; }
|
public long DurationMs { get; set; }
|
||||||
public int ServiceCount { get; set; }
|
public int ServiceCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The instance URL including the abbreviation sub-path (e.g. https://ots.ots-signs.com/ots).</summary>
|
||||||
|
public string? InstanceUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The 3-letter abbreviation for this instance.</summary>
|
||||||
|
public string? Abbrev { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
24
OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs
Normal file
24
OTSSignsOrchestrator.Core/Models/DTOs/ServiceLogEntry.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single log line from a Docker service.
|
||||||
|
/// Parsed from <c>docker service logs --timestamps</c> output.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceLogEntry
|
||||||
|
{
|
||||||
|
/// <summary>UTC timestamp of the log entry (from Docker).</summary>
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Service/replica identifier (e.g. "acm-cms-stack_acm-web.1.abc123").</summary>
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The log message text.</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Short service name without the stack prefix (e.g. "acm-web").</summary>
|
||||||
|
public string ServiceName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Formatted display string for binding: "[timestamp] source | message".</summary>
|
||||||
|
public string DisplayLine =>
|
||||||
|
$"[{Timestamp:HH:mm:ss}] {Source} | {Message}";
|
||||||
|
}
|
||||||
@@ -4,4 +4,10 @@ public class TemplateConfig
|
|||||||
{
|
{
|
||||||
public string Yaml { get; set; } = string.Empty;
|
public string Yaml { get; set; } = string.Empty;
|
||||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local filesystem path to the cached git clone.
|
||||||
|
/// Used to access additional template files (e.g. settings-custom.php.template).
|
||||||
|
/// </summary>
|
||||||
|
public string CacheDir { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Bitwarden.Secrets.Sdk" Version="1.0.0" />
|
||||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||||
|
|||||||
1973
OTSSignsOrchestrator.Core/Services/AuthentikService.cs
Normal file
1973
OTSSignsOrchestrator.Core/Services/AuthentikService.cs
Normal file
File diff suppressed because it is too large
Load Diff
230
OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Normal file
230
OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
using Bitwarden.Sdk;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores and retrieves secrets from Bitwarden Secrets Manager via the official Bitwarden C# SDK.
|
||||||
|
///
|
||||||
|
/// Configuration is read from <see cref="BitwardenOptions"/> (bound to appsettings.json → "Bitwarden").
|
||||||
|
///
|
||||||
|
/// The SDK state file is persisted to %APPDATA%/OTSSignsOrchestrator/bitwarden.state
|
||||||
|
/// so the SDK can cache its internal state across restarts.
|
||||||
|
/// </summary>
|
||||||
|
public class BitwardenSecretService : IBitwardenSecretService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IOptionsMonitor<BitwardenOptions> _optionsMonitor;
|
||||||
|
private readonly ILogger<BitwardenSecretService> _logger;
|
||||||
|
|
||||||
|
// Lazily created on first use (per service instance — registered as Transient).
|
||||||
|
private BitwardenClient? _client;
|
||||||
|
private string? _clientAccessToken; // track which token the client was created with
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Always returns the latest config snapshot (reloaded when appsettings.json changes).</summary>
|
||||||
|
private BitwardenOptions Options => _optionsMonitor.CurrentValue;
|
||||||
|
|
||||||
|
public BitwardenSecretService(
|
||||||
|
IOptionsMonitor<BitwardenOptions> optionsMonitor,
|
||||||
|
ILogger<BitwardenSecretService> logger)
|
||||||
|
{
|
||||||
|
_optionsMonitor = optionsMonitor;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// IBitwardenSecretService
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<bool> IsConfiguredAsync()
|
||||||
|
{
|
||||||
|
var opts = Options;
|
||||||
|
var configured = !string.IsNullOrWhiteSpace(opts.AccessToken)
|
||||||
|
&& !string.IsNullOrWhiteSpace(opts.OrganizationId);
|
||||||
|
return Task.FromResult(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
||||||
|
{
|
||||||
|
var client = await GetClientAsync();
|
||||||
|
var orgId = GetOrgId();
|
||||||
|
var projectIds = GetProjectIds();
|
||||||
|
|
||||||
|
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
||||||
|
return result.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateInstanceSecretAsync(string key, string value, string note = "")
|
||||||
|
{
|
||||||
|
var client = await GetClientAsync();
|
||||||
|
var orgId = GetOrgId();
|
||||||
|
var projectIds = GetInstanceProjectIds();
|
||||||
|
|
||||||
|
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden instance secret created: key={Key}, id={Id}, project={Project}",
|
||||||
|
key, result.Id, Options.InstanceProjectId);
|
||||||
|
return result.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
|
||||||
|
{
|
||||||
|
var client = await GetClientAsync();
|
||||||
|
var result = await Task.Run(() => client.Secrets.Get(Guid.Parse(secretId)));
|
||||||
|
|
||||||
|
return new BitwardenSecret
|
||||||
|
{
|
||||||
|
Id = result.Id.ToString(),
|
||||||
|
Key = result.Key,
|
||||||
|
Value = result.Value,
|
||||||
|
Note = result.Note ?? string.Empty,
|
||||||
|
CreationDate = result.CreationDate.DateTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
||||||
|
{
|
||||||
|
var client = await GetClientAsync();
|
||||||
|
var orgId = GetOrgId();
|
||||||
|
var projectIds = GetProjectIds();
|
||||||
|
|
||||||
|
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "")
|
||||||
|
{
|
||||||
|
var client = await GetClientAsync();
|
||||||
|
var orgId = GetOrgId();
|
||||||
|
var projectIds = GetInstanceProjectIds();
|
||||||
|
|
||||||
|
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden instance secret updated: key={Key}, id={Id}, project={Project}",
|
||||||
|
key, secretId, Options.InstanceProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
|
||||||
|
{
|
||||||
|
var client = await GetClientAsync();
|
||||||
|
var orgId = GetOrgId();
|
||||||
|
var result = await Task.Run(() => client.Secrets.List(orgId));
|
||||||
|
|
||||||
|
return result.Data?.Select(s => new BitwardenSecretSummary
|
||||||
|
{
|
||||||
|
Id = s.Id.ToString(),
|
||||||
|
Key = s.Key,
|
||||||
|
CreationDate = DateTime.MinValue
|
||||||
|
}).ToList() ?? new List<BitwardenSecretSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// SDK client initialisation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an authenticated <see cref="BitwardenClient"/>, creating and logging in on first use.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<BitwardenClient> GetClientAsync()
|
||||||
|
{
|
||||||
|
var opts = Options;
|
||||||
|
|
||||||
|
// If credentials changed since the client was created, tear it down so we re-auth
|
||||||
|
if (_client is not null && _clientAccessToken != opts.AccessToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Bitwarden credentials changed — recreating SDK client");
|
||||||
|
_client.Dispose();
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_client is not null)
|
||||||
|
return _client;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(opts.AccessToken))
|
||||||
|
throw new InvalidOperationException("Bitwarden AccessToken is not configured. Set it in Settings → Bitwarden.");
|
||||||
|
|
||||||
|
var accessToken = opts.AccessToken;
|
||||||
|
var apiUrl = (opts.ApiUrl ?? "https://api.bitwarden.com").TrimEnd('/');
|
||||||
|
var identityUrl = (opts.IdentityUrl ?? "https://identity.bitwarden.com").TrimEnd('/');
|
||||||
|
|
||||||
|
var sdkSettings = new BitwardenSettings { ApiUrl = apiUrl, IdentityUrl = identityUrl };
|
||||||
|
var client = new BitwardenClient(sdkSettings);
|
||||||
|
|
||||||
|
await Task.Run(() => client.Auth.LoginAccessToken(accessToken, GetStateFilePath()));
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden SDK client initialised and authenticated.");
|
||||||
|
_client = client;
|
||||||
|
_clientAccessToken = accessToken;
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Guid GetOrgId()
|
||||||
|
{
|
||||||
|
var orgId = Options.OrganizationId;
|
||||||
|
if (string.IsNullOrWhiteSpace(orgId))
|
||||||
|
throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||||
|
return Guid.Parse(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid[] GetProjectIds()
|
||||||
|
{
|
||||||
|
var projectId = Options.ProjectId;
|
||||||
|
if (string.IsNullOrWhiteSpace(projectId))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Bitwarden ProjectId is required. Set it in Settings → Bitwarden.");
|
||||||
|
return new[] { Guid.Parse(projectId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the project IDs array for instance-level secrets.
|
||||||
|
/// Uses <see cref="BitwardenOptions.InstanceProjectId"/> when configured,
|
||||||
|
/// otherwise falls back to the default <see cref="BitwardenOptions.ProjectId"/>.
|
||||||
|
/// </summary>
|
||||||
|
private Guid[] GetInstanceProjectIds()
|
||||||
|
{
|
||||||
|
var instanceProjectId = Options.InstanceProjectId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(instanceProjectId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Using instance Bitwarden project: {ProjectId}", instanceProjectId);
|
||||||
|
return new[] { Guid.Parse(instanceProjectId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the default config project
|
||||||
|
return GetProjectIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the path where the SDK stores its state between sessions.
|
||||||
|
/// Stored under %APPDATA%/OTSSignsOrchestrator/bitwarden.state.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetStateFilePath()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"OTSSignsOrchestrator");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return Path.Combine(dir, "bitwarden.state");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// IDisposable
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_client?.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ namespace OTSSignsOrchestrator.Core.Services;
|
|||||||
/// Renders a Docker Compose file by loading a template from the git repo and substituting
|
/// Renders a Docker Compose file by loading a template from the git repo and substituting
|
||||||
/// all {{PLACEHOLDER}} tokens with values from RenderContext.
|
/// all {{PLACEHOLDER}} tokens with values from RenderContext.
|
||||||
/// The template file expected in the repo is <c>template.yml</c>.
|
/// The template file expected in the repo is <c>template.yml</c>.
|
||||||
/// Call <see cref="GetTemplateYaml"/> to obtain the canonical template to commit to your repo.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ComposeRenderService
|
public class ComposeRenderService
|
||||||
{
|
{
|
||||||
@@ -111,7 +110,10 @@ public class ComposeRenderService
|
|||||||
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
|
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
|
||||||
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
|
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
|
||||||
var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
||||||
return $":/{path}";
|
// When path is empty the prefix must be ":" with no trailing slash — the template
|
||||||
|
// already supplies the leading "/" before {{ABBREV}}, so ":" + "/ots/..." = ":/ots/..."
|
||||||
|
// (correct). Returning ":/" would produce "://ots/..." which Docker rejects.
|
||||||
|
return string.IsNullOrEmpty(path) ? ":" : $":/{path}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -145,148 +147,6 @@ public class ComposeRenderService
|
|||||||
return folders;
|
return folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the canonical <c>template.yml</c> content with all placeholders.
|
|
||||||
/// Commit this file to the root of your template git repository.
|
|
||||||
/// </summary>
|
|
||||||
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"
|
|
||||||
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}}
|
|
||||||
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
|
|
||||||
""";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
/// <summary>Context object with all inputs needed to render a Compose file.</summary>
|
||||||
|
|||||||
@@ -49,14 +49,15 @@ public class GitTemplateService
|
|||||||
var yamlPath = FindFile(cacheDir, "template.yml");
|
var yamlPath = FindFile(cacheDir, "template.yml");
|
||||||
|
|
||||||
if (yamlPath == null)
|
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);
|
var yaml = await File.ReadAllTextAsync(yamlPath);
|
||||||
|
|
||||||
return new TemplateConfig
|
return new TemplateConfig
|
||||||
{
|
{
|
||||||
Yaml = yaml,
|
Yaml = yaml,
|
||||||
FetchedAt = DateTime.UtcNow
|
FetchedAt = DateTime.UtcNow,
|
||||||
|
CacheDir = cacheDir,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
OTSSignsOrchestrator.Core/Services/IAuthentikService.cs
Normal file
126
OTSSignsOrchestrator.Core/Services/IAuthentikService.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provisions SAML applications in Authentik and retrieves IdP metadata
|
||||||
|
/// needed to render the Xibo SAML settings-custom.php template.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuthentikService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<AuthentikSamlConfig> ProvisionSamlAsync(
|
||||||
|
string instanceAbbrev,
|
||||||
|
string instanceBaseUrl,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests the connection to Authentik by fetching the current user.
|
||||||
|
/// Optionally accepts override URL/key for testing before saving.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, string Message)> TestConnectionAsync(
|
||||||
|
string? overrideUrl = null,
|
||||||
|
string? overrideApiKey = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all available flows from Authentik.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<AuthentikFlowItem>> ListFlowsAsync(
|
||||||
|
string? overrideUrl = null,
|
||||||
|
string? overrideApiKey = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all certificate keypairs from Authentik.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<AuthentikKeypairItem>> ListKeypairsAsync(
|
||||||
|
string? overrideUrl = null,
|
||||||
|
string? overrideApiKey = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all groups from Authentik, optionally filtered to those with
|
||||||
|
/// at least one member. Used for syncing groups to Xibo instances.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<AuthentikGroupItem>> ListGroupsAsync(
|
||||||
|
string? overrideUrl = null,
|
||||||
|
string? overrideApiKey = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Customer invitation infrastructure
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a group in Authentik with the given name.
|
||||||
|
/// Returns the group PK (UUID string). If a group with that name already exists, returns its PK.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> CreateGroupAsync(string groupName, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an invitation stage in Authentik.
|
||||||
|
/// Returns the stage PK. If a stage with that name already exists, returns its PK.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> CreateInvitationStageAsync(string stageName, bool continueWithoutInvitation = false, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an enrollment flow in Authentik with the given name and slug.
|
||||||
|
/// Returns the flow PK (UUID string). If a flow with that slug already exists, returns its PK.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> CreateEnrollmentFlowAsync(string name, string slug, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a stage to a flow at the specified order.
|
||||||
|
/// </summary>
|
||||||
|
Task BindStageToFlowAsync(string flowSlug, string stagePk, int order, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an expression policy in Authentik.
|
||||||
|
/// Returns the policy PK. If a policy with that name already exists, returns its PK.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> CreateExpressionPolicyAsync(string name, string expression, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a policy to a flow stage binding (so it executes when that stage runs).
|
||||||
|
/// </summary>
|
||||||
|
Task BindPolicyToFlowStageBoundAsync(string flowStageBindingPk, string policyPk, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a policy to a flow (policy/group/user binding tab).
|
||||||
|
/// </summary>
|
||||||
|
Task BindPolicyToFlowAsync(string flowSlug, string policyPk, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a role in Authentik with the given name.
|
||||||
|
/// Returns the role PK. If a role with that name already exists, returns its PK.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> CreateRoleAsync(string roleName, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assigns a set of permissions to a role.
|
||||||
|
/// Permission codenames follow Django format, e.g. "add_invitation", "view_invitation".
|
||||||
|
/// </summary>
|
||||||
|
Task AssignPermissionsToRoleAsync(string rolePk, IEnumerable<string> permissionCodenames, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assigns a role to a group so all members of the group inherit the role's permissions.
|
||||||
|
/// </summary>
|
||||||
|
Task AssignRoleToGroupAsync(string rolePk, string groupPk, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the flow-stage binding PK for a specific stage bound to a flow at a given order.
|
||||||
|
/// Returns null if not found.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> GetFlowStageBindingPkAsync(string flowSlug, int order, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a built-in Authentik stage by partial name match (e.g. "default-enrollment-prompt").
|
||||||
|
/// Returns the stage PK, or null if not found.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> FindStageByNameAsync(string nameContains, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction for storing and retrieving secrets via Bitwarden Secrets Manager.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBitwardenSecretService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if Bitwarden is configured (access token + org ID are set).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> IsConfiguredAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new secret in the configured Bitwarden project.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The ID of the created secret.</returns>
|
||||||
|
Task<string> CreateSecretAsync(string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new secret in the instance Bitwarden project (falls back to default project if not configured).
|
||||||
|
/// Use this for instance-level secrets such as DB passwords and Newt credentials.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The ID of the created secret.</returns>
|
||||||
|
Task<string> CreateInstanceSecretAsync(string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a secret by its Bitwarden ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<BitwardenSecret> GetSecretAsync(string secretId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the value of an existing secret in place.
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the value of an existing instance-level secret in place (uses instance project if configured).
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all secrets in the configured project.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<BitwardenSecretSummary>> ListSecretsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BitwardenSecret
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
public string Note { get; set; } = string.Empty;
|
||||||
|
public DateTime CreationDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BitwardenSecretSummary
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public DateTime CreationDate { get; set; }
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ using OTSSignsOrchestrator.Core.Models.DTOs;
|
|||||||
|
|
||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
// Re-export for convenience so consumers only need one using
|
||||||
|
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
|
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
|
||||||
/// Implementations may use local docker CLI or SSH-based remote execution.
|
/// Implementations may use local docker CLI or SSH-based remote execution.
|
||||||
@@ -87,6 +90,29 @@ public interface IDockerCliService
|
|||||||
/// <paramref name="oldSecretName"/> when null, keeping the same /run/secrets/ filename).
|
/// <paramref name="oldSecretName"/> when null, keeping the same /run/secrets/ filename).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null);
|
Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the last <paramref name="tailLines"/> log lines from a Docker Swarm service.
|
||||||
|
/// If <paramref name="serviceName"/> is null, fetches logs from all services in the stack.
|
||||||
|
/// Returns parsed log entries sorted by timestamp ascending.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nfsServer">NFS server hostname or IP.</param>
|
||||||
|
/// <param name="nfsExport">NFS export path (e.g. "/srv/nfs").</param>
|
||||||
|
/// <param name="relativePath">Path relative to the export root (e.g. "subfolder/abbrev/cms-custom/settings-custom.php").</param>
|
||||||
|
/// <param name="content">File content to write.</param>
|
||||||
|
/// <param name="nfsExportFolder">Optional subfolder within the export.</param>
|
||||||
|
Task<(bool Success, string? Error)> WriteFileToNfsAsync(
|
||||||
|
string nfsServer,
|
||||||
|
string nfsExport,
|
||||||
|
string relativePath,
|
||||||
|
string content,
|
||||||
|
string? nfsExportFolder = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StackInfo
|
public class StackInfo
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates the complete Authentik invitation infrastructure setup for a customer.
|
||||||
|
/// Creates a group, enrollment flow with stages, role with invitation permissions,
|
||||||
|
/// and scoping policies so the customer admin can invite new users without OTS involvement.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInvitationSetupService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sets up the full invitation infrastructure for a customer in Authentik:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Create customer group (e.g. <c>customer-acme</c>).</item>
|
||||||
|
/// <item>Create invitation stage (invite-only, no anonymous enrollment).</item>
|
||||||
|
/// <item>Create enrollment flow with stages: Invitation → Prompt → UserWrite → UserLogin.</item>
|
||||||
|
/// <item>Bind expression policy to UserWrite stage to auto-assign users to the customer group.</item>
|
||||||
|
/// <item>Create invite-manager role with invitation CRUD permissions.</item>
|
||||||
|
/// <item>Assign role to customer group and bind scoping policy to flow.</item>
|
||||||
|
/// </list>
|
||||||
|
/// All operations are idempotent — safe to call multiple times for the same customer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="customerAbbrev">Short customer identifier (e.g. "acme").</param>
|
||||||
|
/// <param name="customerName">Human-readable customer name (e.g. "Acme Corp").</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>Result describing what was created and the enrollment flow URL.</returns>
|
||||||
|
Task<InvitationSetupResult> SetupCustomerInvitationAsync(
|
||||||
|
string customerAbbrev,
|
||||||
|
string customerName,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of the invitation infrastructure setup.
|
||||||
|
/// </summary>
|
||||||
|
public class InvitationSetupResult
|
||||||
|
{
|
||||||
|
/// <summary>Whether the setup completed successfully.</summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable status message.</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Name of the customer group created in Authentik.</summary>
|
||||||
|
public string GroupName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Slug of the enrollment flow (used in invite links).</summary>
|
||||||
|
public string EnrollmentFlowSlug { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Name of the role created for invitation management.</summary>
|
||||||
|
public string RoleName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full URL to the Authentik user portal where the customer admin
|
||||||
|
/// can manage invitations.
|
||||||
|
/// </summary>
|
||||||
|
public string? InvitationManagementUrl { get; set; }
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public class InstanceService
|
|||||||
private readonly IDockerSecretsService _secrets;
|
private readonly IDockerSecretsService _secrets;
|
||||||
private readonly XiboApiService _xibo;
|
private readonly XiboApiService _xibo;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly PostInstanceInitService _postInit;
|
||||||
private readonly DockerOptions _dockerOptions;
|
private readonly DockerOptions _dockerOptions;
|
||||||
private readonly ILogger<InstanceService> _logger;
|
private readonly ILogger<InstanceService> _logger;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ public class InstanceService
|
|||||||
IDockerSecretsService secrets,
|
IDockerSecretsService secrets,
|
||||||
XiboApiService xibo,
|
XiboApiService xibo,
|
||||||
SettingsService settings,
|
SettingsService settings,
|
||||||
|
PostInstanceInitService postInit,
|
||||||
IOptions<DockerOptions> dockerOptions,
|
IOptions<DockerOptions> dockerOptions,
|
||||||
ILogger<InstanceService> logger)
|
ILogger<InstanceService> logger)
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,7 @@ public class InstanceService
|
|||||||
_secrets = secrets;
|
_secrets = secrets;
|
||||||
_xibo = xibo;
|
_xibo = xibo;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_postInit = postInit;
|
||||||
_dockerOptions = dockerOptions.Value;
|
_dockerOptions = dockerOptions.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -96,10 +99,22 @@ public class InstanceService
|
|||||||
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
|
||||||
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
|
||||||
|
|
||||||
// ── 1b. Remove any stale stack that might hold references to old secrets ─
|
// ── 1b. Remove stale stack (and optionally its cached volumes) ─────
|
||||||
_logger.LogInformation("Removing stale stack (if any): {StackName}", stackName);
|
// docker stack rm alone leaves named volumes behind; those volumes
|
||||||
await _docker.RemoveStackAsync(stackName);
|
// retain their old driver_opts and Docker re-uses them on the next
|
||||||
await Task.Delay(2000);
|
// deploy, ignoring the new (correct) options in the compose file.
|
||||||
|
// PurgeStaleVolumes must be explicitly opted into to avoid accidental data loss.
|
||||||
|
if (dto.PurgeStaleVolumes)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Purging stale stack and volumes (PurgeStaleVolumes=true): {StackName}", stackName);
|
||||||
|
await _docker.RemoveStackVolumesAsync(stackName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Removing stale stack (if any): {StackName}", stackName);
|
||||||
|
await _docker.RemoveStackAsync(stackName);
|
||||||
|
await Task.Delay(2000);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2. Generate MySQL credentials ──────────────────────────────
|
// ── 2. Generate MySQL credentials ──────────────────────────────
|
||||||
var mysqlPassword = GenerateRandomPassword(32);
|
var mysqlPassword = GenerateRandomPassword(32);
|
||||||
@@ -126,12 +141,11 @@ public class InstanceService
|
|||||||
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
|
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
|
||||||
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
|
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
|
||||||
|
|
||||||
// ── 2c. Persist password (encrypted) for future redeploys ────────
|
// ── 2c. Persist password for future redeploys ────────
|
||||||
await _settings.SetAsync(
|
await _settings.SetAsync(
|
||||||
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
|
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
|
||||||
SettingsService.CatInstance, isSensitive: true);
|
SettingsService.CatInstance, isSensitive: true);
|
||||||
await _db.SaveChangesAsync();
|
_logger.LogInformation("MySQL password stored in Bitwarden for instance {Abbrev}", abbrev);
|
||||||
_logger.LogInformation("MySQL password stored in settings for instance {Abbrev}", abbrev);
|
|
||||||
|
|
||||||
// ── 3. Read settings ────────────────────────────────────────────
|
// ── 3. Read settings ────────────────────────────────────────────
|
||||||
var mySqlHost = mySqlHostValue;
|
var mySqlHost = mySqlHostValue;
|
||||||
@@ -139,7 +153,7 @@ public class InstanceService
|
|||||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||||
var mySqlUser = mySqlUserName;
|
var mySqlUser = mySqlUserName;
|
||||||
|
|
||||||
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||||
var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||||
|
|
||||||
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||||
@@ -235,11 +249,13 @@ public class InstanceService
|
|||||||
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
|
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Remove stale NFS volumes ─────────────────────────────────
|
// ── 5c. Write settings-custom.php to NFS volume (SAML config) ────────
|
||||||
_logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName);
|
// This must happen before the stack is deployed so Xibo starts with SAML
|
||||||
await _docker.RemoveStackVolumesAsync(stackName);
|
// authentication already configured.
|
||||||
|
var instanceUrlForSaml = $"https://{cmsServerName}/{abbrev}";
|
||||||
|
await _postInit.DeploySamlConfigurationAsync(abbrev, instanceUrlForSaml, _settings, dto.CustomerName);
|
||||||
|
|
||||||
// ── 7. Deploy stack ─────────────────────────────────────────────
|
// ── 6. Deploy stack ─────────────────────────────────────────────
|
||||||
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
|
||||||
if (!deployResult.Success)
|
if (!deployResult.Success)
|
||||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||||
@@ -255,8 +271,15 @@ public class InstanceService
|
|||||||
|
|
||||||
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
// ── 7. Return result — post-init will be triggered by the UI ──────
|
||||||
|
// after the user creates an OAuth2 app in the Xibo web UI and supplies
|
||||||
|
// the client_id and client_secret.
|
||||||
|
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
|
||||||
|
|
||||||
|
deployResult.InstanceUrl = instanceUrl;
|
||||||
|
deployResult.Abbrev = abbrev;
|
||||||
deployResult.ServiceCount = 4;
|
deployResult.ServiceCount = 4;
|
||||||
deployResult.Message = "Instance deployed successfully.";
|
deployResult.Message = "Instance deployed successfully. Complete post-install setup by providing OAuth credentials.";
|
||||||
return deployResult;
|
return deployResult;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -324,7 +347,7 @@ public class InstanceService
|
|||||||
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||||
|
|
||||||
var cmsServerName = dto.CmsServerName
|
var cmsServerName = dto.CmsServerName
|
||||||
?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
?? (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||||
var hostHttpPort = dto.HostHttpPort ?? 80;
|
var hostHttpPort = dto.HostHttpPort ?? 80;
|
||||||
var themePath = dto.ThemeHostPath
|
var themePath = dto.ThemeHostPath
|
||||||
?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
?? (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||||
|
|||||||
230
OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs
Normal file
230
OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates the 6-step Authentik invitation infrastructure setup for a customer.
|
||||||
|
/// All operations are idempotent — the underlying Authentik API methods check for
|
||||||
|
/// existing resources before creating new ones.
|
||||||
|
///
|
||||||
|
/// Per customer, this creates:
|
||||||
|
/// 1. A group (<c>customer-{abbrev}</c>)
|
||||||
|
/// 2. An invitation stage (<c>{abbrev}-invitation-stage</c>)
|
||||||
|
/// 3. An enrollment flow (<c>{abbrev}-enrollment</c>) with stages bound in order
|
||||||
|
/// 4. An expression policy on the UserWrite stage to auto-assign users to the group
|
||||||
|
/// 5. A role (<c>{abbrev}-invite-manager</c>) with invitation CRUD permissions
|
||||||
|
/// 6. A scoping policy on the flow so only group members can access it
|
||||||
|
/// </summary>
|
||||||
|
public class InvitationSetupService : IInvitationSetupService
|
||||||
|
{
|
||||||
|
private readonly IAuthentikService _authentik;
|
||||||
|
private readonly SettingsService _settings;
|
||||||
|
private readonly ILogger<InvitationSetupService> _logger;
|
||||||
|
|
||||||
|
public InvitationSetupService(
|
||||||
|
IAuthentikService authentik,
|
||||||
|
SettingsService settings,
|
||||||
|
ILogger<InvitationSetupService> logger)
|
||||||
|
{
|
||||||
|
_authentik = authentik;
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<InvitationSetupResult> SetupCustomerInvitationAsync(
|
||||||
|
string customerAbbrev,
|
||||||
|
string customerName,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var abbrev = customerAbbrev.Trim().ToLowerInvariant();
|
||||||
|
var groupName = $"customer-{abbrev}";
|
||||||
|
var flowSlug = $"{abbrev}-enrollment";
|
||||||
|
var flowName = $"{customerName} Enrollment";
|
||||||
|
var roleName = $"{abbrev}-invite-manager";
|
||||||
|
var invitationStageName = $"{abbrev}-invitation-stage";
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[InviteSetup] Starting invitation infrastructure setup for customer '{Customer}' (abbrev={Abbrev})",
|
||||||
|
customerName, abbrev);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Step 1: Create customer group
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
_logger.LogInformation("[InviteSetup] Step 1/6: Creating customer group '{Group}'", groupName);
|
||||||
|
var groupPk = await _authentik.CreateGroupAsync(groupName, ct);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Step 2: Create invitation stage
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
_logger.LogInformation("[InviteSetup] Step 2/6: Creating invitation stage '{Stage}'", invitationStageName);
|
||||||
|
var invitationStagePk = await _authentik.CreateInvitationStageAsync(
|
||||||
|
invitationStageName, continueWithoutInvitation: false, ct);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Step 3: Create enrollment flow and bind stages
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
_logger.LogInformation("[InviteSetup] Step 3/6: Creating enrollment flow '{Slug}'", flowSlug);
|
||||||
|
var flowPk = await _authentik.CreateEnrollmentFlowAsync(flowName, flowSlug, ct);
|
||||||
|
|
||||||
|
// Resolve built-in Authentik stages for the enrollment pipeline
|
||||||
|
var promptStagePk = await _authentik.FindStageByNameAsync("default-enrollment-prompt", ct);
|
||||||
|
var userWriteStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-write", ct);
|
||||||
|
var userLoginStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-login", ct);
|
||||||
|
|
||||||
|
// Bind stages in order: 10=Invitation, 20=Prompt, 30=UserWrite, 40=UserLogin
|
||||||
|
await _authentik.BindStageToFlowAsync(flowSlug, invitationStagePk, 10, ct);
|
||||||
|
|
||||||
|
if (promptStagePk != null)
|
||||||
|
{
|
||||||
|
await _authentik.BindStageToFlowAsync(flowSlug, promptStagePk, 20, ct);
|
||||||
|
_logger.LogInformation("[InviteSetup] Bound default prompt stage at order 20");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-prompt' stage not found — " +
|
||||||
|
"you may need to create a prompt stage manually and bind it to flow '{Slug}' at order 20", flowSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userWriteStagePk != null)
|
||||||
|
{
|
||||||
|
await _authentik.BindStageToFlowAsync(flowSlug, userWriteStagePk, 30, ct);
|
||||||
|
_logger.LogInformation("[InviteSetup] Bound default user-write stage at order 30");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-write' stage not found — " +
|
||||||
|
"you may need to create a user-write stage manually and bind it to flow '{Slug}' at order 30", flowSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userLoginStagePk != null)
|
||||||
|
{
|
||||||
|
await _authentik.BindStageToFlowAsync(flowSlug, userLoginStagePk, 40, ct);
|
||||||
|
_logger.LogInformation("[InviteSetup] Bound default user-login stage at order 40");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-login' stage not found — " +
|
||||||
|
"you may need to create a user-login stage manually and bind it to flow '{Slug}' at order 40", flowSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Step 4: Create group-assignment policy and bind to UserWrite stage
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
_logger.LogInformation("[InviteSetup] Step 4/6: Creating group-assignment expression policy");
|
||||||
|
|
||||||
|
var groupAssignPolicyName = $"{abbrev}-auto-assign-group";
|
||||||
|
var groupAssignExpression = $"""
|
||||||
|
from authentik.core.models import Group
|
||||||
|
|
||||||
|
# Auto-assign to customer group on registration
|
||||||
|
group = Group.objects.filter(name="{groupName}").first()
|
||||||
|
if group and context.get("pending_user"):
|
||||||
|
context["pending_user"].ak_groups.add(group)
|
||||||
|
|
||||||
|
return True
|
||||||
|
""";
|
||||||
|
|
||||||
|
var groupAssignPolicyPk = await _authentik.CreateExpressionPolicyAsync(
|
||||||
|
groupAssignPolicyName, groupAssignExpression, ct);
|
||||||
|
|
||||||
|
// Bind policy to the UserWrite stage binding (order 30)
|
||||||
|
if (userWriteStagePk != null)
|
||||||
|
{
|
||||||
|
var userWriteBindingPk = await _authentik.GetFlowStageBindingPkAsync(flowSlug, 30, ct);
|
||||||
|
if (userWriteBindingPk != null)
|
||||||
|
{
|
||||||
|
await _authentik.BindPolicyToFlowStageBoundAsync(userWriteBindingPk, groupAssignPolicyPk, ct);
|
||||||
|
_logger.LogInformation("[InviteSetup] Group-assignment policy bound to UserWrite stage");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[InviteSetup] Could not find flow-stage binding at order 30 to attach group policy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Step 5: Create invite-manager role with permissions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
_logger.LogInformation("[InviteSetup] Step 5/6: Creating invite-manager role '{Role}'", roleName);
|
||||||
|
var rolePk = await _authentik.CreateRoleAsync(roleName, ct);
|
||||||
|
|
||||||
|
// Assign invitation CRUD permissions
|
||||||
|
var invitationPermissions = new[]
|
||||||
|
{
|
||||||
|
"add_invitation",
|
||||||
|
"view_invitation",
|
||||||
|
"delete_invitation",
|
||||||
|
};
|
||||||
|
await _authentik.AssignPermissionsToRoleAsync(rolePk, invitationPermissions, ct);
|
||||||
|
|
||||||
|
// Assign role to the customer group
|
||||||
|
await _authentik.AssignRoleToGroupAsync(rolePk, groupPk, ct);
|
||||||
|
_logger.LogInformation("[InviteSetup] Role '{Role}' assigned to group '{Group}'", roleName, groupName);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Step 6: Create scoping policy and bind to flow
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
_logger.LogInformation("[InviteSetup] Step 6/6: Creating invitation scoping policy");
|
||||||
|
|
||||||
|
var scopePolicyName = $"scope-invitations-to-{abbrev}";
|
||||||
|
var scopeExpression = $"""
|
||||||
|
# Only allow users in {groupName} group to manage these invitations
|
||||||
|
user = context.get("pending_user") or request.user
|
||||||
|
|
||||||
|
if user.ak_groups.filter(name="{groupName}").exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scopePolicyPk = await _authentik.CreateExpressionPolicyAsync(scopePolicyName, scopeExpression, ct);
|
||||||
|
|
||||||
|
// Bind scoping policy to the enrollment flow
|
||||||
|
await _authentik.BindPolicyToFlowAsync(flowSlug, scopePolicyPk, ct);
|
||||||
|
_logger.LogInformation("[InviteSetup] Scoping policy bound to enrollment flow");
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Build result with management URL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl);
|
||||||
|
var managementUrl = !string.IsNullOrWhiteSpace(authentikUrl)
|
||||||
|
? $"{authentikUrl.TrimEnd('/')}/if/admin/#/core/invitations"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var result = new InvitationSetupResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = $"Invitation infrastructure created for {customerName}. " +
|
||||||
|
$"Group: {groupName}, Flow: {flowSlug}, Role: {roleName}.",
|
||||||
|
GroupName = groupName,
|
||||||
|
EnrollmentFlowSlug = flowSlug,
|
||||||
|
RoleName = roleName,
|
||||||
|
InvitationManagementUrl = managementUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[InviteSetup] Setup complete for '{Customer}': group={Group}, flow={Flow}, role={Role}, url={Url}",
|
||||||
|
customerName, groupName, flowSlug, roleName, managementUrl ?? "(no URL)");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"[InviteSetup] Invitation setup failed for '{Customer}' (abbrev={Abbrev}): {Message}",
|
||||||
|
customerName, abbrev, ex.Message);
|
||||||
|
|
||||||
|
return new InvitationSetupResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = $"Invitation setup failed: {ex.Message}",
|
||||||
|
GroupName = groupName,
|
||||||
|
EnrollmentFlowSlug = flowSlug,
|
||||||
|
RoleName = roleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
764
OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Normal file
764
OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Waits for the Xibo web service to become available.</item>
|
||||||
|
/// <item>Authenticates using the OAuth2 application credentials supplied by the user.</item>
|
||||||
|
/// <item>Creates the OTS admin user with a random password.</item>
|
||||||
|
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
|
||||||
|
/// <item>Activates the <c>otssigns</c> theme.</item>
|
||||||
|
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
|
||||||
|
/// <item>Deletes the default <c>xibo_admin</c> account.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// The user must first create an OAuth2 application (client_credentials) in the
|
||||||
|
/// Xibo web UI using the default <c>xibo_admin / password</c> account that ships
|
||||||
|
/// with every new Xibo CMS instance.
|
||||||
|
///
|
||||||
|
/// Invoked from the Create Instance UI after the user supplies the OAuth credentials.
|
||||||
|
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
|
||||||
|
/// </summary>
|
||||||
|
/// </summary>
|
||||||
|
public class PostInstanceInitService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<PostInstanceInitService> _logger;
|
||||||
|
|
||||||
|
// How long to wait for Xibo to become healthy before aborting post-init.
|
||||||
|
private static readonly TimeSpan XiboReadinessTimeout = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public PostInstanceInitService(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<PostInstanceInitService> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Entry point
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the post-instance initialisation sequence.
|
||||||
|
/// Called from the UI after the user supplies OAuth2 client credentials.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RunAsync(
|
||||||
|
string abbrev,
|
||||||
|
string instanceUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
|
||||||
|
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
|
||||||
|
if (!ready)
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
||||||
|
|
||||||
|
// ── 2. Authenticate with user-supplied OAuth2 credentials ─────────
|
||||||
|
_logger.LogInformation("[PostInit] Obtaining access token via client_credentials");
|
||||||
|
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
|
||||||
|
|
||||||
|
// ── 3. Generate credentials ───────────────────────────────────────
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var adminPassword = GeneratePassword(24);
|
||||||
|
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
|
||||||
|
|
||||||
|
// ── 4. Create OTS admin group ─────────────────────────────────────
|
||||||
|
var adminGroupName = $"ots-admins-{abbrev}";
|
||||||
|
_logger.LogInformation("[PostInit] Creating OTS admin group '{GroupName}'", adminGroupName);
|
||||||
|
var adminGroupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, adminGroupName);
|
||||||
|
|
||||||
|
// ── 5. Create OTS admin user ──────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername);
|
||||||
|
int userId = await xibo.CreateAdminUserAsync(
|
||||||
|
instanceUrl, accessToken,
|
||||||
|
adminUsername, adminPassword, adminEmail, adminGroupId);
|
||||||
|
|
||||||
|
// ── 5a. Assign admin user to OTS admin group ──────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Assigning '{Username}' to group '{GroupName}'", adminUsername, adminGroupName);
|
||||||
|
await xibo.AssignUserToGroupAsync(instanceUrl, accessToken, adminGroupId, userId);
|
||||||
|
|
||||||
|
// ── 6. Register dedicated OAuth2 application for OTS ──────────────
|
||||||
|
_logger.LogInformation("[PostInit] Registering OTS OAuth2 application");
|
||||||
|
var (otsClientId, otsClientSecret) = await xibo.RegisterOAuthClientAsync(
|
||||||
|
instanceUrl, accessToken,
|
||||||
|
$"OTS Signs — {abbrev.ToUpperInvariant()}");
|
||||||
|
|
||||||
|
// ── 6. Set theme ──────────────────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
||||||
|
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||||
|
|
||||||
|
// ── 7. Store credentials in Bitwarden ─────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
|
|
||||||
|
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
|
value: adminPassword,
|
||||||
|
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
||||||
|
|
||||||
|
var oauthSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-oauth-secret",
|
||||||
|
value: otsClientSecret,
|
||||||
|
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {otsClientId}");
|
||||||
|
|
||||||
|
// Persist Bitwarden secret IDs + OAuth client ID as config settings
|
||||||
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), otsClientId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
|
||||||
|
// ── 8. Remove the default xibo_admin account ──────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Removing default xibo_admin user");
|
||||||
|
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
|
||||||
|
await xibo.DeleteUserAsync(instanceUrl, accessToken, xiboAdminId);
|
||||||
|
_logger.LogInformation("[PostInit] xibo_admin user removed (userId={UserId})", xiboAdminId);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
|
||||||
|
abbrev, adminUsername, otsClientId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||||
|
throw; // Propagate to calling UI so the user sees the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Initialise using caller-supplied OAuth credentials (no new app registration)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user.
|
||||||
|
/// Unlike <see cref="RunAsync"/>, this method does NOT register a new OAuth application;
|
||||||
|
/// instead it stores the caller-supplied credentials for future API operations.
|
||||||
|
/// Steps: wait → authenticate → create OTS admin → set theme → remove xibo_admin → store credentials.
|
||||||
|
/// </summary>
|
||||||
|
public async Task InitializeWithOAuthAsync(
|
||||||
|
string abbrev,
|
||||||
|
string instanceUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Starting initialisation for {Abbrev} ({Url})", abbrev, instanceUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Waiting for Xibo at {Url}...", instanceUrl);
|
||||||
|
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
|
||||||
|
if (!ready)
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"Xibo at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
||||||
|
|
||||||
|
// ── 2. Authenticate with caller-supplied OAuth2 credentials ───────
|
||||||
|
_logger.LogInformation("[PostInit] Obtaining access token");
|
||||||
|
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
|
||||||
|
|
||||||
|
// ── 3. Generate OTS admin credentials ─────────────────────────────
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var adminPassword = GeneratePassword(24);
|
||||||
|
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
|
||||||
|
|
||||||
|
// ── 4. Rename built-in xibo_admin to OTS admin ───────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Looking up xibo_admin user");
|
||||||
|
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
|
||||||
|
_logger.LogInformation("[PostInit] Updating xibo_admin (id={Id}) → '{Username}'", xiboAdminId, adminUsername);
|
||||||
|
await xibo.UpdateUserAsync(instanceUrl, accessToken, xiboAdminId, adminUsername, adminPassword, adminEmail);
|
||||||
|
|
||||||
|
// ── 5. Set theme ──────────────────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
|
||||||
|
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
|
||||||
|
|
||||||
|
// ── 6. Store admin password in Bitwarden ──────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
|
var adminSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
|
value: adminPassword,
|
||||||
|
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
||||||
|
|
||||||
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
|
||||||
|
// ── 7. Store caller-supplied OAuth credentials in Bitwarden ───────
|
||||||
|
var oauthSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-oauth-secret",
|
||||||
|
value: clientSecret,
|
||||||
|
note: $"Xibo CMS OAuth2 client secret for instance {abbrev}. ClientId: {clientId}");
|
||||||
|
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), clientId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
_logger.LogInformation("[PostInit] xibo_admin removed");
|
||||||
|
|
||||||
|
_logger.LogInformation("[PostInit] Initialisation complete for {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[PostInit] Initialisation failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Credential rotation (called from the UI)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> RotateAdminPasswordAsync(string abbrev, string instanceUrl)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev);
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var newPassword = GeneratePassword(24);
|
||||||
|
|
||||||
|
// Log in using the stored OTS OAuth2 client credentials
|
||||||
|
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
|
||||||
|
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No OAuth credentials found for instance '{abbrev}'. Was post-init completed?");
|
||||||
|
|
||||||
|
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
|
||||||
|
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
|
||||||
|
|
||||||
|
// Look up the OTS admin user ID
|
||||||
|
var userId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, adminUsername);
|
||||||
|
|
||||||
|
await xibo.RotateUserPasswordAsync(instanceUrl, accessToken, userId, newPassword);
|
||||||
|
|
||||||
|
// Update Bitwarden secret
|
||||||
|
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
|
if (!string.IsNullOrWhiteSpace(secretId))
|
||||||
|
{
|
||||||
|
await bws.UpdateInstanceSecretAsync(secretId,
|
||||||
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
|
value: newPassword,
|
||||||
|
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Secret doesn't exist yet in Bitwarden — create it now
|
||||||
|
var newSecretId = await bws.CreateInstanceSecretAsync(
|
||||||
|
$"{abbrev}/xibo-admin-password", newPassword,
|
||||||
|
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||||
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
|
||||||
|
return newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the stored admin password for an instance from Bitwarden.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
|
||||||
|
string? adminPassword = null;
|
||||||
|
string? oauthClientSecret = null;
|
||||||
|
|
||||||
|
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
|
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var secret = await bws.GetSecretAsync(adminSecretId);
|
||||||
|
adminPassword = secret.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (!string.IsNullOrWhiteSpace(oauthSecretId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var secret = await bws.GetSecretAsync(oauthSecretId);
|
||||||
|
oauthClientSecret = secret.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InstanceCredentials
|
||||||
|
{
|
||||||
|
AdminUsername = adminUsername,
|
||||||
|
AdminPassword = adminPassword,
|
||||||
|
OAuthClientId = oauthClientId,
|
||||||
|
OAuthClientSecret = oauthClientSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// SAML configuration deployment
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// The template is resolved from (a) the git repo cache, or (b) the local bundled
|
||||||
|
/// <c>templates/</c> directory shipped with the application.
|
||||||
|
/// Errors are logged but do not fail the overall deployment.
|
||||||
|
/// </summary>
|
||||||
|
public async Task DeploySamlConfigurationAsync(
|
||||||
|
string abbrev,
|
||||||
|
string instanceUrl,
|
||||||
|
SettingsService settings,
|
||||||
|
string? customerName = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Deploying SAML settings-custom.php for {Abbrev}", abbrev);
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||||
|
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||||||
|
var docker = scope.ServiceProvider.GetRequiredService<IDockerCliService>();
|
||||||
|
|
||||||
|
// ── 1. Locate settings-custom.php.template ────────────────────────
|
||||||
|
string? templateContent = null;
|
||||||
|
|
||||||
|
// 1a. Try git repo cache first
|
||||||
|
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||||||
|
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||||||
|
if (!string.IsNullOrWhiteSpace(repoUrl))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
|
||||||
|
var gitPath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template");
|
||||||
|
if (File.Exists(gitPath))
|
||||||
|
{
|
||||||
|
templateContent = await File.ReadAllTextAsync(gitPath, ct);
|
||||||
|
_logger.LogInformation("[PostInit] Using template from git repo cache: {Path}", gitPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[PostInit] Could not fetch template from git — trying local fallback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Fall back to local templates/ directory (bundled with app)
|
||||||
|
if (templateContent == null)
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "templates", "settings-custom.php.template"),
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), "templates", "settings-custom.php.template"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
templateContent = await File.ReadAllTextAsync(candidate, ct);
|
||||||
|
_logger.LogInformation("[PostInit] Using local template: {Path}", candidate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateContent == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"[PostInit] settings-custom.php.template not found in git repo or local templates/ — skipping SAML deployment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Provision Authentik SAML application ───────────────────────
|
||||||
|
var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml";
|
||||||
|
Models.DTOs.AuthentikSamlConfig? samlConfig = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"[PostInit] Authentik provisioning failed for {Abbrev} — skipping SAML config deployment to avoid broken Xibo instance",
|
||||||
|
abbrev);
|
||||||
|
return; // Do NOT write a settings-custom.php with empty IdP values — it will crash Xibo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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}{ProviderInfo}",
|
||||||
|
abbrev,
|
||||||
|
samlConfig != null
|
||||||
|
? $" (Authentik provider={samlConfig.ProviderId})"
|
||||||
|
: " (without Authentik — needs manual IdP config)");
|
||||||
|
|
||||||
|
// ── 5. Sync Authentik groups to Xibo ──────────────────────────────
|
||||||
|
// Pre-create Authentik groups as Xibo user groups so they're available
|
||||||
|
// immediately (before any user logs in via SSO).
|
||||||
|
await SyncGroupsFromAuthentikAsync(abbrev, instanceUrl, settings, ct);
|
||||||
|
|
||||||
|
// ── 6. Set up customer invitation infrastructure in Authentik ─────
|
||||||
|
// Creates a group, enrollment flow, invitation stage, role, and
|
||||||
|
// scoping policies so the customer admin can invite users directly.
|
||||||
|
await SetupCustomerInvitationInfrastructureAsync(abbrev, customerName, ct);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets up the customer invitation infrastructure in Authentik (group, enrollment flow,
|
||||||
|
/// stages, role, and policies). Errors are logged but do not block other operations.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SetupCustomerInvitationInfrastructureAsync(
|
||||||
|
string abbrev, string? customerName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var invitationSetup = scope.ServiceProvider.GetRequiredService<IInvitationSetupService>();
|
||||||
|
|
||||||
|
var displayName = !string.IsNullOrWhiteSpace(customerName)
|
||||||
|
? customerName
|
||||||
|
: abbrev.ToUpperInvariant();
|
||||||
|
|
||||||
|
_logger.LogInformation("[PostInit] Setting up invitation infrastructure for {Abbrev}", abbrev);
|
||||||
|
var result = await invitationSetup.SetupCustomerInvitationAsync(abbrev, displayName, ct);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[PostInit] Invitation infrastructure ready for {Abbrev}: group={Group}, flow={Flow}, role={Role}",
|
||||||
|
abbrev, result.GroupName, result.EnrollmentFlowSlug, result.RoleName);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.InvitationManagementUrl))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[PostInit] Customer admin invitation URL: {Url}", result.InvitationManagementUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[PostInit] Invitation setup reported failure for {Abbrev}: {Message}",
|
||||||
|
abbrev, result.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"[PostInit] Invitation infrastructure setup failed for {Abbrev}: {Message}. " +
|
||||||
|
"Customer invitations can be configured manually in Authentik.", abbrev, ex.Message);
|
||||||
|
// Don't rethrow — invitation setup failure should not block post-init
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all groups from Authentik and creates matching user groups in the
|
||||||
|
/// specified Xibo instance, excluding any groups listed in the
|
||||||
|
/// "SamlGroupSyncExcludedGroups" setting (comma-separated group names).
|
||||||
|
/// Groups that already exist in Xibo are skipped.
|
||||||
|
/// This ensures that groups are available in Xibo for permission assignment
|
||||||
|
/// before any user logs in via SAML SSO.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> SyncGroupsFromAuthentikAsync(
|
||||||
|
string abbrev,
|
||||||
|
string instanceUrl,
|
||||||
|
SettingsService settings,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var synced = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[GroupSync] Syncing Authentik groups to Xibo for {Abbrev}", abbrev);
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
|
||||||
|
// ── 1. Fetch groups from Authentik ────────────────────────────────
|
||||||
|
var authentikGroups = await authentik.ListGroupsAsync(ct: ct);
|
||||||
|
if (authentikGroups.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[GroupSync] No groups found in Authentik — nothing to sync");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[GroupSync] Found {Count} Authentik group(s) to sync", authentikGroups.Count);
|
||||||
|
|
||||||
|
// ── 1b. Read excluded groups from settings ────────────────────────
|
||||||
|
var excludedGroupsSetting = await settings.GetAsync("SamlGroupSyncExcludedGroups");
|
||||||
|
var excludedGroups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!string.IsNullOrWhiteSpace(excludedGroupsSetting))
|
||||||
|
{
|
||||||
|
var excluded = excludedGroupsSetting
|
||||||
|
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(g => g.Trim())
|
||||||
|
.Where(g => !string.IsNullOrWhiteSpace(g));
|
||||||
|
foreach (var g in excluded)
|
||||||
|
{
|
||||||
|
excludedGroups.Add(g);
|
||||||
|
}
|
||||||
|
_logger.LogInformation("[GroupSync] Excluded groups: {Groups}", string.Join(", ", excludedGroups));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Authenticate to Xibo ───────────────────────────────────────
|
||||||
|
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
|
||||||
|
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[GroupSync] No OAuth credentials for {Abbrev} — cannot sync groups", abbrev);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
|
||||||
|
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
|
||||||
|
|
||||||
|
// ── 3. List existing Xibo groups ──────────────────────────────────
|
||||||
|
var existingGroups = await xibo.ListUserGroupsAsync(instanceUrl, accessToken);
|
||||||
|
var existingNames = new HashSet<string>(
|
||||||
|
existingGroups.Select(g => g.Group),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// ── 4. Create missing groups in Xibo (excluding specified ones) ────
|
||||||
|
foreach (var group in authentikGroups)
|
||||||
|
{
|
||||||
|
// Skip excluded groups
|
||||||
|
if (excludedGroups.Contains(group.Name))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[GroupSync] Skipping excluded group '{Name}'", group.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingNames.Contains(group.Name))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("[GroupSync] Group '{Name}' already exists in Xibo", group.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var groupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, group.Name);
|
||||||
|
_logger.LogInformation("[GroupSync] Created Xibo group '{Name}' (id={Id})", group.Name, groupId);
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[GroupSync] Failed to create Xibo group '{Name}'", group.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[GroupSync] Sync complete for {Abbrev}: {Synced} group(s) created", abbrev, synced);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[GroupSync] Group sync failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||||
|
// Don't rethrow — group sync failure should not block other operations
|
||||||
|
}
|
||||||
|
|
||||||
|
return synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string GeneratePassword(int length)
|
||||||
|
{
|
||||||
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||||
|
return RandomNumberGenerator.GetString(chars, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Import existing instance secrets on startup
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans all Bitwarden secrets for existing instance-level credentials
|
||||||
|
/// (matching the <c>{abbrev}/xibo-admin-password</c> and <c>{abbrev}/xibo-oauth-secret</c>
|
||||||
|
/// naming convention) and imports their mappings into the config settings so
|
||||||
|
/// the app knows about them without a manual re-provisioning step.
|
||||||
|
/// Safe to call on every startup — existing mappings are never overwritten.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ImportExistingInstanceSecretsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
if (!await bws.IsConfiguredAsync())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("[Import] Bitwarden not configured — skipping instance secret import");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allSecrets = await bws.ListSecretsAsync();
|
||||||
|
var imported = 0;
|
||||||
|
|
||||||
|
foreach (var summary in allSecrets)
|
||||||
|
{
|
||||||
|
// ── Admin password pattern: {abbrev}/xibo-admin-password ──
|
||||||
|
var adminMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-admin-password$");
|
||||||
|
if (adminMatch.Success)
|
||||||
|
{
|
||||||
|
var abbrev = adminMatch.Groups[1].Value;
|
||||||
|
var existing = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
|
if (string.IsNullOrWhiteSpace(existing))
|
||||||
|
{
|
||||||
|
await settings.SetAsync(
|
||||||
|
SettingsService.InstanceAdminPasswordSecretId(abbrev),
|
||||||
|
summary.Id, SettingsService.CatInstance);
|
||||||
|
imported++;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[Import] Imported admin password secret for instance {Abbrev} (id={Id})",
|
||||||
|
abbrev, summary.Id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth secret pattern: {abbrev}/xibo-oauth-secret ──
|
||||||
|
var oauthMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-oauth-secret$");
|
||||||
|
if (oauthMatch.Success)
|
||||||
|
{
|
||||||
|
var abbrev = oauthMatch.Groups[1].Value;
|
||||||
|
var existing = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrWhiteSpace(existing))
|
||||||
|
{
|
||||||
|
await settings.SetAsync(
|
||||||
|
SettingsService.InstanceOAuthSecretId(abbrev),
|
||||||
|
summary.Id, SettingsService.CatInstance);
|
||||||
|
imported++;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[Import] Imported OAuth secret for instance {Abbrev} (id={Id})",
|
||||||
|
abbrev, summary.Id);
|
||||||
|
|
||||||
|
// Try to extract the OAuth client_id from the secret's Note field
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var full = await bws.GetSecretAsync(summary.Id);
|
||||||
|
var cidMatch = Regex.Match(full.Note ?? "", @"ClientId:\s*(\S+)");
|
||||||
|
if (cidMatch.Success)
|
||||||
|
{
|
||||||
|
var existingCid = await settings.GetAsync(
|
||||||
|
SettingsService.InstanceOAuthClientId(abbrev));
|
||||||
|
if (string.IsNullOrWhiteSpace(existingCid))
|
||||||
|
{
|
||||||
|
await settings.SetAsync(
|
||||||
|
SettingsService.InstanceOAuthClientId(abbrev),
|
||||||
|
cidMatch.Groups[1].Value, SettingsService.CatInstance);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[Import] Imported OAuth client ID for instance {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"[Import] Could not fetch full OAuth secret for {Abbrev} to extract client ID",
|
||||||
|
abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imported > 0)
|
||||||
|
_logger.LogInformation("[Import] Imported {Count} instance secret mapping(s) from Bitwarden", imported);
|
||||||
|
else
|
||||||
|
_logger.LogDebug("[Import] No new instance secrets to import");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[Import] Failed to import existing instance secrets from Bitwarden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Snapshot of provisioned credentials for a CMS instance.</summary>
|
||||||
|
public class InstanceCredentials
|
||||||
|
{
|
||||||
|
public string AdminUsername { get; set; } = string.Empty;
|
||||||
|
public string? AdminPassword { get; set; }
|
||||||
|
public string? OAuthClientId { get; set; }
|
||||||
|
public string? OAuthClientSecret { get; set; }
|
||||||
|
|
||||||
|
public bool HasAdminPassword => !string.IsNullOrWhiteSpace(AdminPassword);
|
||||||
|
public bool HasOAuthCredentials => !string.IsNullOrWhiteSpace(OAuthClientId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(OAuthClientSecret);
|
||||||
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
|
||||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads and writes typed application settings from the AppSetting table.
|
/// Reads and writes application settings from Bitwarden Secrets Manager.
|
||||||
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
|
/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/".
|
||||||
|
/// The secret's Note field stores metadata (category|isSensitive).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SettingsService
|
public class SettingsService
|
||||||
{
|
{
|
||||||
private readonly XiboContext _db;
|
private readonly IBitwardenSecretService _bws;
|
||||||
private readonly IDataProtector _protector;
|
|
||||||
private readonly ILogger<SettingsService> _logger;
|
private readonly ILogger<SettingsService> _logger;
|
||||||
|
|
||||||
|
/// <summary>Prefix applied to all config secret keys in Bitwarden.</summary>
|
||||||
|
private const string KeyPrefix = "ots-config/";
|
||||||
|
|
||||||
// ── Category constants ─────────────────────────────────────────────────
|
// ── Category constants ─────────────────────────────────────────────────
|
||||||
public const string CatGit = "Git";
|
public const string CatGit = "Git";
|
||||||
public const string CatMySql = "MySql";
|
public const string CatMySql = "MySql";
|
||||||
@@ -23,6 +22,7 @@ public class SettingsService
|
|||||||
public const string CatPangolin = "Pangolin";
|
public const string CatPangolin = "Pangolin";
|
||||||
public const string CatNfs = "Nfs";
|
public const string CatNfs = "Nfs";
|
||||||
public const string CatDefaults = "Defaults";
|
public const string CatDefaults = "Defaults";
|
||||||
|
public const string CatAuthentik = "Authentik";
|
||||||
|
|
||||||
// ── Key constants ──────────────────────────────────────────────────────
|
// ── Key constants ──────────────────────────────────────────────────────
|
||||||
// Git
|
// Git
|
||||||
@@ -68,87 +68,194 @@ public class SettingsService
|
|||||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||||
|
|
||||||
|
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
|
||||||
|
public const string CatXibo = "Xibo";
|
||||||
|
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)
|
// Instance-specific (keyed by abbreviation)
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a per-instance settings key for the MySQL password.
|
/// Builds a per-instance settings key for the MySQL password.
|
||||||
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||||
|
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
|
||||||
|
public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
|
||||||
|
/// <summary>Bitwarden secret ID for the instance's Xibo OAuth2 client secret.</summary>
|
||||||
|
public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
|
||||||
|
/// <summary>Xibo OAuth2 client_id generated for this instance's OTS application.</summary>
|
||||||
|
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
|
||||||
public const string CatInstance = "Instance";
|
public const string CatInstance = "Instance";
|
||||||
|
|
||||||
|
// ── In-memory cache of secrets (loaded on first access) ────────────────
|
||||||
|
// Maps Bitwarden secret key (with prefix) → (id, value)
|
||||||
|
// Static so the cache is shared across all transient SettingsService instances.
|
||||||
|
private static Dictionary<string, (string Id, string Value)>? s_cache;
|
||||||
|
|
||||||
public SettingsService(
|
public SettingsService(
|
||||||
XiboContext db,
|
IBitwardenSecretService bws,
|
||||||
IDataProtectionProvider dataProtection,
|
|
||||||
ILogger<SettingsService> logger)
|
ILogger<SettingsService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_bws = bws;
|
||||||
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
|
/// <summary>Get a single setting value from Bitwarden.</summary>
|
||||||
public async Task<string?> GetAsync(string key)
|
public async Task<string?> GetAsync(string key)
|
||||||
{
|
{
|
||||||
var setting = await _db.AppSettings.FindAsync(key);
|
var cache = await EnsureCacheAsync();
|
||||||
if (setting == null) return null;
|
var bwKey = KeyPrefix + key;
|
||||||
return setting.IsSensitive && setting.Value != null
|
if (!cache.TryGetValue(bwKey, out var entry))
|
||||||
? Unprotect(setting.Value)
|
return null;
|
||||||
: setting.Value;
|
// Treat single-space sentinel as empty (used to work around SDK marshalling limitation)
|
||||||
|
return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get a setting with a fallback default.</summary>
|
/// <summary>Get a setting with a fallback default.</summary>
|
||||||
public async Task<string> GetAsync(string key, string defaultValue)
|
public async Task<string> GetAsync(string key, string defaultValue)
|
||||||
=> await GetAsync(key) ?? defaultValue;
|
=> await GetAsync(key) ?? defaultValue;
|
||||||
|
|
||||||
/// <summary>Set a single setting, encrypting if sensitive.</summary>
|
/// <summary>Set a single setting in Bitwarden (creates or updates).</summary>
|
||||||
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
||||||
{
|
{
|
||||||
var setting = await _db.AppSettings.FindAsync(key);
|
var cache = await EnsureCacheAsync();
|
||||||
if (setting == null)
|
var bwKey = KeyPrefix + key;
|
||||||
{
|
var note = $"{category}|{(isSensitive ? "sensitive" : "plain")}";
|
||||||
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
|
// Use a single space for empty/null values — the Bitwarden SDK native FFI
|
||||||
_db.AppSettings.Add(setting);
|
// cannot marshal empty strings reliably.
|
||||||
}
|
var safeValue = string.IsNullOrEmpty(value) ? " " : value;
|
||||||
|
|
||||||
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
|
if (cache.TryGetValue(bwKey, out var existing))
|
||||||
setting.IsSensitive = isSensitive;
|
{
|
||||||
setting.Category = category;
|
// Update existing secret
|
||||||
setting.UpdatedAt = DateTime.UtcNow;
|
await _bws.UpdateSecretAsync(existing.Id, bwKey, safeValue, note);
|
||||||
|
cache[bwKey] = (existing.Id, safeValue);
|
||||||
|
s_cache = cache;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
// Only create new secrets when there is an actual value to store
|
||||||
|
var newId = await _bws.CreateSecretAsync(bwKey, safeValue, note);
|
||||||
|
cache[bwKey] = (newId, safeValue);
|
||||||
|
s_cache = cache;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Save multiple settings in a single transaction.</summary>
|
/// <summary>Save multiple settings in a batch.</summary>
|
||||||
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
||||||
{
|
{
|
||||||
|
var count = 0;
|
||||||
|
var errors = new List<string>();
|
||||||
foreach (var (key, value, category, isSensitive) in settings)
|
foreach (var (key, value, category, isSensitive) in settings)
|
||||||
await SetAsync(key, value, category, isSensitive);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SetAsync(key, value, category, isSensitive);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
|
||||||
|
errors.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
_logger.LogInformation("Saved {Count} setting(s) to Bitwarden", count);
|
||||||
_logger.LogInformation("Saved {Count} setting(s)",
|
|
||||||
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
|
if (errors.Count > 0)
|
||||||
|
throw new AggregateException(
|
||||||
|
$"Failed to save {errors.Count} setting(s): {string.Join(", ", errors)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get all settings in a category (values decrypted).</summary>
|
/// <summary>Get all settings in a category (by examining cached keys).</summary>
|
||||||
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
||||||
{
|
{
|
||||||
var settings = await _db.AppSettings
|
var cache = await EnsureCacheAsync();
|
||||||
.Where(s => s.Category == category)
|
var prefix = KeyPrefix + category + ".";
|
||||||
.ToListAsync();
|
var result = new Dictionary<string, string?>();
|
||||||
|
|
||||||
return settings.ToDictionary(
|
foreach (var (bwKey, entry) in cache)
|
||||||
s => s.Key,
|
{
|
||||||
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
|
if (bwKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Strip the "ots-config/" prefix to return the original key
|
||||||
|
var originalKey = bwKey[KeyPrefix.Length..];
|
||||||
|
result[originalKey] = entry.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? Unprotect(string protectedValue)
|
/// <summary>
|
||||||
|
/// Invalidates the in-memory cache so next access re-fetches from Bitwarden.
|
||||||
|
/// </summary>
|
||||||
|
public void InvalidateCache() => s_cache = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-loads the settings cache from Bitwarden.
|
||||||
|
/// Call once at startup so settings are available immediately.
|
||||||
|
/// </summary>
|
||||||
|
public async Task PreloadCacheAsync()
|
||||||
{
|
{
|
||||||
|
InvalidateCache();
|
||||||
|
await EnsureCacheAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Cache management
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<Dictionary<string, (string Id, string Value)>> EnsureCacheAsync()
|
||||||
|
{
|
||||||
|
if (s_cache is not null)
|
||||||
|
return s_cache;
|
||||||
|
|
||||||
|
var cache = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Skip loading if Bitwarden is not yet configured (normal on first run)
|
||||||
|
if (!await _bws.IsConfiguredAsync())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Bitwarden is not configured yet — settings will be available after setup");
|
||||||
|
s_cache = cache;
|
||||||
|
return s_cache;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _protector.Unprotect(protectedValue);
|
// List all secrets, then fetch full value for those matching our prefix
|
||||||
|
var summaries = await _bws.ListSecretsAsync();
|
||||||
|
var configSecrets = summaries
|
||||||
|
.Where(s => s.Key.StartsWith(KeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("Loading {Count} config secrets from Bitwarden", configSecrets.Count);
|
||||||
|
|
||||||
|
foreach (var summary in configSecrets)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var full = await _bws.GetSecretAsync(summary.Id);
|
||||||
|
cache[full.Key] = (full.Id, full.Value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load secret {Key} ({Id})", summary.Key, summary.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
|
_logger.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured");
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s_cache = cache;
|
||||||
|
return s_cache;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using OTSSignsOrchestrator.Core.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
@@ -5,7 +9,13 @@ using OTSSignsOrchestrator.Core.Configuration;
|
|||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
/// Provides connectivity testing and administrative operations against deployed Xibo CMS instances.
|
||||||
|
///
|
||||||
|
/// Bootstrap flow:
|
||||||
|
/// 1. After a new instance is deployed, <see cref="PostInstanceInitService"/> calls
|
||||||
|
/// <see cref="LoginAsync"/> with the default Xibo admin credentials to obtain a session cookie.
|
||||||
|
/// 2. Subsequent operations (create user, register OAuth2 app, set theme) authenticate
|
||||||
|
/// using that session cookie — no pre-existing OAuth2 application is required.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class XiboApiService
|
public class XiboApiService
|
||||||
{
|
{
|
||||||
@@ -23,7 +33,11 @@ public class XiboApiService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Connection test
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string clientId, string clientSecret)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||||
|
|
||||||
@@ -32,43 +46,22 @@ public class XiboApiService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client);
|
||||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
||||||
|
|
||||||
var formContent = new FormUrlEncodedContent(new[]
|
|
||||||
{
|
|
||||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
|
||||||
new KeyValuePair<string, string>("client_id", username),
|
|
||||||
new KeyValuePair<string, string>("client_secret", password)
|
|
||||||
});
|
|
||||||
|
|
||||||
var response = await client.PostAsync(tokenUrl, formContent);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
|
||||||
return new XiboTestResult
|
|
||||||
{
|
|
||||||
IsValid = true,
|
|
||||||
Message = "Connected successfully.",
|
|
||||||
HttpStatus = (int)response.StatusCode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
|
||||||
instanceUrl, (int)response.StatusCode);
|
|
||||||
|
|
||||||
return new XiboTestResult
|
return new XiboTestResult
|
||||||
{
|
{
|
||||||
IsValid = false,
|
IsValid = true,
|
||||||
Message = response.StatusCode switch
|
Message = "Connected successfully.",
|
||||||
{
|
HttpStatus = 200
|
||||||
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
};
|
||||||
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
}
|
||||||
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
catch (XiboAuthException ex)
|
||||||
_ => $"Unexpected response: {(int)response.StatusCode}"
|
{
|
||||||
},
|
return new XiboTestResult
|
||||||
HttpStatus = (int)response.StatusCode
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Message = ex.Message,
|
||||||
|
HttpStatus = ex.HttpStatus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
@@ -80,11 +73,526 @@ public class XiboApiService
|
|||||||
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Session login
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obtains a Bearer access token using the OAuth2 <c>client_credentials</c> grant.
|
||||||
|
/// The caller must have previously created an OAuth2 application in the Xibo CMS
|
||||||
|
/// admin UI and provide the resulting <paramref name="clientId"/> and
|
||||||
|
/// <paramref name="clientSecret"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> LoginAsync(string instanceUrl, string clientId, string clientSecret)
|
||||||
|
{
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||||
|
new KeyValuePair<string, string>("client_id", clientId),
|
||||||
|
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync(tokenUrl, form);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new XiboAuthException(
|
||||||
|
$"Xibo client_credentials login failed for client '{clientId}': HTTP {(int)response.StatusCode} — {body}",
|
||||||
|
(int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var accessToken = doc.RootElement.GetProperty("access_token").GetString()
|
||||||
|
?? throw new InvalidOperationException("access_token missing in Xibo token response.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo access token obtained for client '{ClientId}' at {Url}", clientId, baseUrl);
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Health / readiness
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls <paramref name="instanceUrl"/> until Xibo is genuinely online by calling
|
||||||
|
/// the public <c>/about</c> page (no auth required) and confirming the response body
|
||||||
|
/// contains the word "Xibo". <paramref name="instanceUrl"/> must already include the
|
||||||
|
/// instance sub-path (e.g. <c>https://ots.ots-signs.com/ots</c>).
|
||||||
|
/// The JSON <c>/api/about</c> and <c>/api/clock</c> endpoints both require auth, so
|
||||||
|
/// the HTML about page is the only reliable unauthenticated Xibo-specific probe.
|
||||||
|
/// A plain 200 from a proxy is not sufficient — the body must contain "Xibo".
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> WaitForReadyAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboHealth");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
var healthUrl = $"{baseUrl}/about";
|
||||||
|
|
||||||
|
_logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", healthUrl);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync(healthUrl, ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
// The public /about page always contains the word "Xibo" in its HTML
|
||||||
|
// when Xibo itself is serving responses. A proxy 200 page will not.
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
if (body.Contains("Xibo", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Xibo is ready: {Url}", healthUrl);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_logger.LogDebug("About page returned 200 but body lacks 'Xibo' — proxy may be up but Xibo not yet ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* not yet available */ }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, healthUrl);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Admin user
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> CreateAdminUserAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
string newUsername,
|
||||||
|
string newPassword,
|
||||||
|
string email,
|
||||||
|
int groupId)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("userName", newUsername),
|
||||||
|
new KeyValuePair<string, string>("email", email),
|
||||||
|
new KeyValuePair<string, string>("userTypeId", "1"), // Super Admin
|
||||||
|
new KeyValuePair<string, string>("homePageId", "icondashboard.view"),
|
||||||
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||||
|
new KeyValuePair<string, string>("groupId", groupId.ToString()),
|
||||||
|
new KeyValuePair<string, string>("password", newPassword),
|
||||||
|
new KeyValuePair<string, string>("newUserWizard", "0"),
|
||||||
|
new KeyValuePair<string, string>("hideNavigation", "0"),
|
||||||
|
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/user", form);
|
||||||
|
await EnsureSuccessAsync(response, "create Xibo admin user");
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var userId = doc.RootElement.GetProperty("userId").GetInt32();
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo admin user created: username={Username}, userId={UserId}", newUsername, userId);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the password of an existing Xibo user.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task RotateUserPasswordAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
int userId,
|
||||||
|
string newPassword)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form);
|
||||||
|
await EnsureSuccessAsync(response, "rotate Xibo user password");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo user password rotated: userId={UserId}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// OAuth2 application
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new client_credentials OAuth2 application in Xibo and returns
|
||||||
|
/// the generated client_id and client_secret.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
string appName)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("name", appName),
|
||||||
|
new KeyValuePair<string, string>("clientId", Guid.NewGuid().ToString("N")),
|
||||||
|
new KeyValuePair<string, string>("confidential", "1"),
|
||||||
|
new KeyValuePair<string, string>("authCode", "0"),
|
||||||
|
new KeyValuePair<string, string>("clientCredentials", "1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/application", form);
|
||||||
|
await EnsureSuccessAsync(response, "register Xibo OAuth2 application");
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var cid = root.GetProperty("key").GetString()
|
||||||
|
?? throw new InvalidOperationException("Xibo application 'key' missing in response.");
|
||||||
|
var secret = root.GetProperty("secret").GetString()
|
||||||
|
?? throw new InvalidOperationException("Xibo application 'secret' missing in response.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo OAuth2 application registered: name={Name}, clientId={ClientId}", appName, cid);
|
||||||
|
return (cid, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Theme
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
|
||||||
|
/// obtained from <see cref="LoginAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task SetThemeAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
string themeFolderName = "otssigns")
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
// Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("settings[THEME_FOLDER]", themeFolderName),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/admin/setting", form);
|
||||||
|
await EnsureSuccessAsync(response, "set Xibo theme");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// User groups
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all user groups in the Xibo instance and returns their names and IDs.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<XiboGroupInfo>> ListUserGroupsAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"{baseUrl}/api/group");
|
||||||
|
await EnsureSuccessAsync(response, "list Xibo user groups");
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var groups = new List<XiboGroupInfo>();
|
||||||
|
|
||||||
|
foreach (var el in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
groups.Add(new XiboGroupInfo
|
||||||
|
{
|
||||||
|
GroupId = el.GetProperty("groupId").GetInt32(),
|
||||||
|
Group = el.GetProperty("group").GetString() ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds an existing Xibo group by name or creates it if it doesn't exist.
|
||||||
|
/// Returns the group ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> GetOrCreateUserGroupAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
string groupName)
|
||||||
|
{
|
||||||
|
// Try to find existing group first
|
||||||
|
var existing = await ListUserGroupsAsync(instanceUrl, accessToken);
|
||||||
|
var match = existing.FirstOrDefault(g =>
|
||||||
|
string.Equals(g.Group, groupName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (match != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Xibo group '{Name}' already exists (id={Id})", groupName, match.GroupId);
|
||||||
|
return match.GroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new group
|
||||||
|
return await CreateUserGroupAsync(instanceUrl, accessToken, groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new user group and returns its numeric group ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> CreateUserGroupAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
string groupName)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("group", groupName),
|
||||||
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/group", form);
|
||||||
|
await EnsureSuccessAsync(response, "create Xibo user group");
|
||||||
|
|
||||||
|
// The response is an array containing the created group
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var root = doc.RootElement;
|
||||||
|
// Response may be an array or a single object depending on Xibo version
|
||||||
|
var groupEl = root.ValueKind == JsonValueKind.Array ? root[0] : root;
|
||||||
|
var gid = groupEl.GetProperty("groupId").GetInt32();
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo user group created: name={Name}, groupId={GroupId}", groupName, gid);
|
||||||
|
return gid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assigns a user to a Xibo user group.
|
||||||
|
/// </summary>
|
||||||
|
public async Task AssignUserToGroupAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
int groupId,
|
||||||
|
int userId)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("userId[]", userId.ToString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/group/members/assign/{groupId}", form);
|
||||||
|
await EnsureSuccessAsync(response, $"assign user {userId} to group {groupId}");
|
||||||
|
|
||||||
|
_logger.LogInformation("User {UserId} assigned to group {GroupId}", userId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// User lookup / update / deletion
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing Xibo user's username, password, and email.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateUserAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string accessToken,
|
||||||
|
int userId,
|
||||||
|
string newUsername,
|
||||||
|
string newPassword,
|
||||||
|
string email)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("userName", newUsername),
|
||||||
|
new KeyValuePair<string, string>("email", email),
|
||||||
|
new KeyValuePair<string, string>("userTypeId", "1"),
|
||||||
|
new KeyValuePair<string, string>("homePageId", "icondashboard.view"),
|
||||||
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||||
|
new KeyValuePair<string, string>("newPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("retypeNewPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("newUserWizard", "0"),
|
||||||
|
new KeyValuePair<string, string>("hideNavigation", "0"),
|
||||||
|
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form);
|
||||||
|
await EnsureSuccessAsync(response, $"update Xibo user {userId}");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo user updated: userId={UserId}, newUsername={Username}", userId, newUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a Xibo user by username and returns their numeric user ID.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> GetUserIdByNameAsync(string instanceUrl, string accessToken, string username)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(username)}");
|
||||||
|
await EnsureSuccessAsync(response, "look up Xibo user by name");
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
foreach (var user in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = user.GetProperty("userName").GetString();
|
||||||
|
if (string.Equals(name, username, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return user.GetProperty("userId").GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Xibo user '{username}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a Xibo user by their numeric user ID.
|
||||||
|
/// Authenticates using the supplied <paramref name="accessToken"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task DeleteUserAsync(string instanceUrl, string accessToken, int userId)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
SetBearer(client, accessToken);
|
||||||
|
|
||||||
|
var response = await client.DeleteAsync($"{baseUrl}/api/user/{userId}");
|
||||||
|
await EnsureSuccessAsync(response, $"delete Xibo user {userId}");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo user deleted: userId={UserId}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> GetTokenAsync(
|
||||||
|
string baseUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
HttpClient client)
|
||||||
|
{
|
||||||
|
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||||
|
new KeyValuePair<string, string>("client_id", clientId),
|
||||||
|
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync(tokenUrl, form);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new XiboAuthException(
|
||||||
|
response.StatusCode switch
|
||||||
|
{
|
||||||
|
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
||||||
|
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
||||||
|
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
||||||
|
_ => $"Unexpected response: {(int)response.StatusCode}"
|
||||||
|
},
|
||||||
|
(int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var aToken = doc.RootElement.GetProperty("access_token").GetString()
|
||||||
|
?? throw new InvalidOperationException("access_token missing in Xibo token response.");
|
||||||
|
return aToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetBearer(HttpClient client, string token)
|
||||||
|
=> client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Xibo API call '{operation}' failed: {(int)response.StatusCode} — {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Result / exception types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class XiboTestResult
|
public class XiboTestResult
|
||||||
{
|
{
|
||||||
public bool IsValid { get; set; }
|
public bool IsValid { get; set; }
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
public int HttpStatus { get; set; }
|
public int HttpStatus { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class XiboGroupInfo
|
||||||
|
{
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string Group { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class XiboAuthException : Exception
|
||||||
|
{
|
||||||
|
public int HttpStatus { get; }
|
||||||
|
public XiboAuthException(string message, int httpStatus) : base(message)
|
||||||
|
=> HttpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ using Microsoft.AspNetCore.DataProtection;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Extensions.Http;
|
||||||
|
using Refit;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using OTSSignsOrchestrator.Core.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
@@ -44,6 +48,28 @@ public class App : Application
|
|||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
Log.Information("Creating MainWindow...");
|
Log.Information("Creating MainWindow...");
|
||||||
|
|
||||||
|
// Import existing instance secrets from Bitwarden (fire-and-forget, non-blocking)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Pre-load config settings from Bitwarden so they're available immediately
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
await settings.PreloadCacheAsync();
|
||||||
|
Log.Information("Bitwarden config settings pre-loaded");
|
||||||
|
|
||||||
|
// Import existing instance secrets that aren't yet tracked
|
||||||
|
var postInit = Services.GetRequiredService<PostInstanceInitService>();
|
||||||
|
await postInit.ImportExistingInstanceSecretsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
Log.Information("MainWindowViewModel resolved");
|
Log.Information("MainWindowViewModel resolved");
|
||||||
|
|
||||||
@@ -59,10 +85,26 @@ public class App : Application
|
|||||||
window.Activate();
|
window.Activate();
|
||||||
Log.Information("MainWindow Show() + Activate() called");
|
Log.Information("MainWindow Show() + Activate() called");
|
||||||
|
|
||||||
|
// Start the SignalR connection (fire-and-forget, reconnect handles failures)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var signalR = Services.GetRequiredService<ServerSignalRService>();
|
||||||
|
await signalR.StartAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to start SignalR connection on startup");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
desktop.ShutdownRequested += (_, _) =>
|
desktop.ShutdownRequested += (_, _) =>
|
||||||
{
|
{
|
||||||
var ssh = Services.GetService<SshConnectionService>();
|
var ssh = Services.GetService<SshConnectionService>();
|
||||||
ssh?.Dispose();
|
ssh?.Dispose();
|
||||||
|
var signalR = Services.GetService<ServerSignalRService>();
|
||||||
|
signalR?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -75,10 +117,10 @@ public class App : Application
|
|||||||
|
|
||||||
private static void ConfigureServices(IServiceCollection services)
|
private static void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
// Configuration
|
// Configuration (reloadOnChange so runtime writes to appsettings.json are picked up)
|
||||||
var config = new ConfigurationBuilder()
|
var config = new ConfigurationBuilder()
|
||||||
.SetBasePath(AppContext.BaseDirectory)
|
.SetBasePath(AppContext.BaseDirectory)
|
||||||
.AddJsonFile("appsettings.json", optional: false)
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
services.AddSingleton<IConfiguration>(config);
|
services.AddSingleton<IConfiguration>(config);
|
||||||
@@ -89,6 +131,7 @@ public class App : Application
|
|||||||
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||||
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||||
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||||
|
services.Configure<BitwardenOptions>(config.GetSection(BitwardenOptions.SectionName));
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
services.AddLogging(builder =>
|
services.AddLogging(builder =>
|
||||||
@@ -114,6 +157,22 @@ public class App : Application
|
|||||||
// HTTP
|
// HTTP
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddHttpClient("XiboApi");
|
services.AddHttpClient("XiboApi");
|
||||||
|
services.AddHttpClient("XiboHealth");
|
||||||
|
services.AddHttpClient("AuthentikApi");
|
||||||
|
|
||||||
|
// ── Server API integration ──────────────────────────────────────────
|
||||||
|
services.AddSingleton<TokenStoreService>();
|
||||||
|
services.AddTransient<AuthHeaderHandler>();
|
||||||
|
|
||||||
|
var serverBaseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
|
||||||
|
services.AddRefitClient<IServerApiClient>()
|
||||||
|
.ConfigureHttpClient(c => c.BaseAddress = new Uri(serverBaseUrl))
|
||||||
|
.AddHttpMessageHandler<AuthHeaderHandler>()
|
||||||
|
.AddPolicyHandler(HttpPolicyExtensions
|
||||||
|
.HandleTransientHttpError()
|
||||||
|
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));
|
||||||
|
|
||||||
|
services.AddSingleton<ServerSignalRService>();
|
||||||
|
|
||||||
// SSH services (singletons — maintain connections)
|
// SSH services (singletons — maintain connections)
|
||||||
services.AddSingleton<SshConnectionService>();
|
services.AddSingleton<SshConnectionService>();
|
||||||
@@ -131,11 +190,16 @@ public class App : Application
|
|||||||
services.AddTransient<ComposeValidationService>();
|
services.AddTransient<ComposeValidationService>();
|
||||||
services.AddTransient<XiboApiService>();
|
services.AddTransient<XiboApiService>();
|
||||||
services.AddTransient<InstanceService>();
|
services.AddTransient<InstanceService>();
|
||||||
|
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
|
||||||
|
services.AddTransient<IAuthentikService, AuthentikService>();
|
||||||
|
services.AddTransient<IInvitationSetupService, InvitationSetupService>();
|
||||||
|
services.AddSingleton<PostInstanceInitService>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddTransient<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>(); // singleton: one main window, nav state shared
|
||||||
services.AddTransient<HostsViewModel>();
|
services.AddTransient<HostsViewModel>();
|
||||||
services.AddTransient<InstancesViewModel>();
|
services.AddTransient<InstancesViewModel>();
|
||||||
|
services.AddTransient<InstanceDetailsViewModel>();
|
||||||
services.AddTransient<CreateInstanceViewModel>();
|
services.AddTransient<CreateInstanceViewModel>();
|
||||||
services.AddTransient<SecretsViewModel>();
|
services.AddTransient<SecretsViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|||||||
@@ -22,4 +22,10 @@ public class LiveStackItem
|
|||||||
|
|
||||||
/// <summary>Label of the host — convenience property for data-binding.</summary>
|
/// <summary>Label of the host — convenience property for data-binding.</summary>
|
||||||
public string HostLabel => Host?.Label ?? string.Empty;
|
public string HostLabel => Host?.Label ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side customer ID. Populated when fleet data is loaded from the server API.
|
||||||
|
/// Null when loaded only from local Docker discovery.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CustomerId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<!-- Ensure the Bitwarden SDK native runtime libraries are included on publish -->
|
||||||
|
<RuntimeIdentifiers>linux-x64;win-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -30,6 +32,9 @@
|
|||||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.2" />
|
||||||
|
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
|
||||||
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -47,4 +52,11 @@
|
|||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\templates\settings-custom.php.template">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<Link>templates/settings-custom.php.template</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
152
OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs
Normal file
152
OTSSignsOrchestrator.Desktop/Services/IServerApiClient.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using Refit;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
|
// ── DTOs matching server REST API responses ─────────────────────────────────
|
||||||
|
|
||||||
|
public record FleetSummaryDto
|
||||||
|
{
|
||||||
|
public Guid CustomerId { get; init; }
|
||||||
|
public string Abbreviation { get; init; } = string.Empty;
|
||||||
|
public string CompanyName { get; init; } = string.Empty;
|
||||||
|
public string Plan { get; init; } = string.Empty;
|
||||||
|
public int ScreenCount { get; init; }
|
||||||
|
public string HealthStatus { get; init; } = "Unknown";
|
||||||
|
public DateTime? LastHealthCheck { get; init; }
|
||||||
|
public bool HasRunningJob { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CustomerDetailDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Abbreviation { get; init; } = string.Empty;
|
||||||
|
public string CompanyName { get; init; } = string.Empty;
|
||||||
|
public string? AdminEmail { get; init; }
|
||||||
|
public string Plan { get; init; } = string.Empty;
|
||||||
|
public int ScreenCount { get; init; }
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public List<CustomerInstanceDto> Instances { get; init; } = [];
|
||||||
|
public List<CustomerJobDto> ActiveJobs { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CustomerInstanceDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string? XiboUrl { get; init; }
|
||||||
|
public string? DockerStackName { get; init; }
|
||||||
|
public string HealthStatus { get; init; } = "Unknown";
|
||||||
|
public DateTime? LastHealthCheck { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CustomerJobDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string JobType { get; init; } = string.Empty;
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public DateTime? StartedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);
|
||||||
|
|
||||||
|
public record CreateJobResponse
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string JobType { get; init; } = string.Empty;
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record JobDetailDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Guid CustomerId { get; init; }
|
||||||
|
public string JobType { get; init; } = string.Empty;
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public string? TriggeredBy { get; init; }
|
||||||
|
public string? Parameters { get; init; }
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public DateTime? StartedAt { get; init; }
|
||||||
|
public DateTime? CompletedAt { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
public List<JobStepDto> Steps { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public record JobStepDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string StepName { get; init; } = string.Empty;
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public string? LogOutput { get; init; }
|
||||||
|
public DateTime? StartedAt { get; init; }
|
||||||
|
public DateTime? CompletedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(string Email, string Password);
|
||||||
|
public record RefreshRequest(string RefreshToken);
|
||||||
|
|
||||||
|
public record AuthResponse
|
||||||
|
{
|
||||||
|
public string Token { get; init; } = string.Empty;
|
||||||
|
public string RefreshToken { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RefreshResponse
|
||||||
|
{
|
||||||
|
public string Token { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refit interface ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Headers("Accept: application/json")]
|
||||||
|
public interface IServerApiClient
|
||||||
|
{
|
||||||
|
[Get("/api/fleet")]
|
||||||
|
Task<List<FleetSummaryDto>> GetFleetAsync();
|
||||||
|
|
||||||
|
[Get("/api/fleet/{id}")]
|
||||||
|
Task<CustomerDetailDto> GetCustomerDetailAsync(Guid id);
|
||||||
|
|
||||||
|
[Post("/api/jobs")]
|
||||||
|
Task<CreateJobResponse> CreateJobAsync([Body] CreateJobRequest body);
|
||||||
|
|
||||||
|
[Get("/api/jobs/{id}")]
|
||||||
|
Task<JobDetailDto> GetJobAsync(Guid id);
|
||||||
|
|
||||||
|
[Post("/api/auth/login")]
|
||||||
|
Task<AuthResponse> LoginAsync([Body] LoginRequest body);
|
||||||
|
|
||||||
|
[Post("/api/auth/refresh")]
|
||||||
|
Task<RefreshResponse> RefreshAsync([Body] RefreshRequest body);
|
||||||
|
|
||||||
|
[Get("/api/reports/billing")]
|
||||||
|
Task<HttpResponseMessage> GetBillingCsvAsync([Query] DateOnly from, [Query] DateOnly to);
|
||||||
|
|
||||||
|
[Get("/api/reports/fleet-health")]
|
||||||
|
Task<HttpResponseMessage> GetFleetHealthPdfAsync();
|
||||||
|
|
||||||
|
[Post("/api/fleet/bulk/{action}")]
|
||||||
|
Task<HttpResponseMessage> BulkActionAsync(string action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DelegatingHandler for Bearer token injection ────────────────────────────
|
||||||
|
|
||||||
|
public class AuthHeaderHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly TokenStoreService _tokenStore;
|
||||||
|
|
||||||
|
public AuthHeaderHandler(TokenStoreService tokenStore)
|
||||||
|
{
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var jwt = _tokenStore.GetJwt();
|
||||||
|
if (!string.IsNullOrEmpty(jwt))
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
|
||||||
|
return await base.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs
Normal file
112
OTSSignsOrchestrator.Desktop/Services/ServerSignalRService.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton service managing the persistent SignalR connection to the server's FleetHub.
|
||||||
|
/// All handlers dispatch to the UI thread and republish via <see cref="WeakReferenceMessenger"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerSignalRService : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly HubConnection _connection;
|
||||||
|
private readonly ILogger<ServerSignalRService> _logger;
|
||||||
|
|
||||||
|
public ServerSignalRService(
|
||||||
|
TokenStoreService tokenStore,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<ServerSignalRService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
var baseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
|
||||||
|
|
||||||
|
_connection = new HubConnectionBuilder()
|
||||||
|
.WithUrl($"{baseUrl}/hubs/fleet", options =>
|
||||||
|
{
|
||||||
|
options.AccessTokenProvider = () => Task.FromResult(tokenStore.GetJwt());
|
||||||
|
})
|
||||||
|
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30) })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
RegisterHandlers();
|
||||||
|
|
||||||
|
_connection.Reconnecting += ex =>
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "SignalR reconnecting...");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_connection.Reconnected += connectionId =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SignalR reconnected (connId={ConnectionId})", connectionId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_connection.Closed += ex =>
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "SignalR connection closed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the SignalR connection. Call from <c>App.OnFrameworkInitializationCompleted</c>.
|
||||||
|
/// Failures are logged but do not throw — automatic reconnect will retry.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _connection.StartAsync();
|
||||||
|
_logger.LogInformation("SignalR connected to FleetHub");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "SignalR initial connection failed — will retry via automatic reconnect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
try { await _connection.StopAsync(); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "Error stopping SignalR connection"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public HubConnectionState State => _connection.State;
|
||||||
|
|
||||||
|
private void RegisterHandlers()
|
||||||
|
{
|
||||||
|
_connection.On<string, string, string>("SendJobCreated", (jobId, abbrev, jobType) =>
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
WeakReferenceMessenger.Default.Send(
|
||||||
|
new JobCreatedMessage(new(jobId, abbrev, jobType)))));
|
||||||
|
|
||||||
|
_connection.On<string, string, int, string>("SendJobProgressUpdate", (jobId, stepName, pct, logLine) =>
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
WeakReferenceMessenger.Default.Send(
|
||||||
|
new JobProgressUpdateMessage(new(jobId, stepName, pct, logLine)))));
|
||||||
|
|
||||||
|
_connection.On<string, bool, string>("SendJobCompleted", (jobId, success, summary) =>
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
WeakReferenceMessenger.Default.Send(
|
||||||
|
new JobCompletedMessage(new(jobId, success, summary)))));
|
||||||
|
|
||||||
|
_connection.On<string, string>("SendInstanceStatusChanged", (customerId, status) =>
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
WeakReferenceMessenger.Default.Send(
|
||||||
|
new InstanceStatusChangedMessage(new(customerId, status)))));
|
||||||
|
|
||||||
|
_connection.On<string, string>("SendAlertRaised", (severity, message) =>
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
WeakReferenceMessenger.Default.Send(
|
||||||
|
new AlertRaisedMessage(new(severity, message)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs
Normal file
35
OTSSignsOrchestrator.Desktop/Services/SignalRMessages.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>SignalR push messages republished via WeakReferenceMessenger for ViewModel consumption.</summary>
|
||||||
|
|
||||||
|
public sealed class JobCreatedMessage : ValueChangedMessage<JobCreatedMessage.Payload>
|
||||||
|
{
|
||||||
|
public JobCreatedMessage(Payload value) : base(value) { }
|
||||||
|
public record Payload(string JobId, string Abbrev, string JobType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JobProgressUpdateMessage : ValueChangedMessage<JobProgressUpdateMessage.Payload>
|
||||||
|
{
|
||||||
|
public JobProgressUpdateMessage(Payload value) : base(value) { }
|
||||||
|
public record Payload(string JobId, string StepName, int Pct, string LogLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JobCompletedMessage : ValueChangedMessage<JobCompletedMessage.Payload>
|
||||||
|
{
|
||||||
|
public JobCompletedMessage(Payload value) : base(value) { }
|
||||||
|
public record Payload(string JobId, bool Success, string Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InstanceStatusChangedMessage : ValueChangedMessage<InstanceStatusChangedMessage.Payload>
|
||||||
|
{
|
||||||
|
public InstanceStatusChangedMessage(Payload value) : base(value) { }
|
||||||
|
public record Payload(string CustomerId, string Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AlertRaisedMessage : ValueChangedMessage<AlertRaisedMessage.Payload>
|
||||||
|
{
|
||||||
|
public AlertRaisedMessage(Payload value) : base(value) { }
|
||||||
|
public record Payload(string Severity, string Message);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using OTSSignsOrchestrator.Core.Configuration;
|
|||||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
@@ -40,6 +41,70 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
|
|
||||||
public SshHost? CurrentHost => _currentHost;
|
public SshHost? CurrentHost => _currentHost;
|
||||||
|
|
||||||
|
private void EnsureHost()
|
||||||
|
{
|
||||||
|
if (_currentHost == null)
|
||||||
|
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape password for safe use in shell scripts with proper quoting.
|
||||||
|
/// Uses printf-safe escaping to avoid newline injection and special character issues.
|
||||||
|
/// </summary>
|
||||||
|
private string EscapePasswordForShell(string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Password is null or empty");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Original password length: {Length} characters", password.Length);
|
||||||
|
|
||||||
|
// Use printf-safe format: escape single quotes and other problematic characters
|
||||||
|
// Replace ' with '\'' (close quote, escaped quote, open quote)
|
||||||
|
var escaped = password.Replace("'", "'\\''");
|
||||||
|
|
||||||
|
_logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)",
|
||||||
|
escaped.Length, escaped.Length - password.Length);
|
||||||
|
_logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'",
|
||||||
|
password.Length > 0 ? password[0].ToString() : "N/A",
|
||||||
|
password.Length > 0 ? password[^1].ToString() : "N/A");
|
||||||
|
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test if the current host's password works with sudo by running a no-op sudo command.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(bool Success, string? Error)> TestSudoPasswordAsync()
|
||||||
|
{
|
||||||
|
EnsureHost();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_currentHost!.Password))
|
||||||
|
{
|
||||||
|
return (false, "No password configured for SSH host");
|
||||||
|
}
|
||||||
|
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password);
|
||||||
|
var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1";
|
||||||
|
|
||||||
|
_logger.LogInformation("Testing sudo password for host {Host} user {User}...",
|
||||||
|
_currentHost!.Label, _currentHost!.Username);
|
||||||
|
|
||||||
|
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
if (exitCode == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = (stderr ?? stdout ?? "unknown error").Trim();
|
||||||
|
_logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error);
|
||||||
|
return (false, error);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||||
{
|
{
|
||||||
EnsureHost();
|
EnsureHost();
|
||||||
@@ -183,12 +248,22 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
|
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
|
||||||
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
|
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
|
||||||
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
|
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
|
||||||
|
// Properly escape password for shell use (handle special characters like single quotes)
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(escapedPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||||
|
_currentHost!.Label);
|
||||||
|
}
|
||||||
|
|
||||||
var script = $"""
|
var script = $"""
|
||||||
set -e
|
set -e
|
||||||
MNT=$(mktemp -d)
|
MNT=$(mktemp -d)
|
||||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||||
sudo mkdir -p {mkdirTargets}
|
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||||
sudo umount "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||||
rmdir "$MNT"
|
rmdir "$MNT"
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -228,12 +303,22 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
||||||
var mkdirTargets = string.Join(" ", folderList);
|
var mkdirTargets = string.Join(" ", folderList);
|
||||||
|
|
||||||
|
// Properly escape password for shell use (handle special characters like single quotes)
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(escapedPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||||
|
_currentHost!.Label);
|
||||||
|
}
|
||||||
|
|
||||||
var script = $"""
|
var script = $"""
|
||||||
set -e
|
set -e
|
||||||
MNT=$(mktemp -d)
|
MNT=$(mktemp -d)
|
||||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||||
sudo mkdir -p {mkdirTargets}
|
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||||
sudo umount "$MNT"
|
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||||
rmdir "$MNT"
|
rmdir "$MNT"
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -258,6 +343,80 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
return (false, error);
|
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
|
||||||
|
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
|
||||||
|
var parentDir = $"$(dirname \"{targetPath}\")";
|
||||||
|
|
||||||
|
// Properly escape password for shell use (handle special characters like single quotes)
|
||||||
|
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||||
|
|
||||||
|
_logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}",
|
||||||
|
_currentHost!.Label, _currentHost!.Username,
|
||||||
|
!string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(escapedPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||||
|
_currentHost!.Label);
|
||||||
|
return (false, "No password configured for SSH host");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S.
|
||||||
|
// The heredoc approach fails because the shell's heredoc redirects stdin for the
|
||||||
|
// entire pipeline, so sudo -S reads the PHP content instead of the password.
|
||||||
|
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
|
||||||
|
|
||||||
|
// Strategy: base64-decode content to a temp file (no sudo needed), then use
|
||||||
|
// printf | sudo -S for each privileged command — matching the proven pattern
|
||||||
|
// in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH
|
||||||
|
// exec channels have no TTY and timestamps may not persist between commands.
|
||||||
|
var script = $"""
|
||||||
|
set -e
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
echo '{base64Content}' | base64 -d > "$TMPFILE"
|
||||||
|
MNT=$(mktemp -d)
|
||||||
|
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||||
|
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir}
|
||||||
|
printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}"
|
||||||
|
rm -f "$TMPFILE"
|
||||||
|
printf '%s\n' '{escapedPassword}' | sudo -S 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<bool> ForceUpdateServiceAsync(string serviceName)
|
public async Task<bool> ForceUpdateServiceAsync(string serviceName)
|
||||||
{
|
{
|
||||||
EnsureHost();
|
EnsureHost();
|
||||||
@@ -441,10 +600,119 @@ public class SshDockerCliService : IDockerCliService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureHost()
|
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200)
|
||||||
{
|
{
|
||||||
if (_currentHost == null)
|
EnsureHost();
|
||||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
|
||||||
|
// Determine which services to fetch logs for
|
||||||
|
List<string> serviceNames;
|
||||||
|
if (!string.IsNullOrEmpty(serviceName))
|
||||||
|
{
|
||||||
|
serviceNames = new List<string> { serviceName };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var services = await InspectStackServicesAsync(stackName);
|
||||||
|
serviceNames = services.Select(s => s.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var allEntries = new List<ServiceLogEntry>();
|
||||||
|
foreach (var svcName in serviceNames)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1";
|
||||||
|
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse each line. Docker service logs format with --timestamps:
|
||||||
|
// <timestamp> <service>.<replica>.<taskid>@<node> | <message>
|
||||||
|
// or sometimes just:
|
||||||
|
// <timestamp> <service>.<replica>.<taskid> <message>
|
||||||
|
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var entry = ParseLogLine(line, svcName, stackName);
|
||||||
|
if (entry != null)
|
||||||
|
allEntries.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEntries.OrderBy(e => e.Timestamp).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a single line from <c>docker service logs --timestamps</c> output.
|
||||||
|
/// </summary>
|
||||||
|
private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message"
|
||||||
|
// The timestamp is always the first space-delimited token when --timestamps is used.
|
||||||
|
var firstSpace = line.IndexOf(' ');
|
||||||
|
if (firstSpace <= 0)
|
||||||
|
return new ServiceLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
Source = serviceName,
|
||||||
|
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||||
|
Message = line
|
||||||
|
};
|
||||||
|
|
||||||
|
var timestampStr = line[..firstSpace];
|
||||||
|
var rest = line[(firstSpace + 1)..].TrimStart();
|
||||||
|
|
||||||
|
// Try to parse the timestamp
|
||||||
|
if (!DateTimeOffset.TryParse(timestampStr, out var timestamp))
|
||||||
|
{
|
||||||
|
// If timestamp parsing fails, treat the whole line as the message
|
||||||
|
return new ServiceLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
Source = serviceName,
|
||||||
|
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||||
|
Message = line
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split source and message on the pipe separator
|
||||||
|
var source = serviceName;
|
||||||
|
var message = rest;
|
||||||
|
var pipeIndex = rest.IndexOf('|');
|
||||||
|
if (pipeIndex >= 0)
|
||||||
|
{
|
||||||
|
source = rest[..pipeIndex].Trim();
|
||||||
|
message = rest[(pipeIndex + 1)..].TrimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = timestamp,
|
||||||
|
Source = source,
|
||||||
|
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips the stack name prefix from a fully-qualified service name.
|
||||||
|
/// e.g. "acm-cms-stack_acm-web" → "acm-web"
|
||||||
|
/// </summary>
|
||||||
|
private static string StripStackPrefix(string serviceName, string stackName)
|
||||||
|
{
|
||||||
|
var prefix = stackName + "_";
|
||||||
|
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
||||||
|
|||||||
268
OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs
Normal file
268
OTSSignsOrchestrator.Desktop/Services/TokenStoreService.cs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores and retrieves operator JWT and refresh tokens using the OS credential store.
|
||||||
|
/// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain;
|
||||||
|
/// Linux: AES-encrypted file fallback in AppData.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TokenStoreService
|
||||||
|
{
|
||||||
|
private const string ServiceName = "OTSSignsOrchestrator";
|
||||||
|
private const string JwtAccount = "operator-jwt";
|
||||||
|
private const string RefreshAccount = "operator-refresh";
|
||||||
|
|
||||||
|
private readonly ILogger<TokenStoreService> _logger;
|
||||||
|
|
||||||
|
public TokenStoreService(ILogger<TokenStoreService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StoreTokens(string jwt, string refreshToken)
|
||||||
|
{
|
||||||
|
WriteCredential(JwtAccount, jwt);
|
||||||
|
WriteCredential(RefreshAccount, refreshToken);
|
||||||
|
_logger.LogDebug("Tokens stored in OS credential store");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetJwt() => ReadCredential(JwtAccount);
|
||||||
|
|
||||||
|
public string? GetRefreshToken() => ReadCredential(RefreshAccount);
|
||||||
|
|
||||||
|
public void ClearTokens()
|
||||||
|
{
|
||||||
|
DeleteCredential(JwtAccount);
|
||||||
|
DeleteCredential(RefreshAccount);
|
||||||
|
_logger.LogDebug("Tokens cleared from OS credential store");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform dispatch ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void WriteCredential(string account, string secret)
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
WindowsCredentialManager.Write(ServiceName, account, secret);
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
MacKeychain.Write(ServiceName, account, secret);
|
||||||
|
else
|
||||||
|
LinuxEncryptedFile.Write(ServiceName, account, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ReadCredential(string account)
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
return WindowsCredentialManager.Read(ServiceName, account);
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
return MacKeychain.Read(ServiceName, account);
|
||||||
|
return LinuxEncryptedFile.Read(ServiceName, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteCredential(string account)
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
WindowsCredentialManager.Delete(ServiceName, account);
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
MacKeychain.Delete(ServiceName, account);
|
||||||
|
else
|
||||||
|
LinuxEncryptedFile.Delete(ServiceName, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Windows — advapi32.dll Credential Manager
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
private static class WindowsCredentialManager
|
||||||
|
{
|
||||||
|
private const int CredTypeGeneric = 1;
|
||||||
|
private const int CredPersistLocalMachine = 2;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct CREDENTIAL
|
||||||
|
{
|
||||||
|
public uint Flags;
|
||||||
|
public uint Type;
|
||||||
|
public string TargetName;
|
||||||
|
public string Comment;
|
||||||
|
public long LastWritten;
|
||||||
|
public uint CredentialBlobSize;
|
||||||
|
public IntPtr CredentialBlob;
|
||||||
|
public uint Persist;
|
||||||
|
public uint AttributeCount;
|
||||||
|
public IntPtr Attributes;
|
||||||
|
public string TargetAlias;
|
||||||
|
public string UserName;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags);
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool CredDeleteW(string target, uint type, uint flags);
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll")]
|
||||||
|
private static extern void CredFree(IntPtr buffer);
|
||||||
|
|
||||||
|
private static string TargetName(string service, string account) => $"{service}/{account}";
|
||||||
|
|
||||||
|
public static void Write(string service, string account, string secret)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(secret);
|
||||||
|
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cred = new CREDENTIAL
|
||||||
|
{
|
||||||
|
Type = CredTypeGeneric,
|
||||||
|
TargetName = TargetName(service, account),
|
||||||
|
UserName = account,
|
||||||
|
CredentialBlob = handle.AddrOfPinnedObject(),
|
||||||
|
CredentialBlobSize = (uint)bytes.Length,
|
||||||
|
Persist = CredPersistLocalMachine,
|
||||||
|
};
|
||||||
|
CredWriteW(ref cred, 0);
|
||||||
|
}
|
||||||
|
finally { handle.Free(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Read(string service, string account)
|
||||||
|
{
|
||||||
|
if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr))
|
||||||
|
return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
|
||||||
|
if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null;
|
||||||
|
var bytes = new byte[cred.CredentialBlobSize];
|
||||||
|
Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
|
||||||
|
return Encoding.UTF8.GetString(bytes);
|
||||||
|
}
|
||||||
|
finally { CredFree(credPtr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Delete(string service, string account)
|
||||||
|
=> CredDeleteW(TargetName(service, account), CredTypeGeneric, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// macOS — Security.framework Keychain via /usr/bin/security CLI
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
private static class MacKeychain
|
||||||
|
{
|
||||||
|
public static void Write(string service, string account, string secret)
|
||||||
|
{
|
||||||
|
// Delete first to avoid "duplicate" errors on update
|
||||||
|
Delete(service, account);
|
||||||
|
RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Read(string service, string account)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w");
|
||||||
|
return exitCode == 0 ? stdout.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Delete(string service, string account)
|
||||||
|
=> RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\"");
|
||||||
|
|
||||||
|
private static (int exitCode, string stdout) RunSecurity(string args)
|
||||||
|
{
|
||||||
|
using var proc = new System.Diagnostics.Process();
|
||||||
|
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "/usr/bin/security",
|
||||||
|
Arguments = args,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
proc.Start();
|
||||||
|
var stdout = proc.StandardOutput.ReadToEnd();
|
||||||
|
proc.WaitForExit(5000);
|
||||||
|
return (proc.ExitCode, stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Linux — AES-256-GCM encrypted file in ~/.local/share
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
private static class LinuxEncryptedFile
|
||||||
|
{
|
||||||
|
// Machine-specific key derived from machine-id + user name
|
||||||
|
private static byte[] DeriveKey()
|
||||||
|
{
|
||||||
|
var machineId = "linux-default";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists("/etc/machine-id"))
|
||||||
|
machineId = File.ReadAllText("/etc/machine-id").Trim();
|
||||||
|
}
|
||||||
|
catch { /* fallback */ }
|
||||||
|
|
||||||
|
var material = $"{machineId}:{Environment.UserName}:{ServiceName}";
|
||||||
|
return SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FilePath(string service, string account)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
service, "credentials");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return Path.Combine(dir, $"{account}.enc");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(string service, string account, string secret)
|
||||||
|
{
|
||||||
|
var key = DeriveKey();
|
||||||
|
var plaintext = Encoding.UTF8.GetBytes(secret);
|
||||||
|
var nonce = new byte[12];
|
||||||
|
RandomNumberGenerator.Fill(nonce);
|
||||||
|
var ciphertext = new byte[plaintext.Length];
|
||||||
|
var tag = new byte[16];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(key, 16);
|
||||||
|
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||||
|
|
||||||
|
// File format: [12 nonce][16 tag][ciphertext]
|
||||||
|
var output = new byte[12 + 16 + ciphertext.Length];
|
||||||
|
nonce.CopyTo(output, 0);
|
||||||
|
tag.CopyTo(output, 12);
|
||||||
|
ciphertext.CopyTo(output, 28);
|
||||||
|
File.WriteAllBytes(FilePath(service, account), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Read(string service, string account)
|
||||||
|
{
|
||||||
|
var path = FilePath(service, account);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
|
||||||
|
var data = File.ReadAllBytes(path);
|
||||||
|
if (data.Length < 28) return null;
|
||||||
|
|
||||||
|
var nonce = data[..12];
|
||||||
|
var tag = data[12..28];
|
||||||
|
var ciphertext = data[28..];
|
||||||
|
var plaintext = new byte[ciphertext.Length];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(DeriveKey(), 16);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Delete(string service, string account)
|
||||||
|
{
|
||||||
|
var path = FilePath(service, account);
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
|||||||
public partial class CreateInstanceViewModel : ObservableObject
|
public partial class CreateInstanceViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly MainWindowViewModel _mainVm;
|
||||||
|
|
||||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -43,6 +44,9 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
||||||
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
|
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>When enabled, existing Docker volumes for the stack are removed before deploying.</summary>
|
||||||
|
[ObservableProperty] private bool _purgeStaleVolumes = false;
|
||||||
|
|
||||||
// SSH host selection
|
// SSH host selection
|
||||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||||
@@ -80,9 +84,10 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public CreateInstanceViewModel(IServiceProvider services)
|
public CreateInstanceViewModel(IServiceProvider services, MainWindowViewModel mainVm)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
|
_mainVm = mainVm;
|
||||||
_ = LoadHostsAsync();
|
_ = LoadHostsAsync();
|
||||||
_ = LoadNfsDefaultsAsync();
|
_ = LoadNfsDefaultsAsync();
|
||||||
}
|
}
|
||||||
@@ -164,7 +169,7 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||||
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||||
|
|
||||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||||
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||||
|
|
||||||
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||||
@@ -304,20 +309,29 @@ public partial class CreateInstanceViewModel : ObservableObject
|
|||||||
SshHostId = SelectedSshHost.Id,
|
SshHostId = SelectedSshHost.Id,
|
||||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||||
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
|
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
|
||||||
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
|
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
|
||||||
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
|
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
|
||||||
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
|
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
|
||||||
|
PurgeStaleVolumes = PurgeStaleVolumes,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await instanceSvc.CreateInstanceAsync(dto);
|
var result = await instanceSvc.CreateInstanceAsync(dto);
|
||||||
|
|
||||||
AppendOutput(result.Output ?? string.Empty);
|
AppendOutput(result.Output ?? string.Empty);
|
||||||
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
|
|
||||||
|
|
||||||
StatusMessage = result.Success
|
if (result.Success)
|
||||||
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
|
{
|
||||||
: $"Deploy failed: {result.ErrorMessage}";
|
SetProgress(100, "Stack deployed successfully.");
|
||||||
|
StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " +
|
||||||
|
"Open the details pane on the Instances page to complete setup.";
|
||||||
|
_mainVm.NavigateToInstancesWithSelection(Abbrev);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetProgress(0, "Deployment failed.");
|
||||||
|
StatusMessage = $"Deploy failed: {result.ErrorMessage}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
using OTSSignsOrchestrator.Desktop.Models;
|
||||||
|
using OTSSignsOrchestrator.Desktop.Services;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel for the instance details modal.
|
||||||
|
/// Shows admin credentials, DB credentials, and OAuth2 app details
|
||||||
|
/// with options to rotate passwords.
|
||||||
|
/// </summary>
|
||||||
|
public partial class InstanceDetailsViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
// ── Instance metadata ─────────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _stackName = string.Empty;
|
||||||
|
[ObservableProperty] private string _customerAbbrev = string.Empty;
|
||||||
|
[ObservableProperty] private string _hostLabel = string.Empty;
|
||||||
|
[ObservableProperty] private string _instanceUrl = string.Empty;
|
||||||
|
|
||||||
|
// ── OTS admin credentials ─────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _adminUsername = string.Empty;
|
||||||
|
[ObservableProperty] private string _adminPassword = string.Empty;
|
||||||
|
[ObservableProperty] private bool _adminPasswordVisible = false;
|
||||||
|
[ObservableProperty] private string _adminPasswordDisplay = "••••••••";
|
||||||
|
|
||||||
|
// ── Database credentials ──────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _dbUsername = string.Empty;
|
||||||
|
[ObservableProperty] private string _dbPassword = string.Empty;
|
||||||
|
[ObservableProperty] private bool _dbPasswordVisible = false;
|
||||||
|
[ObservableProperty] private string _dbPasswordDisplay = "••••••••";
|
||||||
|
|
||||||
|
// ── OAuth2 application ────────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _oAuthClientId = string.Empty;
|
||||||
|
[ObservableProperty] private string _oAuthClientSecret = string.Empty;
|
||||||
|
[ObservableProperty] private bool _oAuthSecretVisible = false;
|
||||||
|
[ObservableProperty] private string _oAuthSecretDisplay = "••••••••";
|
||||||
|
|
||||||
|
// ── Status ────────────────────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
// ── Pending-setup inputs (shown when instance hasn't been initialised yet) ────────────
|
||||||
|
[ObservableProperty] private bool _isPendingSetup;
|
||||||
|
[ObservableProperty] private string _initClientId = string.Empty;
|
||||||
|
[ObservableProperty] private string _initClientSecret = string.Empty;
|
||||||
|
// ── Services (for restart) ─────────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private ObservableCollection<ServiceInfo> _stackServices = new();
|
||||||
|
[ObservableProperty] private bool _isLoadingServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback the View wires up to show a confirmation dialog.
|
||||||
|
/// Parameters: (title, message) → returns true if the user confirmed.
|
||||||
|
/// </summary>
|
||||||
|
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
// Cached instance — needed by InitializeCommand to reload after setup
|
||||||
|
private LiveStackItem? _currentInstance;
|
||||||
|
public InstanceDetailsViewModel(IServiceProvider services)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Load
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
|
||||||
|
public async Task LoadAsync(LiveStackItem instance)
|
||||||
|
{
|
||||||
|
_currentInstance = instance;
|
||||||
|
StackName = instance.StackName;
|
||||||
|
CustomerAbbrev = instance.CustomerAbbrev;
|
||||||
|
HostLabel = instance.HostLabel;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "Loading credentials...";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
var postInit = scope.ServiceProvider.GetRequiredService<PostInstanceInitService>();
|
||||||
|
|
||||||
|
// Derive the instance URL from the CMS server name template
|
||||||
|
var serverTemplate = await settings.GetAsync(
|
||||||
|
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||||
|
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||||
|
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
||||||
|
|
||||||
|
// ── Admin credentials ─────────────────────────────────────────
|
||||||
|
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
|
||||||
|
AdminUsername = creds.AdminUsername;
|
||||||
|
SetAdminPassword(creds.AdminPassword ?? string.Empty);
|
||||||
|
|
||||||
|
OAuthClientId = creds.OAuthClientId ?? string.Empty;
|
||||||
|
SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty);
|
||||||
|
|
||||||
|
// ── DB credentials ────────────────────────────────────────────
|
||||||
|
var mySqlUserTemplate = await settings.GetAsync(
|
||||||
|
SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
||||||
|
DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||||
|
|
||||||
|
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev));
|
||||||
|
SetDbPassword(dbPw ?? string.Empty);
|
||||||
|
|
||||||
|
StatusMessage = creds.HasAdminPassword
|
||||||
|
? "Credentials loaded."
|
||||||
|
: "Pending setup — enter your Xibo OAuth credentials below to initialise this instance.";
|
||||||
|
|
||||||
|
IsPendingSetup = !creds.HasAdminPassword;
|
||||||
|
// Clear any previous init inputs when re-loading
|
||||||
|
if (IsPendingSetup)
|
||||||
|
{
|
||||||
|
InitClientId = string.Empty;
|
||||||
|
InitClientSecret = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load stack services ───────────────────────────────────────
|
||||||
|
await LoadServicesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error loading credentials: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Initialise (pending setup)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(InitClientId) || string.IsNullOrWhiteSpace(InitClientSecret))
|
||||||
|
{
|
||||||
|
StatusMessage = "Both Client ID and Client Secret are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_currentInstance is null) return;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "Waiting for Xibo and running initialisation (this may take several minutes)...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var postInit = _services.GetRequiredService<PostInstanceInitService>();
|
||||||
|
await postInit.InitializeWithOAuthAsync(
|
||||||
|
CustomerAbbrev,
|
||||||
|
InstanceUrl,
|
||||||
|
InitClientId.Trim(),
|
||||||
|
InitClientSecret.Trim());
|
||||||
|
|
||||||
|
// Reload credentials — IsPendingSetup will flip to false
|
||||||
|
IsBusy = false;
|
||||||
|
await LoadAsync(_currentInstance);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Initialisation failed: {ex.Message}";
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Visibility toggles
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleAdminPasswordVisibility()
|
||||||
|
{
|
||||||
|
AdminPasswordVisible = !AdminPasswordVisible;
|
||||||
|
AdminPasswordDisplay = AdminPasswordVisible
|
||||||
|
? AdminPassword
|
||||||
|
: (AdminPassword.Length > 0 ? "••••••••" : "(not set)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleDbPasswordVisibility()
|
||||||
|
{
|
||||||
|
DbPasswordVisible = !DbPasswordVisible;
|
||||||
|
DbPasswordDisplay = DbPasswordVisible
|
||||||
|
? DbPassword
|
||||||
|
: (DbPassword.Length > 0 ? "••••••••" : "(not set)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleOAuthSecretVisibility()
|
||||||
|
{
|
||||||
|
OAuthSecretVisible = !OAuthSecretVisible;
|
||||||
|
OAuthSecretDisplay = OAuthSecretVisible
|
||||||
|
? OAuthClientSecret
|
||||||
|
: (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Clipboard
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CopyAdminPasswordAsync()
|
||||||
|
=> await CopyToClipboardAsync(AdminPassword, "Admin password");
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CopyDbPasswordAsync()
|
||||||
|
=> await CopyToClipboardAsync(DbPassword, "DB password");
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CopyOAuthClientIdAsync()
|
||||||
|
=> await CopyToClipboardAsync(OAuthClientId, "OAuth client ID");
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CopyOAuthSecretAsync()
|
||||||
|
=> await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret");
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rotation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestartServiceAsync(ServiceInfo? service)
|
||||||
|
{
|
||||||
|
if (service is null || _currentInstance is null) return;
|
||||||
|
|
||||||
|
if (ConfirmAsync is not null)
|
||||||
|
{
|
||||||
|
var confirmed = await ConfirmAsync(
|
||||||
|
"Restart Service",
|
||||||
|
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = $"Restarting service '{service.Name}'...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
|
||||||
|
StatusMessage = ok
|
||||||
|
? $"Service '{service.Name}' restarted successfully."
|
||||||
|
: $"Failed to restart service '{service.Name}'.";
|
||||||
|
|
||||||
|
// Refresh service list to show updated replica status
|
||||||
|
await LoadServicesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error restarting service: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadServicesAsync()
|
||||||
|
{
|
||||||
|
if (_currentInstance is null) return;
|
||||||
|
|
||||||
|
IsLoadingServices = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
dockerCli.SetHost(_currentInstance.Host);
|
||||||
|
var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName);
|
||||||
|
StackServices = new ObservableCollection<ServiceInfo>(services);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error loading services: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoadingServices = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RotateAdminPasswordAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "Rotating OTS admin password...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var postInit = _services.GetRequiredService<PostInstanceInitService>();
|
||||||
|
var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl);
|
||||||
|
SetAdminPassword(newPassword);
|
||||||
|
StatusMessage = "Admin password rotated successfully.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error rotating admin password: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RotateDbPasswordAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = $"Rotating MySQL password for {StackName}...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||||
|
|
||||||
|
// We need the Host — retrieve from the HostLabel lookup
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||||
|
|
||||||
|
// Get the host from the loaded stack — caller must have set the SSH host before
|
||||||
|
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
// Reload DB password
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev));
|
||||||
|
SetDbPassword(dbPw ?? string.Empty);
|
||||||
|
StatusMessage = $"DB password rotated: {msg}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = $"DB rotation failed: {msg}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error rotating DB password: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void SetAdminPassword(string value)
|
||||||
|
{
|
||||||
|
AdminPassword = value;
|
||||||
|
AdminPasswordVisible = false;
|
||||||
|
AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetDbPassword(string value)
|
||||||
|
{
|
||||||
|
DbPassword = value;
|
||||||
|
DbPasswordVisible = false;
|
||||||
|
DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetOAuthSecret(string value)
|
||||||
|
{
|
||||||
|
OAuthClientSecret = value;
|
||||||
|
OAuthSecretVisible = false;
|
||||||
|
OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CopyToClipboardAsync(string text, string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
var topLevel = Avalonia.Application.Current?.ApplicationLifetime is
|
||||||
|
Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt
|
||||||
|
? dt.MainWindow
|
||||||
|
: null;
|
||||||
|
var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null;
|
||||||
|
if (clipboard is not null)
|
||||||
|
await clipboard.SetTextAsync(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using OTSSignsOrchestrator.Core.Data;
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
using OTSSignsOrchestrator.Desktop.Models;
|
using OTSSignsOrchestrator.Desktop.Models;
|
||||||
@@ -14,10 +18,18 @@ namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// ViewModel for listing, viewing, and managing CMS instances.
|
/// ViewModel for listing, viewing, and managing CMS instances.
|
||||||
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
|
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
|
||||||
|
/// Server operations (decommission, suspend, reactivate) go through the REST API.
|
||||||
|
/// Real-time updates arrive via SignalR → WeakReferenceMessenger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class InstancesViewModel : ObservableObject
|
public partial class InstancesViewModel : ObservableObject,
|
||||||
|
IRecipient<AlertRaisedMessage>,
|
||||||
|
IRecipient<InstanceStatusChangedMessage>,
|
||||||
|
IRecipient<JobCreatedMessage>,
|
||||||
|
IRecipient<JobCompletedMessage>
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<InstancesViewModel> _logger;
|
||||||
|
private readonly IServerApiClient? _serverApi;
|
||||||
|
|
||||||
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
|
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
|
||||||
[ObservableProperty] private LiveStackItem? _selectedInstance;
|
[ObservableProperty] private LiveStackItem? _selectedInstance;
|
||||||
@@ -30,12 +42,105 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||||
|
|
||||||
|
// ── P1 Authentik Banner ──────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private bool _isAuthentikP1BannerVisible;
|
||||||
|
[ObservableProperty] private string _authentikP1Message = string.Empty;
|
||||||
|
|
||||||
|
// ── Container Logs ──────────────────────────────────────────────────────
|
||||||
|
[ObservableProperty] private ObservableCollection<ServiceLogEntry> _logEntries = new();
|
||||||
|
[ObservableProperty] private ObservableCollection<string> _logServiceFilter = new();
|
||||||
|
[ObservableProperty] private string _selectedLogService = "All Services";
|
||||||
|
[ObservableProperty] private bool _isLogsPanelVisible;
|
||||||
|
[ObservableProperty] private bool _isLogsAutoRefresh = true;
|
||||||
|
[ObservableProperty] private bool _isLoadingLogs;
|
||||||
|
[ObservableProperty] private string _logsStatusMessage = string.Empty;
|
||||||
|
[ObservableProperty] private int _logTailLines = 200;
|
||||||
|
|
||||||
|
private DispatcherTimer? _logRefreshTimer;
|
||||||
|
private bool _isLogRefreshRunning;
|
||||||
|
|
||||||
|
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
|
||||||
|
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback the View wires up to show a confirmation dialog.
|
||||||
|
/// Parameters: (title, message) → returns true if the user confirmed.
|
||||||
|
/// </summary>
|
||||||
|
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback the View wires up to show a multi-step confirmation dialog for decommission.
|
||||||
|
/// Parameters: (abbreviation) → returns true if confirmed through all steps.
|
||||||
|
/// </summary>
|
||||||
|
public Func<string, Task<bool>>? ConfirmDecommissionAsync { get; set; }
|
||||||
|
|
||||||
|
private string? _pendingSelectAbbrev;
|
||||||
|
|
||||||
public InstancesViewModel(IServiceProvider services)
|
public InstancesViewModel(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
|
_logger = services.GetRequiredService<ILogger<InstancesViewModel>>();
|
||||||
|
_serverApi = services.GetService<IServerApiClient>();
|
||||||
|
|
||||||
|
// Register for SignalR messages via WeakReferenceMessenger
|
||||||
|
WeakReferenceMessenger.Default.Register<AlertRaisedMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<InstanceStatusChangedMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<JobCreatedMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<JobCompletedMessage>(this);
|
||||||
|
|
||||||
_ = RefreshAllAsync();
|
_ = RefreshAllAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an abbreviation to be auto-selected once the next live refresh completes.
|
||||||
|
/// Call immediately after construction (before <see cref="RefreshAllAsync"/> finishes).
|
||||||
|
/// </summary>
|
||||||
|
public void SetPendingSelection(string abbrev)
|
||||||
|
=> _pendingSelectAbbrev = abbrev;
|
||||||
|
|
||||||
|
// ── SignalR Message Handlers ─────────────────────────────────────────────
|
||||||
|
// These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread).
|
||||||
|
|
||||||
|
void IRecipient<AlertRaisedMessage>.Receive(AlertRaisedMessage message)
|
||||||
|
{
|
||||||
|
var (severity, msg) = message.Value;
|
||||||
|
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
msg.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AuthentikP1Message = msg;
|
||||||
|
IsAuthentikP1BannerVisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void IRecipient<InstanceStatusChangedMessage>.Receive(InstanceStatusChangedMessage message)
|
||||||
|
{
|
||||||
|
var (customerId, status) = message.Value;
|
||||||
|
_logger.LogInformation("Instance status changed: customer={CustomerId} status={Status}", customerId, status);
|
||||||
|
StatusMessage = $"Instance {customerId} status → {status}";
|
||||||
|
// Refresh the list to pick up the new status
|
||||||
|
_ = RefreshAllAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void IRecipient<JobCreatedMessage>.Receive(JobCreatedMessage message)
|
||||||
|
{
|
||||||
|
var (jobId, abbrev, jobType) = message.Value;
|
||||||
|
_logger.LogInformation("Job created: {JobId} type={JobType} abbrev={Abbrev}", jobId, jobType, abbrev);
|
||||||
|
StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)";
|
||||||
|
}
|
||||||
|
|
||||||
|
void IRecipient<JobCompletedMessage>.Receive(JobCompletedMessage message)
|
||||||
|
{
|
||||||
|
var (jobId, success, summary) = message.Value;
|
||||||
|
_logger.LogInformation("Job completed: {JobId} success={Success} summary={Summary}", jobId, success, summary);
|
||||||
|
StatusMessage = success
|
||||||
|
? $"Job {jobId[..8]}… completed: {summary}"
|
||||||
|
: $"Job {jobId[..8]}… failed: {summary}";
|
||||||
|
// Refresh the instance list to reflect changes from the completed job
|
||||||
|
_ = RefreshAllAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load / Refresh ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
||||||
/// live instance list. Only stacks matching *-cms-stack are shown.
|
/// live instance list. Only stacks matching *-cms-stack are shown.
|
||||||
@@ -79,6 +184,25 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
|
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with server-side customer IDs if the server API is available
|
||||||
|
if (_serverApi is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fleet = await _serverApi.GetFleetAsync();
|
||||||
|
var lookup = fleet.ToDictionary(f => f.Abbreviation, f => f.CustomerId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var item in all)
|
||||||
|
{
|
||||||
|
if (lookup.TryGetValue(item.CustomerAbbrev, out var customerId))
|
||||||
|
item.CustomerId = customerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not enrich instances with server fleet data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||||
all = all.Where(i =>
|
all = all.Where(i =>
|
||||||
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -86,6 +210,15 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
|
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
Instances = new ObservableCollection<LiveStackItem>(all);
|
Instances = new ObservableCollection<LiveStackItem>(all);
|
||||||
|
|
||||||
|
// Auto-select a pending instance (e.g. just deployed from Create Instance page)
|
||||||
|
if (_pendingSelectAbbrev is not null)
|
||||||
|
{
|
||||||
|
SelectedInstance = all.FirstOrDefault(i =>
|
||||||
|
i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_pendingSelectAbbrev = null;
|
||||||
|
}
|
||||||
|
|
||||||
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
|
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
|
||||||
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
|
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
|
||||||
StatusMessage = msg;
|
StatusMessage = msg;
|
||||||
@@ -107,11 +240,181 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||||
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
||||||
|
|
||||||
|
// Populate service filter dropdown and show logs panel
|
||||||
|
var filterItems = new List<string> { "All Services" };
|
||||||
|
filterItems.AddRange(services.Select(s => s.Name));
|
||||||
|
LogServiceFilter = new ObservableCollection<string>(filterItems);
|
||||||
|
SelectedLogService = "All Services";
|
||||||
|
IsLogsPanelVisible = true;
|
||||||
|
|
||||||
|
// Fetch initial logs and start auto-refresh
|
||||||
|
await FetchLogsInternalAsync();
|
||||||
|
StartLogAutoRefresh();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
|
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Container Log Commands ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefreshLogsAsync()
|
||||||
|
{
|
||||||
|
await FetchLogsInternalAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleLogsAutoRefresh()
|
||||||
|
{
|
||||||
|
IsLogsAutoRefresh = !IsLogsAutoRefresh;
|
||||||
|
if (IsLogsAutoRefresh)
|
||||||
|
StartLogAutoRefresh();
|
||||||
|
else
|
||||||
|
StopLogAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseLogsPanel()
|
||||||
|
{
|
||||||
|
StopLogAutoRefresh();
|
||||||
|
IsLogsPanelVisible = false;
|
||||||
|
LogEntries = new ObservableCollection<ServiceLogEntry>();
|
||||||
|
LogsStatusMessage = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedLogServiceChanged(string value)
|
||||||
|
{
|
||||||
|
// When user changes the service filter, refresh logs immediately
|
||||||
|
if (IsLogsPanelVisible)
|
||||||
|
_ = FetchLogsInternalAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchLogsInternalAsync()
|
||||||
|
{
|
||||||
|
if (SelectedInstance == null || _isLogRefreshRunning) return;
|
||||||
|
|
||||||
|
_isLogRefreshRunning = true;
|
||||||
|
IsLoadingLogs = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
dockerCli.SetHost(SelectedInstance.Host);
|
||||||
|
|
||||||
|
string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService;
|
||||||
|
var entries = await dockerCli.GetServiceLogsAsync(
|
||||||
|
SelectedInstance.StackName, serviceFilter, LogTailLines);
|
||||||
|
|
||||||
|
LogEntries = new ObservableCollection<ServiceLogEntry>(entries);
|
||||||
|
LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogsStatusMessage = $"Error fetching logs: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoadingLogs = false;
|
||||||
|
_isLogRefreshRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartLogAutoRefresh()
|
||||||
|
{
|
||||||
|
StopLogAutoRefresh();
|
||||||
|
if (!IsLogsAutoRefresh) return;
|
||||||
|
|
||||||
|
_logRefreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(5)
|
||||||
|
};
|
||||||
|
_logRefreshTimer.Tick += async (_, _) =>
|
||||||
|
{
|
||||||
|
if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning)
|
||||||
|
await FetchLogsInternalAsync();
|
||||||
|
};
|
||||||
|
_logRefreshTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopLogAutoRefresh()
|
||||||
|
{
|
||||||
|
_logRefreshTimer?.Stop();
|
||||||
|
_logRefreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Restart Commands ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestartStackAsync()
|
||||||
|
{
|
||||||
|
if (SelectedInstance == null) return;
|
||||||
|
|
||||||
|
if (ConfirmAsync is not null)
|
||||||
|
{
|
||||||
|
var confirmed = await ConfirmAsync(
|
||||||
|
"Restart Stack",
|
||||||
|
$"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
dockerCli.SetHost(SelectedInstance.Host);
|
||||||
|
|
||||||
|
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
for (var i = 0; i < services.Count; i++)
|
||||||
|
{
|
||||||
|
var svc = services[i];
|
||||||
|
StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}...";
|
||||||
|
var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name);
|
||||||
|
if (!ok) failures.Add(svc.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusMessage = failures.Count == 0
|
||||||
|
? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully."
|
||||||
|
: $"Restarted with errors — failed services: {string.Join(", ", failures)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; }
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestartServiceAsync(ServiceInfo? service)
|
||||||
|
{
|
||||||
|
if (service is null || SelectedInstance is null) return;
|
||||||
|
|
||||||
|
if (ConfirmAsync is not null)
|
||||||
|
{
|
||||||
|
var confirmed = await ConfirmAsync(
|
||||||
|
"Restart Service",
|
||||||
|
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = $"Restarting service '{service.Name}'...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
dockerCli.SetHost(SelectedInstance.Host);
|
||||||
|
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
|
||||||
|
StatusMessage = ok
|
||||||
|
? $"Service '{service.Name}' restarted successfully."
|
||||||
|
: $"Failed to restart service '{service.Name}'.";
|
||||||
|
|
||||||
|
// Refresh services to show updated replica status
|
||||||
|
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||||
|
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; }
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteInstanceAsync()
|
private async Task DeleteInstanceAsync()
|
||||||
{
|
{
|
||||||
@@ -158,4 +461,167 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
|
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── P1 Banner Commands ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void DismissP1Banner()
|
||||||
|
{
|
||||||
|
IsAuthentikP1BannerVisible = false;
|
||||||
|
AuthentikP1Message = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called from a SignalR <c>AlertRaised</c> handler (runs on a background thread).
|
||||||
|
/// CRITICAL: wraps all property updates with <see cref="Dispatcher.UIThread"/> to
|
||||||
|
/// avoid silent cross-thread exceptions in Avalonia.
|
||||||
|
/// </summary>
|
||||||
|
public void HandleAlertRaised(string severity, string message)
|
||||||
|
{
|
||||||
|
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
message.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
AuthentikP1Message = message;
|
||||||
|
IsAuthentikP1BannerVisible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server-side Job Commands (decommission, suspend, reactivate) ────────
|
||||||
|
// Desktop has NO direct infrastructure access — all operations go through the server REST API.
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DecommissionAsync(LiveStackItem? instance)
|
||||||
|
{
|
||||||
|
instance ??= SelectedInstance;
|
||||||
|
if (instance is null) return;
|
||||||
|
|
||||||
|
if (instance.CustomerId is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Cannot decommission: no server-side customer ID available for this instance.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-step confirmation: user must type the abbreviation to confirm
|
||||||
|
if (ConfirmDecommissionAsync is not null)
|
||||||
|
{
|
||||||
|
var confirmed = await ConfirmDecommissionAsync(instance.CustomerAbbrev);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
else if (ConfirmAsync is not null)
|
||||||
|
{
|
||||||
|
var confirmed = await ConfirmAsync(
|
||||||
|
"Decommission Instance",
|
||||||
|
$"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" +
|
||||||
|
"This will:\n" +
|
||||||
|
" • Remove all Docker services and stack\n" +
|
||||||
|
" • Delete Docker secrets\n" +
|
||||||
|
" • Remove NFS volumes and data\n" +
|
||||||
|
" • Revoke Authentik provider\n" +
|
||||||
|
" • Mark the customer as decommissioned\n\n" +
|
||||||
|
"This action is IRREVERSIBLE.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateServerJobAsync(instance, "decommission");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SuspendInstanceAsync(LiveStackItem? instance)
|
||||||
|
{
|
||||||
|
instance ??= SelectedInstance;
|
||||||
|
if (instance is null) return;
|
||||||
|
|
||||||
|
if (instance.CustomerId is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Cannot suspend: no server-side customer ID available for this instance.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConfirmAsync is not null)
|
||||||
|
{
|
||||||
|
var confirmed = await ConfirmAsync(
|
||||||
|
"Suspend Instance",
|
||||||
|
$"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" +
|
||||||
|
"The instance will be scaled to zero replicas. Data will be preserved.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateServerJobAsync(instance, "suspend");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ReactivateInstanceAsync(LiveStackItem? instance)
|
||||||
|
{
|
||||||
|
instance ??= SelectedInstance;
|
||||||
|
if (instance is null) return;
|
||||||
|
|
||||||
|
if (instance.CustomerId is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateServerJobAsync(instance, "reactivate");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateServerJobAsync(LiveStackItem instance, string jobType)
|
||||||
|
{
|
||||||
|
if (_serverApi is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Server API client is not configured.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _serverApi.CreateJobAsync(
|
||||||
|
new CreateJobRequest(instance.CustomerId!.Value, jobType, null));
|
||||||
|
StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}";
|
||||||
|
_logger.LogInformation("Server job created: {JobId} type={JobType} customer={CustomerId}",
|
||||||
|
response.Id, jobType, instance.CustomerId);
|
||||||
|
}
|
||||||
|
catch (Refit.ApiException ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}";
|
||||||
|
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error creating '{jobType}' job: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Details ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenDetailsAsync()
|
||||||
|
{
|
||||||
|
if (SelectedInstance == null) return;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = $"Loading details for '{SelectedInstance.StackName}'...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Set the SSH host on singleton Docker services so modal operations target the right host
|
||||||
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||||
|
dockerCli.SetHost(SelectedInstance.Host);
|
||||||
|
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||||
|
dockerSecrets.SetHost(SelectedInstance.Host);
|
||||||
|
|
||||||
|
var detailsVm = _services.GetRequiredService<InstanceDetailsViewModel>();
|
||||||
|
await detailsVm.LoadAsync(SelectedInstance);
|
||||||
|
|
||||||
|
OpenDetailsRequested?.Invoke(detailsVm);
|
||||||
|
StatusMessage = string.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; }
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigates to the Instances page and auto-selects the instance with the given abbreviation
|
||||||
|
/// once the live refresh completes.
|
||||||
|
/// </summary>
|
||||||
|
public void NavigateToInstancesWithSelection(string abbrev)
|
||||||
|
{
|
||||||
|
SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances")
|
||||||
|
if (CurrentView is InstancesViewModel instancesVm)
|
||||||
|
instancesVm.SetPendingSelection(abbrev);
|
||||||
|
}
|
||||||
|
|
||||||
public void SetStatus(string message)
|
public void SetStatus(string message)
|
||||||
{
|
{
|
||||||
StatusMessage = message;
|
StatusMessage = message;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||||
using OTSSignsOrchestrator.Core.Services;
|
using OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
@@ -51,7 +56,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
||||||
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
||||||
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
||||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
|
[ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com";
|
||||||
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
|
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
|
||||||
@@ -59,20 +64,78 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
|
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
|
||||||
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
||||||
|
|
||||||
|
// ── Bitwarden Secrets Manager ─────────────────────────────────
|
||||||
|
[ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com";
|
||||||
|
[ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com";
|
||||||
|
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
|
||||||
|
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
|
||||||
|
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
|
||||||
|
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
|
||||||
|
|
||||||
|
// ── Authentik (SAML IdP) ────────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _authentikUrl = string.Empty;
|
||||||
|
[ObservableProperty] private string _authentikApiKey = string.Empty;
|
||||||
|
[ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty;
|
||||||
|
[ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty;
|
||||||
|
[ObservableProperty] private string _authentikSigningKeypairId = string.Empty;
|
||||||
|
[ObservableProperty] private string _authentikStatusMessage = string.Empty;
|
||||||
|
[ObservableProperty] private bool _isAuthentikBusy;
|
||||||
|
|
||||||
|
// Dropdown collections for Authentik flows / keypairs
|
||||||
|
public ObservableCollection<AuthentikFlowItem> AuthentikAuthorizationFlows { get; } = new();
|
||||||
|
public ObservableCollection<AuthentikFlowItem> AuthentikInvalidationFlows { get; } = new();
|
||||||
|
public ObservableCollection<AuthentikKeypairItem> AuthentikKeypairs { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow;
|
||||||
|
[ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow;
|
||||||
|
[ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair;
|
||||||
|
|
||||||
|
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
|
||||||
|
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
||||||
|
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
|
||||||
|
|
||||||
public SettingsViewModel(IServiceProvider services)
|
public SettingsViewModel(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
_ = LoadAsync();
|
_ = LoadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether Bitwarden is configured and reachable.</summary>
|
||||||
|
[ObservableProperty] private bool _isBitwardenConfigured;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoadAsync()
|
private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false);
|
||||||
|
|
||||||
|
private async Task LoadCoreAsync(bool skipBitwarden)
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!skipBitwarden)
|
||||||
|
{
|
||||||
|
// ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ──
|
||||||
|
var bwOptions = _services.GetRequiredService<IOptionsMonitor<BitwardenOptions>>().CurrentValue;
|
||||||
|
BitwardenIdentityUrl = bwOptions.IdentityUrl;
|
||||||
|
BitwardenApiUrl = bwOptions.ApiUrl;
|
||||||
|
BitwardenAccessToken = bwOptions.AccessToken;
|
||||||
|
BitwardenOrganizationId = bwOptions.OrganizationId;
|
||||||
|
BitwardenProjectId = bwOptions.ProjectId;
|
||||||
|
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||||
|
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||||
|
|
||||||
|
if (!IsBitwardenConfigured)
|
||||||
|
{
|
||||||
|
StatusMessage = "Bitwarden is not configured. Fill in the Bitwarden section and save to get started.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load all other settings from Bitwarden ──
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
svc.InvalidateCache();
|
||||||
|
|
||||||
// Git
|
// Git
|
||||||
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
|
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
|
||||||
@@ -108,7 +171,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||||
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||||
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||||
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
||||||
@@ -116,7 +179,22 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||||
|
|
||||||
StatusMessage = "Settings loaded.";
|
// Authentik
|
||||||
|
AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty);
|
||||||
|
AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty);
|
||||||
|
AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty);
|
||||||
|
AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty);
|
||||||
|
AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty);
|
||||||
|
|
||||||
|
// If Authentik URL + key are configured, try loading dropdowns
|
||||||
|
if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey))
|
||||||
|
await FetchAuthentikDropdownsInternalAsync();
|
||||||
|
|
||||||
|
// Xibo Bootstrap
|
||||||
|
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
||||||
|
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
||||||
|
|
||||||
|
StatusMessage = "Settings loaded from Bitwarden.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -129,13 +207,51 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SaveAsync()
|
private async Task SaveBitwardenLocalAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await SaveBitwardenConfigToFileAsync();
|
||||||
|
|
||||||
|
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||||
|
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||||
|
|
||||||
|
StatusMessage = IsBitwardenConfigured
|
||||||
|
? "Bitwarden config saved to appsettings.json."
|
||||||
|
: "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error saving Bitwarden config: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PullFromBitwardenAsync()
|
||||||
|
{
|
||||||
|
await LoadCoreAsync(skipBitwarden: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PushToBitwardenAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!IsBitwardenConfigured)
|
||||||
|
{
|
||||||
|
StatusMessage = "Bitwarden is not configured. Save Bitwarden config first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
svc.InvalidateCache();
|
||||||
|
|
||||||
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
||||||
{
|
{
|
||||||
@@ -180,10 +296,21 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
||||||
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
|
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
|
||||||
|
|
||||||
|
// Authentik
|
||||||
|
(SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false),
|
||||||
|
(SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true),
|
||||||
|
(SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false),
|
||||||
|
(SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false),
|
||||||
|
(SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false),
|
||||||
|
|
||||||
|
// Xibo Bootstrap
|
||||||
|
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
||||||
|
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
await svc.SaveManyAsync(settings);
|
await svc.SaveManyAsync(settings);
|
||||||
StatusMessage = "Settings saved successfully.";
|
StatusMessage = "Settings pushed to Bitwarden.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -195,6 +322,198 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task TestBitwardenConnectionAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId))
|
||||||
|
{
|
||||||
|
StatusMessage = "Bitwarden Access Token and Organization ID are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "Saving Bitwarden config and testing connection...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Save to appsettings.json first so the service picks up fresh values
|
||||||
|
await SaveBitwardenConfigToFileAsync();
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var secrets = await bws.ListSecretsAsync();
|
||||||
|
IsBitwardenConfigured = true;
|
||||||
|
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
IsBitwardenConfigured = false;
|
||||||
|
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Authentik: save, test, fetch dropdowns
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveAndTestAuthentikAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey))
|
||||||
|
{
|
||||||
|
AuthentikStatusMessage = "Authentik URL and API Token are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsAuthentikBusy = true;
|
||||||
|
AuthentikStatusMessage = "Saving Authentik settings and testing connection...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Persist URL + API key first so subsequent calls work
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
await svc.SetAsync(SettingsService.AuthentikUrl, AuthentikUrl.Trim(), SettingsService.CatAuthentik);
|
||||||
|
await svc.SetAsync(SettingsService.AuthentikApiKey, AuthentikApiKey.Trim(), SettingsService.CatAuthentik, isSensitive: true);
|
||||||
|
|
||||||
|
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||||
|
var (ok, msg) = await authentik.TestConnectionAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
AuthentikStatusMessage = $"Connection failed: {msg}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthentikStatusMessage = "Connected — loading flows and keypairs...";
|
||||||
|
|
||||||
|
// Now fetch dropdowns
|
||||||
|
await FetchAuthentikDropdownsInternalAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
|
||||||
|
|
||||||
|
// Save selected flow/keypair values
|
||||||
|
var authSlug = SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug;
|
||||||
|
var invalSlug = SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug;
|
||||||
|
var kpId = SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId;
|
||||||
|
|
||||||
|
await svc.SetAsync(SettingsService.AuthentikAuthorizationFlowSlug, authSlug, SettingsService.CatAuthentik);
|
||||||
|
await svc.SetAsync(SettingsService.AuthentikInvalidationFlowSlug, invalSlug, SettingsService.CatAuthentik);
|
||||||
|
await svc.SetAsync(SettingsService.AuthentikSigningKeypairId, kpId, SettingsService.CatAuthentik);
|
||||||
|
|
||||||
|
AuthentikStatusMessage = $"Authentik connected. {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s) loaded.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AuthentikStatusMessage = $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsAuthentikBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task FetchAuthentikDropdownsAsync()
|
||||||
|
{
|
||||||
|
IsAuthentikBusy = true;
|
||||||
|
AuthentikStatusMessage = "Fetching flows and keypairs from Authentik...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchAuthentikDropdownsInternalAsync();
|
||||||
|
AuthentikStatusMessage = $"Loaded {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s).";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AuthentikStatusMessage = $"Error fetching data: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsAuthentikBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchAuthentikDropdownsInternalAsync(
|
||||||
|
string? overrideUrl = null, string? overrideApiKey = null)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||||
|
|
||||||
|
var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey);
|
||||||
|
var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey);
|
||||||
|
|
||||||
|
// Populate authorization flows (designation = "authorization")
|
||||||
|
AuthentikAuthorizationFlows.Clear();
|
||||||
|
foreach (var f in flows.Where(f => f.Designation == "authorization"))
|
||||||
|
AuthentikAuthorizationFlows.Add(f);
|
||||||
|
|
||||||
|
// Populate invalidation flows (designation = "invalidation")
|
||||||
|
AuthentikInvalidationFlows.Clear();
|
||||||
|
foreach (var f in flows.Where(f => f.Designation == "invalidation"))
|
||||||
|
AuthentikInvalidationFlows.Add(f);
|
||||||
|
|
||||||
|
// Populate keypairs
|
||||||
|
AuthentikKeypairs.Clear();
|
||||||
|
// Add a "None" option
|
||||||
|
AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" });
|
||||||
|
foreach (var k in keypairs)
|
||||||
|
AuthentikKeypairs.Add(k);
|
||||||
|
|
||||||
|
// Select items matching saved slugs
|
||||||
|
SelectedAuthorizationFlow = AuthentikAuthorizationFlows
|
||||||
|
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent")
|
||||||
|
?? AuthentikAuthorizationFlows.FirstOrDefault();
|
||||||
|
|
||||||
|
SelectedInvalidationFlow = AuthentikInvalidationFlows
|
||||||
|
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow")
|
||||||
|
?? AuthentikInvalidationFlows.FirstOrDefault();
|
||||||
|
|
||||||
|
SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId)
|
||||||
|
? AuthentikKeypairs.First() // "(none)"
|
||||||
|
: AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId)
|
||||||
|
?? AuthentikKeypairs.First();
|
||||||
|
|
||||||
|
// Update slug fields to match selection
|
||||||
|
if (SelectedAuthorizationFlow != null)
|
||||||
|
AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug;
|
||||||
|
if (SelectedInvalidationFlow != null)
|
||||||
|
AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug;
|
||||||
|
if (SelectedSigningKeypair != null)
|
||||||
|
AuthentikSigningKeypairId = SelectedSigningKeypair.Pk;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task TestXiboBootstrapAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret))
|
||||||
|
{
|
||||||
|
StatusMessage = "Xibo Bootstrap Client ID and Secret are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "Testing Xibo bootstrap credentials...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||||
|
// Use a placeholder URL — user must configure a live instance for full test
|
||||||
|
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task TestMySqlConnectionAsync()
|
private async Task TestMySqlConnectionAsync()
|
||||||
{
|
{
|
||||||
@@ -233,6 +552,33 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveBitwardenConfigToFileAsync()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||||
|
var json = await File.ReadAllTextAsync(path);
|
||||||
|
var doc = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip })!;
|
||||||
|
|
||||||
|
var bw = doc["Bitwarden"]?.AsObject();
|
||||||
|
if (bw == null)
|
||||||
|
{
|
||||||
|
bw = new JsonObject();
|
||||||
|
doc.AsObject()["Bitwarden"] = bw;
|
||||||
|
}
|
||||||
|
|
||||||
|
bw["IdentityUrl"] = BitwardenIdentityUrl;
|
||||||
|
bw["ApiUrl"] = BitwardenApiUrl;
|
||||||
|
bw["AccessToken"] = BitwardenAccessToken;
|
||||||
|
bw["OrganizationId"] = BitwardenOrganizationId;
|
||||||
|
bw["ProjectId"] = BitwardenProjectId;
|
||||||
|
bw["InstanceProjectId"] = BitwardenInstanceProjectId;
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
await File.WriteAllTextAsync(path, doc.ToJsonString(options));
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NullIfEmpty(string? value)
|
private static string? NullIfEmpty(string? value)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
27
OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml
Normal file
27
OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="OTSSignsOrchestrator.Desktop.Views.ConfirmationDialog"
|
||||||
|
Title="Confirm"
|
||||||
|
Width="420" Height="200"
|
||||||
|
MinWidth="320" MinHeight="160"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
SizeToContent="Height">
|
||||||
|
|
||||||
|
<DockPanel Margin="24">
|
||||||
|
<!-- Buttons -->
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right" Spacing="10" Margin="0,16,0,0">
|
||||||
|
<Button Content="Cancel" Name="CancelButton" Width="90" />
|
||||||
|
<Button Content="Confirm" Name="ConfirmButton" Classes="accent" Width="90" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Name="TitleText" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource AccentBrush}" />
|
||||||
|
<TextBlock Name="MessageText" FontSize="13" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A simple Yes/No confirmation dialog that can be shown modally.
|
||||||
|
/// Use <see cref="ShowAsync"/> for a convenient one-liner.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ConfirmationDialog : Window
|
||||||
|
{
|
||||||
|
public bool Result { get; private set; }
|
||||||
|
|
||||||
|
public ConfirmationDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConfirmationDialog(string title, string message) : this()
|
||||||
|
{
|
||||||
|
TitleText.Text = title;
|
||||||
|
MessageText.Text = message;
|
||||||
|
Title = title;
|
||||||
|
|
||||||
|
ConfirmButton.Click += OnConfirmClicked;
|
||||||
|
CancelButton.Click += OnCancelClicked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConfirmClicked(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Result = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancelClicked(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Result = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a modal confirmation dialog and returns true if the user confirmed.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<bool> ShowAsync(Window owner, string title, string message)
|
||||||
|
{
|
||||||
|
var dialog = new ConfirmationDialog(title, message);
|
||||||
|
await dialog.ShowDialog(owner);
|
||||||
|
return dialog.Result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,20 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
<!-- Advanced options -->
|
||||||
|
<Expander Header="Advanced options">
|
||||||
|
<Border Classes="card" Margin="0,8,0,0">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<CheckBox IsChecked="{Binding PurgeStaleVolumes}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Purge stale volumes before deploying" FontSize="12" />
|
||||||
|
<TextBlock Text="Removes existing Docker volumes for this stack so fresh volumes are created. Only needed if volumes were created with wrong settings." FontSize="11" Foreground="{StaticResource TextMutedBrush}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</CheckBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
<!-- Deploy button + progress -->
|
<!-- Deploy button + progress -->
|
||||||
<Button Content="Deploy Instance"
|
<Button Content="Deploy Instance"
|
||||||
Classes="accent"
|
Classes="accent"
|
||||||
|
|||||||
240
OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml
Normal file
240
OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||||
|
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
|
||||||
|
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
|
||||||
|
x:Class="OTSSignsOrchestrator.Desktop.Views.InstanceDetailsWindow"
|
||||||
|
x:DataType="vm:InstanceDetailsViewModel"
|
||||||
|
Title="Instance Details"
|
||||||
|
Width="620" Height="860"
|
||||||
|
MinWidth="520" MinHeight="700"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="True">
|
||||||
|
|
||||||
|
<DockPanel Margin="24">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,16">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="{Binding StackName}" FontSize="22" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource AccentBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding HostLabel, StringFormat='Host: {0}'}"
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,2,0,0" />
|
||||||
|
<TextBlock Text="{Binding InstanceUrl}"
|
||||||
|
FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||||
|
Margin="0,12,0,0" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Main scrollable content -->
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
|
||||||
|
<!-- ═══ Pending Setup Banner ═══ -->
|
||||||
|
<Border IsVisible="{Binding IsPendingSetup}"
|
||||||
|
Background="#1F2A1A" BorderBrush="#4ADE80" BorderThickness="1"
|
||||||
|
CornerRadius="8" Padding="14,10">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="⚙" FontSize="18" VerticalAlignment="Center" Foreground="#4ADE80" />
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Pending Setup" FontSize="14" FontWeight="SemiBold"
|
||||||
|
Foreground="#4ADE80" />
|
||||||
|
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Enter your Xibo OAuth credentials below to complete instance initialisation." />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ OTS Admin Account ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
|
||||||
|
<TextBlock Text="OTS Admin Account" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#F97316" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding AdminUsername}" IsReadOnly="True" />
|
||||||
|
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||||
|
Command="{Binding CopyAdminPasswordCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding AdminPasswordDisplay}" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas,monospace" />
|
||||||
|
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||||
|
IsVisible="{Binding !AdminPasswordVisible}"
|
||||||
|
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
|
||||||
|
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||||
|
IsVisible="{Binding AdminPasswordVisible}"
|
||||||
|
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
|
||||||
|
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||||
|
Command="{Binding CopyAdminPasswordCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button Content="Rotate Admin Password"
|
||||||
|
Command="{Binding RotateAdminPasswordCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
Classes="accent"
|
||||||
|
Margin="0,6,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Database Credentials ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#4ADE80" />
|
||||||
|
<TextBlock Text="Database Credentials" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#4ADE80" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="MySQL Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<Grid ColumnDefinitions="*">
|
||||||
|
<TextBox Text="{Binding DbUsername}" IsReadOnly="True" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="MySQL Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding DbPasswordDisplay}" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas,monospace" />
|
||||||
|
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||||
|
IsVisible="{Binding !DbPasswordVisible}"
|
||||||
|
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
|
||||||
|
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||||
|
IsVisible="{Binding DbPasswordVisible}"
|
||||||
|
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
|
||||||
|
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||||
|
Command="{Binding CopyDbPasswordCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button Content="Rotate DB Password"
|
||||||
|
Command="{Binding RotateDbPasswordCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
Margin="0,6,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Xibo OAuth2 Application ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#60A5FA" />
|
||||||
|
<TextBlock Text="OTS OAuth2 Application" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#60A5FA" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Client credentials used by the OTS orchestrator for Xibo API access."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- ── Pending: editable credential input ── -->
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding IsPendingSetup}">
|
||||||
|
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Log into the Xibo CMS as xibo_admin (password: password), go to Administration → Applications, create a client_credentials app, then paste the credentials here." />
|
||||||
|
|
||||||
|
<TextBlock Text="Client ID" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||||
|
<TextBox Text="{Binding InitClientId}" Watermark="OAuth2 Client ID" />
|
||||||
|
|
||||||
|
<TextBlock Text="Client Secret" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding InitClientSecret}" PasswordChar="●"
|
||||||
|
Watermark="(paste from Xibo Applications page)" />
|
||||||
|
|
||||||
|
<Button Content="Initialize Instance"
|
||||||
|
Command="{Binding InitializeCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
Classes="accent"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Center"
|
||||||
|
Padding="14,10" FontSize="14"
|
||||||
|
Margin="0,8,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ── Initialized: read-only display ── -->
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding !IsPendingSetup}">
|
||||||
|
<TextBlock Text="Client ID" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas,monospace" />
|
||||||
|
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||||
|
Command="{Binding CopyOAuthClientIdCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Client Secret" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas,monospace" />
|
||||||
|
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||||
|
IsVisible="{Binding !OAuthSecretVisible}"
|
||||||
|
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||||
|
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||||
|
IsVisible="{Binding OAuthSecretVisible}"
|
||||||
|
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||||
|
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||||
|
Command="{Binding CopyOAuthSecretCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Stack Services ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#A78BFA" />
|
||||||
|
<TextBlock Text="Stack Services" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#A78BFA" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Force-restart individual services within this stack."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<TextBlock Text="Loading services..." FontSize="12"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
IsVisible="{Binding IsLoadingServices}" />
|
||||||
|
|
||||||
|
<!-- Services list -->
|
||||||
|
<ItemsControl ItemsSource="{Binding StackServices}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="svc:ServiceInfo">
|
||||||
|
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
||||||
|
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
||||||
|
<TextBlock Text="{Binding Image}" FontSize="11"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}" />
|
||||||
|
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||||
|
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="Restart"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).RestartServiceCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsEnabled="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="12" Padding="10,6"
|
||||||
|
ToolTip.Tip="Force-restart this service" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||||
|
|
||||||
|
public partial class InstanceDetailsWindow : Window
|
||||||
|
{
|
||||||
|
public InstanceDetailsWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is InstanceDetailsViewModel vm)
|
||||||
|
{
|
||||||
|
vm.ConfirmAsync = async (title, message) =>
|
||||||
|
await ConfirmationDialog.ShowAsync(this, title, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||||
|
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
|
||||||
|
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
|
||||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
||||||
x:DataType="vm:InstancesViewModel">
|
x:DataType="vm:InstancesViewModel">
|
||||||
|
|
||||||
@@ -16,7 +18,13 @@
|
|||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
||||||
|
<Button Content="Details" Classes="accent" Command="{Binding OpenDetailsCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
ToolTip.Tip="View credentials and manage this instance." />
|
||||||
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
|
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
|
||||||
|
<Button Content="Restart Stack" Command="{Binding RestartStackCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
ToolTip.Tip="Force-restart all services in the selected stack." />
|
||||||
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
|
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
|
||||||
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
|
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
|
||||||
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
|
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
|
||||||
@@ -34,45 +42,127 @@
|
|||||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||||
Margin="0,10,0,0" />
|
Margin="0,10,0,0" />
|
||||||
|
|
||||||
<!-- Services panel (shown when inspecting) -->
|
<!-- Main content: split into upper (grid + services) and lower (logs) -->
|
||||||
<Border DockPanel.Dock="Right" Width="360"
|
<Grid RowDefinitions="*,Auto,Auto">
|
||||||
IsVisible="{Binding SelectedServices.Count}"
|
|
||||||
Classes="card" Margin="16,0,0,0">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="Stack Services" Classes="sectionTitle"
|
|
||||||
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
|
|
||||||
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
|
||||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
|
||||||
<StackPanel Spacing="3">
|
|
||||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
|
||||||
<TextBlock Text="{Binding Image}" FontSize="11"
|
|
||||||
Foreground="{StaticResource TextMutedBrush}" />
|
|
||||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
|
||||||
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Instance list -->
|
<!-- Upper area: instance list + services side panel -->
|
||||||
<DataGrid ItemsSource="{Binding Instances}"
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||||
SelectedItem="{Binding SelectedInstance}"
|
|
||||||
AutoGenerateColumns="False"
|
<!-- Instance list -->
|
||||||
IsReadOnly="True"
|
<DataGrid Grid.Column="0"
|
||||||
GridLinesVisibility="Horizontal"
|
ItemsSource="{Binding Instances}"
|
||||||
CanUserResizeColumns="True">
|
SelectedItem="{Binding SelectedInstance}"
|
||||||
<DataGrid.Columns>
|
AutoGenerateColumns="False"
|
||||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
IsReadOnly="True"
|
||||||
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
GridLinesVisibility="Horizontal"
|
||||||
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
CanUserResizeColumns="True">
|
||||||
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
<DataGrid.Columns>
|
||||||
</DataGrid.Columns>
|
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
||||||
</DataGrid>
|
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
||||||
|
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
||||||
|
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
|
||||||
|
<!-- Services panel (shown when inspecting) -->
|
||||||
|
<Border Grid.Column="1" Width="360"
|
||||||
|
IsVisible="{Binding SelectedServices.Count}"
|
||||||
|
Classes="card" Margin="16,0,0,0">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Stack Services" Classes="sectionTitle"
|
||||||
|
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="svc:ServiceInfo">
|
||||||
|
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
||||||
|
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
||||||
|
<TextBlock Text="{Binding Image}" FontSize="11"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}" />
|
||||||
|
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||||
|
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="Restart"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).RestartServiceCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsEnabled="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="12" Padding="10,6"
|
||||||
|
ToolTip.Tip="Force-restart this service" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Grid splitter between instances and logs -->
|
||||||
|
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch"
|
||||||
|
IsVisible="{Binding IsLogsPanelVisible}"
|
||||||
|
Background="Transparent" />
|
||||||
|
|
||||||
|
<!-- Container Logs Panel -->
|
||||||
|
<Border Grid.Row="2" Classes="card" Margin="0,4,0,0"
|
||||||
|
IsVisible="{Binding IsLogsPanelVisible}"
|
||||||
|
MinHeight="180" MaxHeight="400">
|
||||||
|
<DockPanel>
|
||||||
|
<!-- Logs toolbar -->
|
||||||
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,8">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Container Logs" Classes="sectionTitle"
|
||||||
|
Foreground="{StaticResource AccentBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding LogServiceFilter}"
|
||||||
|
SelectedItem="{Binding SelectedLogService}"
|
||||||
|
MinWidth="200" FontSize="12"
|
||||||
|
ToolTip.Tip="Filter logs by service" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding LogsStatusMessage}"
|
||||||
|
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button Content="Refresh" Command="{Binding RefreshLogsCommand}"
|
||||||
|
FontSize="11" Padding="8,4"
|
||||||
|
ToolTip.Tip="Fetch latest logs" />
|
||||||
|
<ToggleButton IsChecked="{Binding IsLogsAutoRefresh}"
|
||||||
|
Content="Auto"
|
||||||
|
FontSize="11" Padding="8,4"
|
||||||
|
ToolTip.Tip="Toggle auto-refresh (every 5 seconds)"
|
||||||
|
Command="{Binding ToggleLogsAutoRefreshCommand}" />
|
||||||
|
<Button Content="✕" Command="{Binding CloseLogsPanelCommand}"
|
||||||
|
FontSize="11" Padding="6,4"
|
||||||
|
ToolTip.Tip="Close logs panel" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Log entries list -->
|
||||||
|
<Border Background="#1a1a2e" CornerRadius="4" Padding="8"
|
||||||
|
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding LogEntries}"
|
||||||
|
x:DataType="vm:InstancesViewModel">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="dto:ServiceLogEntry">
|
||||||
|
<TextBlock Text="{Binding DisplayLine}"
|
||||||
|
FontFamily="Cascadia Mono,Consolas,Menlo,monospace"
|
||||||
|
FontSize="11" Padding="0,1"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
Foreground="#cccccc" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||||
|
|
||||||
public partial class InstancesView : UserControl
|
public partial class InstancesView : UserControl
|
||||||
{
|
{
|
||||||
|
private InstancesViewModel? _vm;
|
||||||
|
|
||||||
public InstancesView()
|
public InstancesView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm is not null)
|
||||||
|
_vm.OpenDetailsRequested -= OnOpenDetailsRequested;
|
||||||
|
|
||||||
|
_vm = DataContext as InstancesViewModel;
|
||||||
|
|
||||||
|
if (_vm is not null)
|
||||||
|
{
|
||||||
|
_vm.OpenDetailsRequested += OnOpenDetailsRequested;
|
||||||
|
_vm.ConfirmAsync = ShowConfirmationAsync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ShowConfirmationAsync(string title, string message)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) return false;
|
||||||
|
return await ConfirmationDialog.ShowAsync(owner, title, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnOpenDetailsRequested(InstanceDetailsViewModel detailsVm)
|
||||||
|
{
|
||||||
|
var window = new InstanceDetailsWindow { DataContext = detailsVm };
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is not null)
|
||||||
|
await window.ShowDialog(owner);
|
||||||
|
else
|
||||||
|
window.Show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,14 @@
|
|||||||
<!-- Top toolbar -->
|
<!-- Top toolbar -->
|
||||||
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="Save All Settings"
|
<Button Content="Push to Bitwarden"
|
||||||
Classes="accent"
|
Classes="accent"
|
||||||
Command="{Binding SaveCommand}"
|
Command="{Binding PushToBitwardenCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
IsEnabled="{Binding !IsBusy}"
|
||||||
FontWeight="SemiBold" Padding="20,8" />
|
FontWeight="SemiBold" Padding="20,8" />
|
||||||
<Button Content="Reload" Command="{Binding LoadCommand}" IsEnabled="{Binding !IsBusy}" />
|
<Button Content="Pull from Bitwarden"
|
||||||
|
Command="{Binding PullFromBitwardenCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
||||||
VerticalAlignment="Center" Margin="6,0,0,0" />
|
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -29,6 +31,64 @@
|
|||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
|
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
|
||||||
|
|
||||||
|
<!-- ═══ Bitwarden Secrets Manager (Bootstrap — always shown first) ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
||||||
|
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#818CF8" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="All application settings are stored in Bitwarden. Configure these credentials first — they are saved to appsettings.json on disk."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="1*,12,1*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
||||||
|
Watermark="https://identity.bitwarden.com" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenApiUrl}"
|
||||||
|
Watermark="https://api.bitwarden.com" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
||||||
|
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
||||||
|
|
||||||
|
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenOrganizationId}"
|
||||||
|
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||||
|
|
||||||
|
<TextBlock Text="Project ID (required — config secrets are stored in this project)" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding BitwardenProjectId}"
|
||||||
|
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||||
|
|
||||||
|
<TextBlock Text="Instance Project ID (optional — instance secrets like DB passwords go here; falls back to Project ID if empty)" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap" />
|
||||||
|
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
||||||
|
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,6,0,0">
|
||||||
|
<Button Content="Save Bitwarden Config"
|
||||||
|
Classes="accent"
|
||||||
|
Command="{Binding SaveBitwardenLocalCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Content="Test Connection"
|
||||||
|
Command="{Binding TestBitwardenConnectionCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Remaining settings — disabled until Bitwarden is configured ═══ -->
|
||||||
|
<StackPanel Spacing="16" IsEnabled="{Binding IsBitwardenConfigured}">
|
||||||
|
|
||||||
<!-- ═══ Git Repository ═══ -->
|
<!-- ═══ Git Repository ═══ -->
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
@@ -231,6 +291,116 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
|
||||||
|
<TextBlock Text="Xibo Bootstrap OAuth2" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#F97316" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="A pre-configured Xibo OAuth2 client_credentials application used for post-install setup (creating admin users, registering OTS app, setting theme). Create once in the Xibo admin panel of any instance."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="Bootstrap Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding XiboBootstrapClientId}"
|
||||||
|
Watermark="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||||
|
|
||||||
|
<TextBlock Text="Bootstrap Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding XiboBootstrapClientSecret}" PasswordChar="●" />
|
||||||
|
|
||||||
|
<Button Content="Save & Verify"
|
||||||
|
Command="{Binding TestXiboBootstrapCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
Margin="0,6,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ Authentik (SAML IdP) ═══ -->
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||||
|
<Border Width="4" Height="20" CornerRadius="2" Background="#FB923C" />
|
||||||
|
<TextBlock Text="Authentik (SAML IdP)" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="#FB923C" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Authentik identity provider settings. A SAML application is automatically provisioned in Authentik for each new instance during post-init, and a settings-custom.php file is deployed."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="Authentik Base URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding AuthentikUrl}"
|
||||||
|
Watermark="https://id.oribi-tech.com" />
|
||||||
|
|
||||||
|
<TextBlock Text="API Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<TextBox Text="{Binding AuthentikApiKey}" PasswordChar="●"
|
||||||
|
Watermark="Bearer token for /api/v3/" />
|
||||||
|
|
||||||
|
<!-- Save & Test button -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,8,0,0">
|
||||||
|
<Button Content="Save & Test Connection"
|
||||||
|
Classes="accent"
|
||||||
|
Command="{Binding SaveAndTestAuthentikCommand}"
|
||||||
|
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||||
|
FontWeight="SemiBold" Padding="16,8" />
|
||||||
|
<Button Content="Refresh Dropdowns"
|
||||||
|
Command="{Binding FetchAuthentikDropdownsCommand}"
|
||||||
|
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||||
|
Padding="16,8" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding AuthentikStatusMessage}"
|
||||||
|
FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2,0,0" />
|
||||||
|
|
||||||
|
<!-- Flow / Keypair dropdowns -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
|
||||||
|
<TextBlock Text="Flows & Keypairs" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||||
|
<TextBlock Text="These are loaded from your Authentik instance. Save & Test to populate."
|
||||||
|
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,4"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="Authorization Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding AuthentikAuthorizationFlows}"
|
||||||
|
SelectedItem="{Binding SelectedAuthorizationFlow}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="(save & test to load flows)">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||||
|
<TextBlock Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="Invalidation Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding AuthentikInvalidationFlows}"
|
||||||
|
SelectedItem="{Binding SelectedInvalidationFlow}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="(save & test to load flows)">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||||
|
<TextBlock Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="Signing Keypair" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||||
|
<ComboBox ItemsSource="{Binding AuthentikKeypairs}"
|
||||||
|
SelectedItem="{Binding SelectedSigningKeypair}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="(save & test to load keypairs)">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||||
|
<TextBlock Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -31,11 +31,19 @@
|
|||||||
"Database": {
|
"Database": {
|
||||||
"Provider": "Sqlite"
|
"Provider": "Sqlite"
|
||||||
},
|
},
|
||||||
|
"Bitwarden": {
|
||||||
|
"IdentityUrl": "https://identity.bitwarden.com",
|
||||||
|
"ApiUrl": "https://api.bitwarden.com",
|
||||||
|
"AccessToken": "",
|
||||||
|
"OrganizationId": "",
|
||||||
|
"ProjectId": "",
|
||||||
|
"InstanceProjectId": ""
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Data Source=otssigns-desktop.db"
|
"Default": "Data Source=otssigns-desktop.db"
|
||||||
},
|
},
|
||||||
"InstanceDefaults": {
|
"InstanceDefaults": {
|
||||||
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
|
"CmsServerNameTemplate": "app.ots-signs.com",
|
||||||
"ThemeHostPath": "/cms/ots-theme",
|
"ThemeHostPath": "/cms/ots-theme",
|
||||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Jobs;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Tests;
|
||||||
|
|
||||||
|
public class ByoiCertExpiryThresholdTests
|
||||||
|
{
|
||||||
|
// ── ShouldAlert ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(61, false)] // 61 days: above all thresholds → no alert
|
||||||
|
[InlineData(60, true)] // 60 days: at first threshold → alert
|
||||||
|
[InlineData(59, true)] // 59 days: below 60 → alert
|
||||||
|
[InlineData(31, true)] // 31 days: between 60 and 30 → alert
|
||||||
|
[InlineData(30, true)] // 30 days: at second threshold → alert
|
||||||
|
[InlineData(8, true)] // 8 days: between 30 and 7 → alert
|
||||||
|
[InlineData(7, true)] // 7 days: at critical threshold → alert
|
||||||
|
[InlineData(1, true)] // 1 day: below critical → alert
|
||||||
|
[InlineData(0, true)] // 0 days: expiry day → alert
|
||||||
|
[InlineData(-1, true)] // -1 day: already expired → alert
|
||||||
|
public void ShouldAlert_ReturnsCorrectValue(double daysRemaining, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, ByoiCertExpiryJob.ShouldAlert(daysRemaining));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldAlert_LargeValue_NoAlert()
|
||||||
|
{
|
||||||
|
Assert.False(ByoiCertExpiryJob.ShouldAlert(365));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetSeverity ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(60, "Warning")]
|
||||||
|
[InlineData(30, "Warning")]
|
||||||
|
[InlineData(8, "Warning")]
|
||||||
|
[InlineData(7.01, "Warning")]
|
||||||
|
[InlineData(7, "Critical")] // Exactly at critical boundary
|
||||||
|
[InlineData(6, "Critical")]
|
||||||
|
[InlineData(1, "Critical")]
|
||||||
|
[InlineData(0, "Critical")]
|
||||||
|
[InlineData(-1, "Critical")] // Already expired
|
||||||
|
public void GetSeverity_ReturnsCorrectLevel(double daysRemaining, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, ByoiCertExpiryJob.GetSeverity(daysRemaining));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Threshold constants ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlertThresholds_AreDescending()
|
||||||
|
{
|
||||||
|
var thresholds = ByoiCertExpiryJob.AlertThresholdDays;
|
||||||
|
for (int i = 1; i < thresholds.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.True(thresholds[i - 1] > thresholds[i],
|
||||||
|
$"Thresholds must be in descending order: {thresholds[i - 1]} should be > {thresholds[i]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CriticalThreshold_IsSmallestAlertThreshold()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
ByoiCertExpiryJob.CriticalThresholdDays,
|
||||||
|
ByoiCertExpiryJob.AlertThresholdDays[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boundary precision ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldAlert_JustAboveThreshold_NoAlert()
|
||||||
|
{
|
||||||
|
// 60.001 days — just above 60-day threshold
|
||||||
|
Assert.False(ByoiCertExpiryJob.ShouldAlert(60.001));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldAlert_JustBelowThreshold_Alerts()
|
||||||
|
{
|
||||||
|
// 59.999 days — just below 60-day threshold
|
||||||
|
Assert.True(ByoiCertExpiryJob.ShouldAlert(59.999));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSeverity_JustAboveCritical_IsWarning()
|
||||||
|
{
|
||||||
|
Assert.Equal("Warning", ByoiCertExpiryJob.GetSeverity(7.001));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSeverity_ExactlyCritical_IsCritical()
|
||||||
|
{
|
||||||
|
Assert.Equal("Critical", ByoiCertExpiryJob.GetSeverity(7.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OTSSignsOrchestrator.Server\OTSSignsOrchestrator.Server.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
248
OTSSignsOrchestrator.Server/Api/CustomerPortalApi.cs
Normal file
248
OTSSignsOrchestrator.Server/Api/CustomerPortalApi.cs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
using OTSSignsOrchestrator.Server.Workers;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Api;
|
||||||
|
|
||||||
|
public static class CustomerPortalApi
|
||||||
|
{
|
||||||
|
private const int MinCertDaysRemaining = 30;
|
||||||
|
|
||||||
|
public static void MapCustomerPortalEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var portal = app.MapGroup("/api/portal/byoi")
|
||||||
|
.RequireAuthorization("CustomerPortal");
|
||||||
|
|
||||||
|
portal.MapPost("/configure", HandleConfigureByoi);
|
||||||
|
portal.MapGet("/sp-metadata", HandleGetSpMetadata);
|
||||||
|
portal.MapPost("/rotate-cert", HandleRotateCert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/portal/byoi/configure ─────────────────────────────────────
|
||||||
|
private static async Task<IResult> HandleConfigureByoi(
|
||||||
|
ConfigureByoiRequest req,
|
||||||
|
OrchestratorDbContext db,
|
||||||
|
IHubContext<FleetHub, IFleetClient> hub,
|
||||||
|
HttpContext httpContext,
|
||||||
|
ILogger<ConfigureByoiRequest> logger)
|
||||||
|
{
|
||||||
|
// Resolve customer from the authenticated JWT
|
||||||
|
var customer = await ResolveCustomerAsync(httpContext, db);
|
||||||
|
if (customer is null)
|
||||||
|
return Results.Forbid();
|
||||||
|
|
||||||
|
if (customer.Plan != CustomerPlan.Pro)
|
||||||
|
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
|
||||||
|
|
||||||
|
// Validate cert PEM
|
||||||
|
var certValidation = ValidateCertPem(req.CertPem);
|
||||||
|
if (certValidation is not null)
|
||||||
|
return Results.ValidationProblem(
|
||||||
|
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
var errors = new Dictionary<string, string[]>();
|
||||||
|
if (string.IsNullOrWhiteSpace(req.SsoUrl))
|
||||||
|
errors["ssoUrl"] = ["ssoUrl is required."];
|
||||||
|
if (string.IsNullOrWhiteSpace(req.IdpEntityId))
|
||||||
|
errors["idpEntityId"] = ["idpEntityId is required."];
|
||||||
|
if (errors.Count > 0)
|
||||||
|
return Results.ValidationProblem(errors);
|
||||||
|
|
||||||
|
// Create a provision-byoi Job
|
||||||
|
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
|
||||||
|
{
|
||||||
|
CustomerCertPem = req.CertPem!,
|
||||||
|
CustomerSsoUrl = req.SsoUrl!,
|
||||||
|
CustomerIdpEntityId = req.IdpEntityId!,
|
||||||
|
CustomerSloUrl = req.SloUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
var job = new Job
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
JobType = "provision-byoi",
|
||||||
|
Status = JobStatus.Queued,
|
||||||
|
TriggeredBy = $"customer-portal:{customer.AdminEmail}",
|
||||||
|
Parameters = parametersJson,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Jobs.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("BYOI configure job {JobId} created for customer {CustomerId}",
|
||||||
|
job.Id, customer.Id);
|
||||||
|
|
||||||
|
await hub.Clients.All.SendJobCreated(
|
||||||
|
job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||||||
|
|
||||||
|
return Results.Created($"/api/jobs/{job.Id}", new { jobId = job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/portal/byoi/sp-metadata ────────────────────────────────────
|
||||||
|
private static async Task<IResult> HandleGetSpMetadata(
|
||||||
|
OrchestratorDbContext db,
|
||||||
|
IAuthentikClient authentikClient,
|
||||||
|
HttpContext httpContext,
|
||||||
|
ILogger<ConfigureByoiRequest> logger)
|
||||||
|
{
|
||||||
|
var customer = await ResolveCustomerAsync(httpContext, db);
|
||||||
|
if (customer is null)
|
||||||
|
return Results.Forbid();
|
||||||
|
|
||||||
|
var instance = customer.Instances.FirstOrDefault();
|
||||||
|
if (instance is null)
|
||||||
|
return Results.NotFound("No instance found for this customer.");
|
||||||
|
|
||||||
|
var byoiConfig = await db.ByoiConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
|
||||||
|
|
||||||
|
if (byoiConfig is null)
|
||||||
|
return Results.NotFound("No BYOI configuration found for this instance.");
|
||||||
|
|
||||||
|
var metadataResponse = await authentikClient.GetSamlSourceMetadataAsync(byoiConfig.Slug);
|
||||||
|
if (!metadataResponse.IsSuccessStatusCode || metadataResponse.Content is null)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to fetch SP metadata for slug {Slug}: {Error}",
|
||||||
|
byoiConfig.Slug, metadataResponse.Error?.Content ?? metadataResponse.ReasonPhrase);
|
||||||
|
return Results.Problem("Failed to retrieve SP metadata from Authentik.", statusCode: 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Content(metadataResponse.Content, "application/xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/portal/byoi/rotate-cert ───────────────────────────────────
|
||||||
|
private static async Task<IResult> HandleRotateCert(
|
||||||
|
RotateCertRequest req,
|
||||||
|
OrchestratorDbContext db,
|
||||||
|
IHubContext<FleetHub, IFleetClient> hub,
|
||||||
|
HttpContext httpContext,
|
||||||
|
ILogger<RotateCertRequest> logger)
|
||||||
|
{
|
||||||
|
var customer = await ResolveCustomerAsync(httpContext, db);
|
||||||
|
if (customer is null)
|
||||||
|
return Results.Forbid();
|
||||||
|
|
||||||
|
if (customer.Plan != CustomerPlan.Pro)
|
||||||
|
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
|
||||||
|
|
||||||
|
// Validate cert PEM
|
||||||
|
var certValidation = ValidateCertPem(req.CertPem);
|
||||||
|
if (certValidation is not null)
|
||||||
|
return Results.ValidationProblem(
|
||||||
|
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
|
||||||
|
|
||||||
|
var instance = customer.Instances.FirstOrDefault();
|
||||||
|
if (instance is null)
|
||||||
|
return Results.NotFound("No instance found for this customer.");
|
||||||
|
|
||||||
|
var existingConfig = await db.ByoiConfigs
|
||||||
|
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
|
||||||
|
|
||||||
|
if (existingConfig is null)
|
||||||
|
return Results.NotFound("No active BYOI configuration found to rotate.");
|
||||||
|
|
||||||
|
// Create a re-provisioning job with the new cert
|
||||||
|
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
|
||||||
|
{
|
||||||
|
CustomerCertPem = req.CertPem!,
|
||||||
|
CustomerSsoUrl = existingConfig.SsoUrl,
|
||||||
|
CustomerIdpEntityId = existingConfig.EntityId,
|
||||||
|
CustomerSloUrl = null,
|
||||||
|
});
|
||||||
|
|
||||||
|
var job = new Job
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
JobType = "provision-byoi",
|
||||||
|
Status = JobStatus.Queued,
|
||||||
|
TriggeredBy = $"customer-portal:cert-rotate:{customer.AdminEmail}",
|
||||||
|
Parameters = parametersJson,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Jobs.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("BYOI cert rotate job {JobId} created for customer {CustomerId}",
|
||||||
|
job.Id, customer.Id);
|
||||||
|
|
||||||
|
await hub.Clients.All.SendJobCreated(
|
||||||
|
job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||||||
|
|
||||||
|
return Results.Ok(new { jobId = job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a PEM certificate string. Returns an error message on failure, or null if valid.
|
||||||
|
/// Rejects self-signed, expired, and certs expiring in < 30 days.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ValidateCertPem(string? certPem)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(certPem))
|
||||||
|
return "certPem is required.";
|
||||||
|
|
||||||
|
X509Certificate2 cert;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var base64 = certPem
|
||||||
|
.Replace("-----BEGIN CERTIFICATE-----", "")
|
||||||
|
.Replace("-----END CERTIFICATE-----", "")
|
||||||
|
.Replace("\r", "")
|
||||||
|
.Replace("\n", "")
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
cert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return "Invalid certificate PEM format.";
|
||||||
|
}
|
||||||
|
|
||||||
|
using (cert)
|
||||||
|
{
|
||||||
|
if (cert.NotAfter.ToUniversalTime() < DateTime.UtcNow)
|
||||||
|
return "Certificate has already expired.";
|
||||||
|
|
||||||
|
if ((cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays < MinCertDaysRemaining)
|
||||||
|
return $"Certificate expires in less than {MinCertDaysRemaining} days. Provide a certificate with a longer validity period.";
|
||||||
|
|
||||||
|
// Reject self-signed: issuer == subject
|
||||||
|
if (string.Equals(cert.Issuer, cert.Subject, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "Self-signed certificates are not accepted. Provide a CA-signed certificate.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the current customer from the authenticated JWT claims.
|
||||||
|
/// Expects a "customer_id" claim in the token.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Customer?> ResolveCustomerAsync(HttpContext httpContext, OrchestratorDbContext db)
|
||||||
|
{
|
||||||
|
var customerIdClaim = httpContext.User.FindFirst("customer_id")?.Value;
|
||||||
|
if (customerIdClaim is null || !Guid.TryParse(customerIdClaim, out var customerId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await db.Customers
|
||||||
|
.Include(c => c.Instances)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == customerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request DTOs ────────────────────────────────────────────────────────────
|
||||||
|
public record ConfigureByoiRequest(string? CertPem, string? SsoUrl, string? IdpEntityId, string? SloUrl);
|
||||||
|
public record RotateCertRequest(string? CertPem);
|
||||||
245
OTSSignsOrchestrator.Server/Api/FleetApi.cs
Normal file
245
OTSSignsOrchestrator.Server/Api/FleetApi.cs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
using OTSSignsOrchestrator.Server.Reports;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Api;
|
||||||
|
|
||||||
|
public static class FleetApi
|
||||||
|
{
|
||||||
|
public static void MapFleetEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var fleet = app.MapGroup("/api/fleet").RequireAuthorization();
|
||||||
|
fleet.MapGet("/", GetFleetSummary);
|
||||||
|
fleet.MapGet("/{id:guid}", GetFleetDetail);
|
||||||
|
|
||||||
|
var jobs = app.MapGroup("/api/jobs").RequireAuthorization();
|
||||||
|
jobs.MapPost("/", CreateJob);
|
||||||
|
jobs.MapGet("/{id:guid}", GetJob);
|
||||||
|
|
||||||
|
app.MapGet("/api/health", () => Results.Ok(new { status = "healthy" }));
|
||||||
|
|
||||||
|
// ── Report endpoints (admin only) ────────────────────────────────────
|
||||||
|
var reports = app.MapGroup("/api/reports").RequireAuthorization()
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole("admin"));
|
||||||
|
|
||||||
|
reports.MapGet("/billing", GetBillingCsv);
|
||||||
|
reports.MapGet("/version-drift", GetVersionDriftCsv);
|
||||||
|
reports.MapGet("/fleet-health", GetFleetHealthPdf);
|
||||||
|
reports.MapGet("/customer/{id:guid}/usage", GetCustomerUsagePdf);
|
||||||
|
|
||||||
|
fleet.MapPost("/bulk/export-fleet-report", ExportFleetReport)
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole("admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/fleet ──────────────────────────────────────────────────────
|
||||||
|
private static async Task<IResult> GetFleetSummary(OrchestratorDbContext db)
|
||||||
|
{
|
||||||
|
var customers = await db.Customers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Instances)
|
||||||
|
.Include(c => c.Jobs)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Get latest health event per instance in one query
|
||||||
|
var latestHealth = await db.HealthEvents
|
||||||
|
.AsNoTracking()
|
||||||
|
.GroupBy(h => h.InstanceId)
|
||||||
|
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
|
||||||
|
.ToDictionaryAsync(h => h.InstanceId);
|
||||||
|
|
||||||
|
var result = customers.Select(c =>
|
||||||
|
{
|
||||||
|
var primaryInstance = c.Instances.FirstOrDefault();
|
||||||
|
HealthEvent? health = null;
|
||||||
|
if (primaryInstance is not null)
|
||||||
|
latestHealth.TryGetValue(primaryInstance.Id, out health);
|
||||||
|
|
||||||
|
return new FleetSummaryDto
|
||||||
|
{
|
||||||
|
CustomerId = c.Id,
|
||||||
|
Abbreviation = c.Abbreviation,
|
||||||
|
CompanyName = c.CompanyName,
|
||||||
|
Plan = c.Plan.ToString(),
|
||||||
|
ScreenCount = c.ScreenCount,
|
||||||
|
HealthStatus = health?.Status.ToString() ?? primaryInstance?.HealthStatus.ToString() ?? "Unknown",
|
||||||
|
LastHealthCheck = health?.OccurredAt ?? primaryInstance?.LastHealthCheck,
|
||||||
|
HasRunningJob = c.Jobs.Any(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued),
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/fleet/{id} ─────────────────────────────────────────────────
|
||||||
|
private static async Task<IResult> GetFleetDetail(Guid id, OrchestratorDbContext db)
|
||||||
|
{
|
||||||
|
var customer = await db.Customers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Instances)
|
||||||
|
.Include(c => c.Jobs.Where(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued))
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
if (customer is null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
customer.Id,
|
||||||
|
customer.Abbreviation,
|
||||||
|
customer.CompanyName,
|
||||||
|
customer.AdminEmail,
|
||||||
|
Plan = customer.Plan.ToString(),
|
||||||
|
customer.ScreenCount,
|
||||||
|
Status = customer.Status.ToString(),
|
||||||
|
customer.CreatedAt,
|
||||||
|
Instances = customer.Instances.Select(i => new
|
||||||
|
{
|
||||||
|
i.Id,
|
||||||
|
i.XiboUrl,
|
||||||
|
i.DockerStackName,
|
||||||
|
HealthStatus = i.HealthStatus.ToString(),
|
||||||
|
i.LastHealthCheck,
|
||||||
|
}),
|
||||||
|
ActiveJobs = customer.Jobs.Select(j => new
|
||||||
|
{
|
||||||
|
j.Id,
|
||||||
|
j.JobType,
|
||||||
|
Status = j.Status.ToString(),
|
||||||
|
j.CreatedAt,
|
||||||
|
j.StartedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/jobs ──────────────────────────────────────────────────────
|
||||||
|
private static async Task<IResult> CreateJob(
|
||||||
|
CreateJobRequest req,
|
||||||
|
OrchestratorDbContext db,
|
||||||
|
IHubContext<FleetHub, IFleetClient> hub,
|
||||||
|
ILogger<CreateJobRequest> logger)
|
||||||
|
{
|
||||||
|
var customer = await db.Customers.FindAsync(req.CustomerId);
|
||||||
|
if (customer is null)
|
||||||
|
return Results.NotFound("Customer not found.");
|
||||||
|
|
||||||
|
var job = new Job
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CustomerId = req.CustomerId,
|
||||||
|
JobType = req.JobType,
|
||||||
|
Status = JobStatus.Queued,
|
||||||
|
TriggeredBy = "operator",
|
||||||
|
Parameters = req.Parameters,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Jobs.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("Job created: {JobId} type={JobType} customer={CustomerId}",
|
||||||
|
job.Id, job.JobType, job.CustomerId);
|
||||||
|
|
||||||
|
await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||||||
|
|
||||||
|
return Results.Created($"/api/jobs/{job.Id}", new { job.Id, job.JobType, Status = job.Status.ToString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/jobs/{id} ──────────────────────────────────────────────────
|
||||||
|
private static async Task<IResult> GetJob(Guid id, OrchestratorDbContext db)
|
||||||
|
{
|
||||||
|
var job = await db.Jobs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(j => j.Steps.OrderBy(s => s.StartedAt))
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == id);
|
||||||
|
|
||||||
|
if (job is null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
job.CustomerId,
|
||||||
|
job.JobType,
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.TriggeredBy,
|
||||||
|
job.Parameters,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt,
|
||||||
|
job.ErrorMessage,
|
||||||
|
Steps = job.Steps.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.StepName,
|
||||||
|
Status = s.Status.ToString(),
|
||||||
|
s.LogOutput,
|
||||||
|
s.StartedAt,
|
||||||
|
s.CompletedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/reports/billing?from=&to= ──────────────────────────────────
|
||||||
|
private static async Task<IResult> GetBillingCsv(
|
||||||
|
DateOnly from, DateOnly to, BillingReportService billing)
|
||||||
|
{
|
||||||
|
var csv = await billing.GenerateBillingCsvAsync(from, to);
|
||||||
|
return Results.File(csv, "text/csv", $"billing-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/reports/version-drift ──────────────────────────────────────
|
||||||
|
private static async Task<IResult> GetVersionDriftCsv(BillingReportService billing)
|
||||||
|
{
|
||||||
|
var csv = await billing.GenerateVersionDriftCsvAsync();
|
||||||
|
return Results.File(csv, "text/csv", $"version-drift-{DateTime.UtcNow:yyyy-MM-dd}.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/reports/fleet-health?from=&to= ─────────────────────────────
|
||||||
|
private static async Task<IResult> GetFleetHealthPdf(
|
||||||
|
DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
|
||||||
|
{
|
||||||
|
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
|
||||||
|
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/reports/customer/{id}/usage?from=&to= ──────────────────────
|
||||||
|
private static async Task<IResult> GetCustomerUsagePdf(
|
||||||
|
Guid id, DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pdf = await pdfService.GenerateCustomerUsagePdfAsync(id, from, to);
|
||||||
|
return Results.File(pdf, "application/pdf", $"customer-usage-{id}-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.NotFound(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/fleet/bulk/export-fleet-report ────────────────────────────
|
||||||
|
private static async Task<IResult> ExportFleetReport(FleetHealthPdfService pdfService)
|
||||||
|
{
|
||||||
|
var to = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var from = to.AddDays(-7);
|
||||||
|
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
|
||||||
|
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FleetSummaryDto
|
||||||
|
{
|
||||||
|
public Guid CustomerId { get; init; }
|
||||||
|
public string Abbreviation { get; init; } = string.Empty;
|
||||||
|
public string CompanyName { get; init; } = string.Empty;
|
||||||
|
public string Plan { get; init; } = string.Empty;
|
||||||
|
public int ScreenCount { get; init; }
|
||||||
|
public string HealthStatus { get; init; } = "Unknown";
|
||||||
|
public DateTime? LastHealthCheck { get; init; }
|
||||||
|
public bool HasRunningJob { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);
|
||||||
173
OTSSignsOrchestrator.Server/Api/SignupApi.cs
Normal file
173
OTSSignsOrchestrator.Server/Api/SignupApi.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
using Stripe.Checkout;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Api;
|
||||||
|
|
||||||
|
public static class SignupApi
|
||||||
|
{
|
||||||
|
public static void MapSignupEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/signup/initiate", HandleInitiate)
|
||||||
|
.RequireRateLimiting("signup");
|
||||||
|
|
||||||
|
app.MapGet("/api/signup/status/{token:guid}", HandleStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleInitiate(
|
||||||
|
SignupRequest req,
|
||||||
|
OrchestratorDbContext db,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<SignupRequest> logger)
|
||||||
|
{
|
||||||
|
// ── Validation ──────────────────────────────────────────────────────
|
||||||
|
var errors = new List<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(req.CompanyName))
|
||||||
|
errors.Add("companyName is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(req.AdminEmail) || !new EmailAddressAttribute().IsValid(req.AdminEmail))
|
||||||
|
errors.Add("A valid adminEmail is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Plan) ||
|
||||||
|
!req.Plan.Equals("Essentials", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!req.Plan.Equals("Pro", StringComparison.OrdinalIgnoreCase))
|
||||||
|
errors.Add("plan must be 'Essentials' or 'Pro'.");
|
||||||
|
if (req.ScreenCount < 1)
|
||||||
|
errors.Add("screenCount must be at least 1.");
|
||||||
|
if (req.Plan?.Equals("Essentials", StringComparison.OrdinalIgnoreCase) == true && req.ScreenCount > 50)
|
||||||
|
errors.Add("Essentials plan supports a maximum of 50 screens.");
|
||||||
|
if (string.IsNullOrWhiteSpace(req.BillingFrequency) ||
|
||||||
|
!req.BillingFrequency.Equals("monthly", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!req.BillingFrequency.Equals("annual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
errors.Add("billingFrequency must be 'monthly' or 'annual'.");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
return Results.ValidationProblem(
|
||||||
|
errors.ToDictionary(e => e, _ => new[] { "Validation failed." }));
|
||||||
|
|
||||||
|
// ── Create pending customer ─────────────────────────────────────────
|
||||||
|
var plan = Enum.Parse<CustomerPlan>(req.Plan!, true);
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CompanyName = req.CompanyName!.Trim(),
|
||||||
|
AdminEmail = req.AdminEmail!.Trim().ToLowerInvariant(),
|
||||||
|
AdminFirstName = req.AdminFirstName?.Trim() ?? string.Empty,
|
||||||
|
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
|
||||||
|
Plan = plan,
|
||||||
|
ScreenCount = req.ScreenCount,
|
||||||
|
Status = CustomerStatus.PendingPayment,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Customers.Add(customer);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// ── Stripe Checkout Session ─────────────────────────────────────────
|
||||||
|
var priceKey = $"Stripe:Prices:{req.Plan}:{req.BillingFrequency}".ToLowerInvariant();
|
||||||
|
var priceId = config[priceKey];
|
||||||
|
if (string.IsNullOrWhiteSpace(priceId))
|
||||||
|
{
|
||||||
|
logger.LogError("Stripe price ID not configured for key {PriceKey}", priceKey);
|
||||||
|
return Results.Problem("Billing configuration error. Contact support.", statusCode: 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionOptions = new SessionCreateOptions
|
||||||
|
{
|
||||||
|
Mode = "subscription",
|
||||||
|
CustomerEmail = customer.AdminEmail,
|
||||||
|
LineItems = new List<SessionLineItemOptions>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = req.ScreenCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SubscriptionData = new SessionSubscriptionDataOptions
|
||||||
|
{
|
||||||
|
TrialPeriodDays = 14,
|
||||||
|
},
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["ots_customer_id"] = customer.Id.ToString(),
|
||||||
|
["company_name"] = customer.CompanyName,
|
||||||
|
["admin_email"] = customer.AdminEmail,
|
||||||
|
["admin_first_name"] = customer.AdminFirstName,
|
||||||
|
["admin_last_name"] = customer.AdminLastName,
|
||||||
|
["plan"] = req.Plan!,
|
||||||
|
["screen_count"] = req.ScreenCount.ToString(),
|
||||||
|
["billing_frequency"] = req.BillingFrequency!,
|
||||||
|
},
|
||||||
|
SuccessUrl = config["Stripe:SuccessUrl"] ?? "https://app.ots-signs.com/signup/success?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
CancelUrl = config["Stripe:CancelUrl"] ?? "https://app.ots-signs.com/signup/cancel",
|
||||||
|
};
|
||||||
|
|
||||||
|
var sessionService = new SessionService();
|
||||||
|
var session = await sessionService.CreateAsync(sessionOptions);
|
||||||
|
|
||||||
|
customer.StripeCheckoutSessionId = session.Id;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Signup initiated: customer={CustomerId}, company={Company}, plan={Plan}, screens={Screens}",
|
||||||
|
customer.Id, customer.CompanyName, req.Plan, req.ScreenCount);
|
||||||
|
|
||||||
|
return Results.Ok(new { checkoutUrl = session.Url, statusToken = customer.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleStatus(
|
||||||
|
Guid token,
|
||||||
|
OrchestratorDbContext db)
|
||||||
|
{
|
||||||
|
var customer = await db.Customers
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == token);
|
||||||
|
|
||||||
|
if (customer is null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
// Find latest provisioning job if any
|
||||||
|
var job = await db.Jobs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(j => j.CustomerId == customer.Id && j.JobType == "provision")
|
||||||
|
.OrderByDescending(j => j.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
int pctComplete = customer.Status switch
|
||||||
|
{
|
||||||
|
CustomerStatus.PendingPayment => 0,
|
||||||
|
CustomerStatus.Provisioning => job?.Status switch
|
||||||
|
{
|
||||||
|
JobStatus.Running => 50,
|
||||||
|
JobStatus.Completed => 100,
|
||||||
|
_ => 10,
|
||||||
|
},
|
||||||
|
CustomerStatus.Active => 100,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
status = customer.Status.ToString(),
|
||||||
|
provisioningStep = job?.Steps
|
||||||
|
.Where(s => s.Status == JobStepStatus.Running)
|
||||||
|
.Select(s => s.StepName)
|
||||||
|
.FirstOrDefault() ?? (customer.Status == CustomerStatus.Active ? "complete" : "waiting"),
|
||||||
|
pctComplete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignupRequest(
|
||||||
|
string? CompanyName,
|
||||||
|
string? AdminFirstName,
|
||||||
|
string? AdminLastName,
|
||||||
|
string? AdminEmail,
|
||||||
|
string? Phone,
|
||||||
|
string? Plan,
|
||||||
|
int ScreenCount,
|
||||||
|
string? BillingFrequency,
|
||||||
|
string? PromoCode);
|
||||||
10
OTSSignsOrchestrator.Server/Auth/JwtOptions.cs
Normal file
10
OTSSignsOrchestrator.Server/Auth/JwtOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Auth;
|
||||||
|
|
||||||
|
public class JwtOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Jwt";
|
||||||
|
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Issuer { get; set; } = "OTSSignsOrchestrator";
|
||||||
|
public string Audience { get; set; } = "OTSSignsOrchestrator";
|
||||||
|
}
|
||||||
102
OTSSignsOrchestrator.Server/Auth/OperatorAuthService.cs
Normal file
102
OTSSignsOrchestrator.Server/Auth/OperatorAuthService.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Auth;
|
||||||
|
|
||||||
|
public class OperatorAuthService
|
||||||
|
{
|
||||||
|
private readonly OrchestratorDbContext _db;
|
||||||
|
private readonly JwtOptions _jwt;
|
||||||
|
private readonly ILogger<OperatorAuthService> _logger;
|
||||||
|
|
||||||
|
public OperatorAuthService(
|
||||||
|
OrchestratorDbContext db,
|
||||||
|
IOptions<JwtOptions> jwt,
|
||||||
|
ILogger<OperatorAuthService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jwt = jwt.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(string Jwt, string RefreshToken)> LoginAsync(string email, string password)
|
||||||
|
{
|
||||||
|
var op = await _db.Operators.FirstOrDefaultAsync(
|
||||||
|
o => o.Email == email.Trim().ToLowerInvariant());
|
||||||
|
|
||||||
|
if (op is null || !BCrypt.Net.BCrypt.Verify(password, op.PasswordHash))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Login failed for {Email}", email);
|
||||||
|
throw new UnauthorizedAccessException("Invalid email or password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Operator {Email} logged in", op.Email);
|
||||||
|
var jwt = GenerateJwt(op);
|
||||||
|
var refresh = await CreateRefreshTokenAsync(op.Id);
|
||||||
|
return (jwt, refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> RefreshAsync(string refreshToken)
|
||||||
|
{
|
||||||
|
var token = await _db.RefreshTokens
|
||||||
|
.Include(r => r.Operator)
|
||||||
|
.FirstOrDefaultAsync(r => r.Token == refreshToken);
|
||||||
|
|
||||||
|
if (token is null || token.RevokedAt is not null || token.ExpiresAt < DateTime.UtcNow)
|
||||||
|
throw new UnauthorizedAccessException("Invalid or expired refresh token.");
|
||||||
|
|
||||||
|
// Revoke the used token (single-use rotation)
|
||||||
|
token.RevokedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Refresh token used for operator {Email}", token.Operator.Email);
|
||||||
|
return GenerateJwt(token.Operator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateJwt(Operator op)
|
||||||
|
{
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Key));
|
||||||
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, op.Id.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Email, op.Email),
|
||||||
|
new Claim(ClaimTypes.Name, op.Email),
|
||||||
|
new Claim(ClaimTypes.Role, op.Role.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _jwt.Issuer,
|
||||||
|
audience: _jwt.Audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(15),
|
||||||
|
signingCredentials: creds);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateRefreshTokenAsync(Guid operatorId)
|
||||||
|
{
|
||||||
|
var tokenValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||||
|
|
||||||
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
OperatorId = operatorId,
|
||||||
|
Token = tokenValue,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return tokenValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
OTSSignsOrchestrator.Server/Clients/IAuthentikClient.cs
Normal file
146
OTSSignsOrchestrator.Server/Clients/IAuthentikClient.cs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
using Refit;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Clients;
|
||||||
|
|
||||||
|
// ── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
public sealed class AuthentikOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Authentik";
|
||||||
|
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
public string ApiToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>UUID of the OTS signing certificate-key pair used for all SAML sources.</summary>
|
||||||
|
public string OtsSigningKpId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Authentik pre-authentication flow slug for SAML sources (e.g. "default-source-pre-authentication").</summary>
|
||||||
|
public string SourcePreAuthFlowSlug { get; set; } = "default-source-pre-authentication";
|
||||||
|
|
||||||
|
/// <summary>Authentik authentication flow slug for SAML sources (e.g. "default-source-authentication").</summary>
|
||||||
|
public string SourceAuthFlowSlug { get; set; } = "default-source-authentication";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request DTOs ────────────────────────────────────────────────────────────
|
||||||
|
public record CreateSamlProviderRequest(
|
||||||
|
string Name,
|
||||||
|
string AuthorizationFlow,
|
||||||
|
string AcsUrl,
|
||||||
|
string Issuer,
|
||||||
|
string SpBinding,
|
||||||
|
string Audience,
|
||||||
|
string? SigningKp);
|
||||||
|
|
||||||
|
public record CreateAuthentikApplicationRequest(
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string Provider,
|
||||||
|
string? MetaLaunchUrl);
|
||||||
|
|
||||||
|
public record CreateAuthentikGroupRequest(
|
||||||
|
string Name,
|
||||||
|
bool? IsSuperuser,
|
||||||
|
string? Parent);
|
||||||
|
|
||||||
|
public record CreateFlowRequest(
|
||||||
|
string Name,
|
||||||
|
bool? SingleUse,
|
||||||
|
DateTimeOffset? Expires);
|
||||||
|
|
||||||
|
public record CreateAuthentikUserRequest(
|
||||||
|
string Username,
|
||||||
|
string Name,
|
||||||
|
string Email,
|
||||||
|
string[] Groups);
|
||||||
|
|
||||||
|
public record ImportCertRequest(
|
||||||
|
string Name,
|
||||||
|
string CertificateData,
|
||||||
|
string? KeyData);
|
||||||
|
|
||||||
|
public record CreateSamlSourceRequest(
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string SsoUrl,
|
||||||
|
string? SloUrl,
|
||||||
|
string Issuer,
|
||||||
|
string? SigningKp,
|
||||||
|
string? VerificationKp,
|
||||||
|
string BindingType,
|
||||||
|
string NameIdPolicy,
|
||||||
|
string PreAuthenticationFlow,
|
||||||
|
string AuthenticationFlow,
|
||||||
|
bool AllowIdpInitiated);
|
||||||
|
|
||||||
|
// ── Response DTOs ───────────────────────────────────────────────────────────
|
||||||
|
/// <summary>Authentik paginated list response. Results contain dictionaries with entity fields.</summary>
|
||||||
|
public record AuthentikPagedResult(
|
||||||
|
List<Dictionary<string, object>> Results);
|
||||||
|
|
||||||
|
// ── Authentik Refit Interface ───────────────────────────────────────────────
|
||||||
|
// One global Authentik instance serves all tenants.
|
||||||
|
[Headers("Authorization: Bearer")]
|
||||||
|
public interface IAuthentikClient
|
||||||
|
{
|
||||||
|
// ── SAML Providers ──────────────────────────────────────────────────────
|
||||||
|
[Post("/api/v3/providers/saml/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateSamlProviderAsync(
|
||||||
|
[Body] CreateSamlProviderRequest body);
|
||||||
|
|
||||||
|
[Get("/api/v3/providers/saml/{id}/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> GetSamlProviderAsync(int id);
|
||||||
|
|
||||||
|
[Delete("/api/v3/providers/saml/{id}/")]
|
||||||
|
Task DeleteSamlProviderAsync(int id);
|
||||||
|
|
||||||
|
// ── Applications ────────────────────────────────────────────────────────
|
||||||
|
[Post("/api/v3/core/applications/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateApplicationAsync(
|
||||||
|
[Body] CreateAuthentikApplicationRequest body);
|
||||||
|
|
||||||
|
[Delete("/api/v3/core/applications/{slug}/")]
|
||||||
|
Task DeleteApplicationAsync(string slug);
|
||||||
|
|
||||||
|
// ── Groups ──────────────────────────────────────────────────────────────
|
||||||
|
[Get("/api/v3/core/groups/")]
|
||||||
|
Task<ApiResponse<AuthentikPagedResult>> ListGroupsAsync([AliasAs("search")] string? search = null);
|
||||||
|
|
||||||
|
[Post("/api/v3/core/groups/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateGroupAsync(
|
||||||
|
[Body] CreateAuthentikGroupRequest body);
|
||||||
|
|
||||||
|
[Delete("/api/v3/core/groups/{id}/")]
|
||||||
|
Task DeleteGroupAsync(string id);
|
||||||
|
|
||||||
|
// ── Invitations ─────────────────────────────────────────────────────────
|
||||||
|
[Post("/api/v3/stages/invitation/invitations/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateInvitationAsync(
|
||||||
|
[Body] CreateFlowRequest body);
|
||||||
|
|
||||||
|
// ── Users ───────────────────────────────────────────────────────────────
|
||||||
|
[Post("/api/v3/core/users/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateUserAsync(
|
||||||
|
[Body] CreateAuthentikUserRequest body);
|
||||||
|
|
||||||
|
// ── Health ──────────────────────────────────────────────────────────────
|
||||||
|
[Get("/api/v3/-/health/ready/")]
|
||||||
|
Task<ApiResponse<object>> CheckHealthAsync();
|
||||||
|
|
||||||
|
// ── Certificates ────────────────────────────────────────────────────────
|
||||||
|
[Get("/api/v3/crypto/certificatekeypairs/{kpId}/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> GetCertificateKeyPairAsync(string kpId);
|
||||||
|
|
||||||
|
[Post("/api/v3/crypto/certificatekeypairs/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> ImportCertificateAsync(
|
||||||
|
[Body] ImportCertRequest body);
|
||||||
|
|
||||||
|
// ── SAML Sources ────────────────────────────────────────────────────────
|
||||||
|
[Post("/api/v3/sources/saml/")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateSamlSourceAsync(
|
||||||
|
[Body] CreateSamlSourceRequest body);
|
||||||
|
|
||||||
|
[Get("/api/v3/sources/saml/{slug}/metadata/")]
|
||||||
|
Task<ApiResponse<string>> GetSamlSourceMetadataAsync(string slug);
|
||||||
|
|
||||||
|
[Delete("/api/v3/sources/saml/{slug}/")]
|
||||||
|
Task DeleteSamlSourceAsync(string slug);
|
||||||
|
}
|
||||||
138
OTSSignsOrchestrator.Server/Clients/IXiboApiClient.cs
Normal file
138
OTSSignsOrchestrator.Server/Clients/IXiboApiClient.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
using Refit;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Clients;
|
||||||
|
|
||||||
|
// ── Request DTOs ────────────────────────────────────────────────────────────
|
||||||
|
public record CreateUserRequest(
|
||||||
|
string UserName,
|
||||||
|
string Email,
|
||||||
|
string Password,
|
||||||
|
int UserTypeId,
|
||||||
|
int HomePageId);
|
||||||
|
|
||||||
|
public record UpdateUserRequest(
|
||||||
|
string? UserName,
|
||||||
|
string? Email,
|
||||||
|
string? Password,
|
||||||
|
int? UserTypeId,
|
||||||
|
int? HomePageId,
|
||||||
|
int? Retired);
|
||||||
|
|
||||||
|
public record CreateGroupRequest(string Group, string? Description);
|
||||||
|
|
||||||
|
public record AssignMemberRequest(int[] UserId);
|
||||||
|
|
||||||
|
public record SetAclRequest(string[] ObjectId, string[] PermissionsId);
|
||||||
|
|
||||||
|
public record CreateApplicationRequest(string Name);
|
||||||
|
|
||||||
|
public record UpdateSettingsRequest(Dictionary<string, string> Settings);
|
||||||
|
|
||||||
|
public record CreateDisplayRequest(string Display, string? Description);
|
||||||
|
|
||||||
|
// ── Xibo CMS Refit Interface ────────────────────────────────────────────────
|
||||||
|
// CRITICAL: GET /api/application is BLOCKED — only POST and DELETE exist.
|
||||||
|
// All group endpoints use /api/group, NOT /api/usergroup.
|
||||||
|
// Feature assignment is POST /api/group/{id}/acl, NOT /features.
|
||||||
|
// Xibo paginates at 10 items by default — always pass start + length params.
|
||||||
|
[Headers("Authorization: Bearer")]
|
||||||
|
public interface IXiboApiClient
|
||||||
|
{
|
||||||
|
// ── About ───────────────────────────────────────────────────────────────
|
||||||
|
[Get("/about")]
|
||||||
|
Task<ApiResponse<object>> GetAboutAsync();
|
||||||
|
|
||||||
|
// ── Users ───────────────────────────────────────────────────────────────
|
||||||
|
[Get("/user")]
|
||||||
|
Task<List<Dictionary<string, object>>> GetUsersAsync(
|
||||||
|
[AliasAs("start")] int? start = 0,
|
||||||
|
[AliasAs("length")] int? length = 200);
|
||||||
|
|
||||||
|
[Post("/user")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateUserAsync(
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] CreateUserRequest body);
|
||||||
|
|
||||||
|
[Put("/user/{userId}")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> UpdateUserAsync(
|
||||||
|
int userId,
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] UpdateUserRequest body);
|
||||||
|
|
||||||
|
[Delete("/user/{userId}")]
|
||||||
|
Task DeleteUserAsync(int userId);
|
||||||
|
|
||||||
|
// ── Groups (NOT /usergroup) ─────────────────────────────────────────────
|
||||||
|
[Get("/group")]
|
||||||
|
Task<List<Dictionary<string, object>>> GetGroupsAsync(
|
||||||
|
[AliasAs("start")] int? start = 0,
|
||||||
|
[AliasAs("length")] int? length = 200);
|
||||||
|
|
||||||
|
[Post("/group")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateGroupAsync(
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] CreateGroupRequest body);
|
||||||
|
|
||||||
|
[Delete("/group/{groupId}")]
|
||||||
|
Task DeleteGroupAsync(int groupId);
|
||||||
|
|
||||||
|
[Post("/group/members/assign/{groupId}")]
|
||||||
|
Task<ApiResponse<object>> AssignUserToGroupAsync(
|
||||||
|
int groupId,
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] AssignMemberRequest body);
|
||||||
|
|
||||||
|
// ACL — NOT /features
|
||||||
|
[Post("/group/{groupId}/acl")]
|
||||||
|
Task<ApiResponse<object>> SetGroupAclAsync(
|
||||||
|
int groupId,
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] SetAclRequest body);
|
||||||
|
|
||||||
|
// ── Displays ────────────────────────────────────────────────────────────
|
||||||
|
[Get("/display")]
|
||||||
|
Task<List<Dictionary<string, object>>> GetDisplaysAsync(
|
||||||
|
[AliasAs("start")] int? start = 0,
|
||||||
|
[AliasAs("length")] int? length = 200,
|
||||||
|
[AliasAs("authorised")] int? authorised = null);
|
||||||
|
|
||||||
|
// ── Applications (POST + DELETE only — GET is BLOCKED) ──────────────────
|
||||||
|
[Post("/application")]
|
||||||
|
Task<ApiResponse<Dictionary<string, object>>> CreateApplicationAsync(
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] CreateApplicationRequest body);
|
||||||
|
|
||||||
|
[Delete("/application/{key}")]
|
||||||
|
Task DeleteApplicationAsync(string key);
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────────
|
||||||
|
[Get("/settings")]
|
||||||
|
Task<ApiResponse<object>> GetSettingsAsync();
|
||||||
|
|
||||||
|
[Put("/settings")]
|
||||||
|
Task<ApiResponse<object>> UpdateSettingsAsync(
|
||||||
|
[Body(BodySerializationMethod.UrlEncoded)] UpdateSettingsRequest body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pagination helper ───────────────────────────────────────────────────────
|
||||||
|
public static class XiboApiClientExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pages through a Xibo list endpoint until a page returns fewer items than pageSize.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<List<T>> GetAllPagesAsync<T>(
|
||||||
|
this IXiboApiClient client,
|
||||||
|
Func<int, int, Task<List<T>>> listMethod,
|
||||||
|
int pageSize = 200)
|
||||||
|
{
|
||||||
|
var all = new List<T>();
|
||||||
|
var start = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var page = await listMethod(start, pageSize);
|
||||||
|
all.AddRange(page);
|
||||||
|
|
||||||
|
if (page.Count < pageSize)
|
||||||
|
break;
|
||||||
|
|
||||||
|
start += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
OTSSignsOrchestrator.Server/Clients/XiboClientFactory.cs
Normal file
211
OTSSignsOrchestrator.Server/Clients/XiboClientFactory.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Retry;
|
||||||
|
using Refit;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Clients;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates per-instance <see cref="IXiboApiClient"/> Refit proxies with
|
||||||
|
/// OAuth2 bearer-token caching, auto-refresh on 401, and Polly retry.
|
||||||
|
/// Registered as a singleton.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class XiboClientFactory
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ConcurrentDictionary<string, TokenEntry> _tokenCache = new();
|
||||||
|
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||||
|
|
||||||
|
private static readonly TimeSpan TokenCacheTtl = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public XiboClientFactory(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a Refit client targeting <paramref name="instanceBaseUrl"/>/api.
|
||||||
|
/// Tokens are cached per base URL for 5 minutes and auto-refreshed on 401.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IXiboApiClient> CreateAsync(
|
||||||
|
string instanceBaseUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret)
|
||||||
|
{
|
||||||
|
// Ensure we have a valid token up-front
|
||||||
|
var token = await GetOrRefreshTokenAsync(instanceBaseUrl, clientId, clientSecret);
|
||||||
|
|
||||||
|
var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
|
||||||
|
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
|
||||||
|
{
|
||||||
|
MaxRetryAttempts = 3,
|
||||||
|
BackoffType = DelayBackoffType.Exponential,
|
||||||
|
Delay = TimeSpan.FromSeconds(1),
|
||||||
|
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
|
||||||
|
.HandleResult(r =>
|
||||||
|
r.StatusCode is HttpStatusCode.RequestTimeout
|
||||||
|
or HttpStatusCode.TooManyRequests
|
||||||
|
or >= HttpStatusCode.InternalServerError),
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var handler = new XiboDelegatingHandler(
|
||||||
|
this, instanceBaseUrl, clientId, clientSecret, token, retryPipeline)
|
||||||
|
{
|
||||||
|
InnerHandler = new HttpClientHandler(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(instanceBaseUrl.TrimEnd('/') + "/api"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return RestService.For<IXiboApiClient>(httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal async Task<string> GetOrRefreshTokenAsync(
|
||||||
|
string instanceBaseUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
bool forceRefresh = false)
|
||||||
|
{
|
||||||
|
var key = instanceBaseUrl.TrimEnd('/').ToLowerInvariant();
|
||||||
|
|
||||||
|
if (!forceRefresh
|
||||||
|
&& _tokenCache.TryGetValue(key, out var cached)
|
||||||
|
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return cached.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _tokenLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Double-check after acquiring lock
|
||||||
|
if (!forceRefresh
|
||||||
|
&& _tokenCache.TryGetValue(key, out cached)
|
||||||
|
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return cached.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await RequestTokenAsync(instanceBaseUrl, clientId, clientSecret);
|
||||||
|
_tokenCache[key] = new TokenEntry(token, DateTimeOffset.UtcNow.Add(TokenCacheTtl));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_tokenLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> RequestTokenAsync(
|
||||||
|
string instanceBaseUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret)
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
|
||||||
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "client_credentials",
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["client_secret"] = clientSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"{instanceBaseUrl.TrimEnd('/')}/api/authorize/access_token",
|
||||||
|
content);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
return doc.RootElement.GetProperty("access_token").GetString()
|
||||||
|
?? throw new InvalidOperationException("Token response missing access_token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TokenEntry(string AccessToken, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
// ── Delegating handler ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class XiboDelegatingHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _factory;
|
||||||
|
private readonly string _instanceBaseUrl;
|
||||||
|
private readonly string _clientId;
|
||||||
|
private readonly string _clientSecret;
|
||||||
|
private readonly ResiliencePipeline<HttpResponseMessage> _retryPipeline;
|
||||||
|
private string _accessToken;
|
||||||
|
|
||||||
|
public XiboDelegatingHandler(
|
||||||
|
XiboClientFactory factory,
|
||||||
|
string instanceBaseUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
string accessToken,
|
||||||
|
ResiliencePipeline<HttpResponseMessage> retryPipeline)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_instanceBaseUrl = instanceBaseUrl;
|
||||||
|
_clientId = clientId;
|
||||||
|
_clientSecret = clientSecret;
|
||||||
|
_accessToken = accessToken;
|
||||||
|
_retryPipeline = retryPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
{
|
||||||
|
// Clone the request for retries (original may already be disposed)
|
||||||
|
using var clone = await CloneRequestAsync(request);
|
||||||
|
clone.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
|
||||||
|
var response = await base.SendAsync(clone, ct);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
// Force-refresh the token and retry once
|
||||||
|
_accessToken = await _factory.GetOrRefreshTokenAsync(
|
||||||
|
_instanceBaseUrl, _clientId, _clientSecret, forceRefresh: true);
|
||||||
|
|
||||||
|
using var retry = await CloneRequestAsync(request);
|
||||||
|
retry.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
|
||||||
|
response = await base.SendAsync(retry, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpRequestMessage> CloneRequestAsync(
|
||||||
|
HttpRequestMessage original)
|
||||||
|
{
|
||||||
|
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
|
||||||
|
|
||||||
|
if (original.Content != null)
|
||||||
|
{
|
||||||
|
var body = await original.Content.ReadAsByteArrayAsync();
|
||||||
|
clone.Content = new ByteArrayContent(body);
|
||||||
|
foreach (var header in original.Content.Headers)
|
||||||
|
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var header in original.Headers)
|
||||||
|
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
OTSSignsOrchestrator.Server/Data/Entities/AuditLog.cs
Normal file
13
OTSSignsOrchestrator.Server/Data/Entities/AuditLog.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public class AuditLog
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid? InstanceId { get; set; }
|
||||||
|
public string Actor { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string Target { get; set; } = string.Empty;
|
||||||
|
public string? Outcome { get; set; }
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum AuthentikMetricsStatus
|
||||||
|
{
|
||||||
|
Healthy,
|
||||||
|
Degraded,
|
||||||
|
Critical
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthentikMetrics
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public DateTime CheckedAt { get; set; }
|
||||||
|
public AuthentikMetricsStatus Status { get; set; }
|
||||||
|
public int LatencyMs { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
16
OTSSignsOrchestrator.Server/Data/Entities/ByoiConfig.cs
Normal file
16
OTSSignsOrchestrator.Server/Data/Entities/ByoiConfig.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public class ByoiConfig
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid InstanceId { get; set; }
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string EntityId { get; set; } = string.Empty;
|
||||||
|
public string SsoUrl { get; set; } = string.Empty;
|
||||||
|
public string CertPem { get; set; } = string.Empty;
|
||||||
|
public DateTime CertExpiry { get; set; }
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Instance Instance { get; set; } = null!;
|
||||||
|
}
|
||||||
38
OTSSignsOrchestrator.Server/Data/Entities/Customer.cs
Normal file
38
OTSSignsOrchestrator.Server/Data/Entities/Customer.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum CustomerPlan
|
||||||
|
{
|
||||||
|
Essentials,
|
||||||
|
Pro
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CustomerStatus
|
||||||
|
{
|
||||||
|
PendingPayment,
|
||||||
|
Provisioning,
|
||||||
|
Active,
|
||||||
|
Suspended,
|
||||||
|
Decommissioned
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Customer
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Abbreviation { get; set; } = string.Empty;
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public string AdminEmail { get; set; } = string.Empty;
|
||||||
|
public string AdminFirstName { get; set; } = string.Empty;
|
||||||
|
public string AdminLastName { get; set; } = string.Empty;
|
||||||
|
public CustomerPlan Plan { get; set; }
|
||||||
|
public int ScreenCount { get; set; }
|
||||||
|
public string? StripeCustomerId { get; set; }
|
||||||
|
public string? StripeSubscriptionId { get; set; }
|
||||||
|
public string? StripeCheckoutSessionId { get; set; }
|
||||||
|
public CustomerStatus Status { get; set; }
|
||||||
|
public int FailedPaymentCount { get; set; }
|
||||||
|
public DateTime? FirstPaymentFailedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<Instance> Instances { get; set; } = [];
|
||||||
|
public ICollection<Job> Jobs { get; set; } = [];
|
||||||
|
}
|
||||||
21
OTSSignsOrchestrator.Server/Data/Entities/HealthEvent.cs
Normal file
21
OTSSignsOrchestrator.Server/Data/Entities/HealthEvent.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum HealthEventStatus
|
||||||
|
{
|
||||||
|
Healthy,
|
||||||
|
Degraded,
|
||||||
|
Critical
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HealthEvent
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid InstanceId { get; set; }
|
||||||
|
public string CheckName { get; set; } = string.Empty;
|
||||||
|
public HealthEventStatus Status { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public bool Remediated { get; set; }
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
|
||||||
|
public Instance Instance { get; set; } = null!;
|
||||||
|
}
|
||||||
30
OTSSignsOrchestrator.Server/Data/Entities/Instance.cs
Normal file
30
OTSSignsOrchestrator.Server/Data/Entities/Instance.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum HealthStatus
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Healthy,
|
||||||
|
Degraded,
|
||||||
|
Critical
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Instance
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid CustomerId { get; set; }
|
||||||
|
public string XiboUrl { get; set; } = string.Empty;
|
||||||
|
public string DockerStackName { get; set; } = string.Empty;
|
||||||
|
public string MysqlDatabase { get; set; } = string.Empty;
|
||||||
|
public string NfsPath { get; set; } = string.Empty;
|
||||||
|
public string? CmsAdminPassRef { get; set; }
|
||||||
|
public string? AuthentikProviderId { get; set; }
|
||||||
|
public HealthStatus HealthStatus { get; set; }
|
||||||
|
public DateTime? LastHealthCheck { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Customer Customer { get; set; } = null!;
|
||||||
|
public ICollection<HealthEvent> HealthEvents { get; set; } = [];
|
||||||
|
public ICollection<ScreenSnapshot> ScreenSnapshots { get; set; } = [];
|
||||||
|
public ICollection<OauthAppRegistry> OauthAppRegistries { get; set; } = [];
|
||||||
|
public ICollection<ByoiConfig> ByoiConfigs { get; set; } = [];
|
||||||
|
}
|
||||||
26
OTSSignsOrchestrator.Server/Data/Entities/Job.cs
Normal file
26
OTSSignsOrchestrator.Server/Data/Entities/Job.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum JobStatus
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Job
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid CustomerId { get; set; }
|
||||||
|
public string JobType { get; set; } = string.Empty;
|
||||||
|
public JobStatus Status { get; set; }
|
||||||
|
public string? TriggeredBy { get; set; }
|
||||||
|
public string? Parameters { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public Customer Customer { get; set; } = null!;
|
||||||
|
public ICollection<JobStep> Steps { get; set; } = [];
|
||||||
|
}
|
||||||
22
OTSSignsOrchestrator.Server/Data/Entities/JobStep.cs
Normal file
22
OTSSignsOrchestrator.Server/Data/Entities/JobStep.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum JobStepStatus
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JobStep
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid JobId { get; set; }
|
||||||
|
public string StepName { get; set; } = string.Empty;
|
||||||
|
public JobStepStatus Status { get; set; }
|
||||||
|
public string? LogOutput { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
|
||||||
|
public Job Job { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public class OauthAppRegistry
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid InstanceId { get; set; }
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Instance Instance { get; set; } = null!;
|
||||||
|
}
|
||||||
18
OTSSignsOrchestrator.Server/Data/Entities/Operator.cs
Normal file
18
OTSSignsOrchestrator.Server/Data/Entities/Operator.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public enum OperatorRole
|
||||||
|
{
|
||||||
|
Admin,
|
||||||
|
Viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Operator
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public OperatorRole Role { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<RefreshToken> RefreshTokens { get; set; } = [];
|
||||||
|
}
|
||||||
12
OTSSignsOrchestrator.Server/Data/Entities/RefreshToken.cs
Normal file
12
OTSSignsOrchestrator.Server/Data/Entities/RefreshToken.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public class RefreshToken
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid OperatorId { get; set; }
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
public Operator Operator { get; set; } = null!;
|
||||||
|
}
|
||||||
12
OTSSignsOrchestrator.Server/Data/Entities/ScreenSnapshot.cs
Normal file
12
OTSSignsOrchestrator.Server/Data/Entities/ScreenSnapshot.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public class ScreenSnapshot
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid InstanceId { get; set; }
|
||||||
|
public DateOnly SnapshotDate { get; set; }
|
||||||
|
public int ScreenCount { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Instance Instance { get; set; } = null!;
|
||||||
|
}
|
||||||
9
OTSSignsOrchestrator.Server/Data/Entities/StripeEvent.cs
Normal file
9
OTSSignsOrchestrator.Server/Data/Entities/StripeEvent.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
public class StripeEvent
|
||||||
|
{
|
||||||
|
public string StripeEventId { get; set; } = string.Empty;
|
||||||
|
public string EventType { get; set; } = string.Empty;
|
||||||
|
public DateTime ProcessedAt { get; set; }
|
||||||
|
public string? Payload { get; set; }
|
||||||
|
}
|
||||||
190
OTSSignsOrchestrator.Server/Data/OrchestratorDbContext.cs
Normal file
190
OTSSignsOrchestrator.Server/Data/OrchestratorDbContext.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Data;
|
||||||
|
|
||||||
|
public class OrchestratorDbContext : DbContext
|
||||||
|
{
|
||||||
|
public OrchestratorDbContext(DbContextOptions<OrchestratorDbContext> options)
|
||||||
|
: base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Customer> Customers => Set<Customer>();
|
||||||
|
public DbSet<Instance> Instances => Set<Instance>();
|
||||||
|
public DbSet<Job> Jobs => Set<Job>();
|
||||||
|
public DbSet<JobStep> JobSteps => Set<JobStep>();
|
||||||
|
public DbSet<HealthEvent> HealthEvents => Set<HealthEvent>();
|
||||||
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
public DbSet<StripeEvent> StripeEvents => Set<StripeEvent>();
|
||||||
|
public DbSet<ScreenSnapshot> ScreenSnapshots => Set<ScreenSnapshot>();
|
||||||
|
public DbSet<OauthAppRegistry> OauthAppRegistries => Set<OauthAppRegistry>();
|
||||||
|
public DbSet<AuthentikMetrics> AuthentikMetrics => Set<AuthentikMetrics>();
|
||||||
|
public DbSet<Operator> Operators => Set<Operator>();
|
||||||
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<ByoiConfig> ByoiConfigs => Set<ByoiConfig>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// ── Snake-case naming convention ─────────────────────────────────
|
||||||
|
foreach (var entity in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
entity.SetTableName(ToSnakeCase(entity.GetTableName()!));
|
||||||
|
|
||||||
|
foreach (var property in entity.GetProperties())
|
||||||
|
property.SetColumnName(ToSnakeCase(property.GetColumnName()));
|
||||||
|
|
||||||
|
foreach (var key in entity.GetKeys())
|
||||||
|
key.SetName(ToSnakeCase(key.GetName()!));
|
||||||
|
|
||||||
|
foreach (var fk in entity.GetForeignKeys())
|
||||||
|
fk.SetConstraintName(ToSnakeCase(fk.GetConstraintName()!));
|
||||||
|
|
||||||
|
foreach (var index in entity.GetIndexes())
|
||||||
|
index.SetDatabaseName(ToSnakeCase(index.GetDatabaseName()!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer ────────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<Customer>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(c => c.Id);
|
||||||
|
e.Property(c => c.Abbreviation).HasMaxLength(8);
|
||||||
|
e.Property(c => c.Plan).HasConversion<string>();
|
||||||
|
e.Property(c => c.Status).HasConversion<string>();
|
||||||
|
e.Property(c => c.FailedPaymentCount).HasDefaultValue(0);
|
||||||
|
e.HasIndex(c => c.Abbreviation).IsUnique();
|
||||||
|
e.HasIndex(c => c.StripeCustomerId).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Instance ────────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<Instance>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(i => i.Id);
|
||||||
|
e.Property(i => i.HealthStatus).HasConversion<string>();
|
||||||
|
e.HasIndex(i => i.CustomerId);
|
||||||
|
e.HasIndex(i => i.DockerStackName).IsUnique();
|
||||||
|
e.HasOne(i => i.Customer)
|
||||||
|
.WithMany(c => c.Instances)
|
||||||
|
.HasForeignKey(i => i.CustomerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Job ─────────────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<Job>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(j => j.Id);
|
||||||
|
e.Property(j => j.Status).HasConversion<string>();
|
||||||
|
e.Property(j => j.Parameters).HasColumnType("text");
|
||||||
|
e.HasIndex(j => j.CustomerId);
|
||||||
|
e.HasOne(j => j.Customer)
|
||||||
|
.WithMany(c => c.Jobs)
|
||||||
|
.HasForeignKey(j => j.CustomerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── JobStep ─────────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<JobStep>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(s => s.Id);
|
||||||
|
e.Property(s => s.Status).HasConversion<string>();
|
||||||
|
e.Property(s => s.LogOutput).HasColumnType("text");
|
||||||
|
e.HasIndex(s => s.JobId);
|
||||||
|
e.HasOne(s => s.Job)
|
||||||
|
.WithMany(j => j.Steps)
|
||||||
|
.HasForeignKey(s => s.JobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── HealthEvent ─────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<HealthEvent>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(h => h.Id);
|
||||||
|
e.Property(h => h.Status).HasConversion<string>();
|
||||||
|
e.HasIndex(h => h.InstanceId);
|
||||||
|
e.HasOne(h => h.Instance)
|
||||||
|
.WithMany(i => i.HealthEvents)
|
||||||
|
.HasForeignKey(h => h.InstanceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AuditLog ────────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<AuditLog>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.Id);
|
||||||
|
e.Property(a => a.Detail).HasColumnType("text");
|
||||||
|
e.HasIndex(a => a.InstanceId);
|
||||||
|
e.HasIndex(a => a.OccurredAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── StripeEvent ─────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<StripeEvent>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(s => s.StripeEventId);
|
||||||
|
e.Property(s => s.Payload).HasColumnType("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ScreenSnapshot ──────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<ScreenSnapshot>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(s => s.Id);
|
||||||
|
e.HasIndex(s => s.InstanceId);
|
||||||
|
e.HasOne(s => s.Instance)
|
||||||
|
.WithMany(i => i.ScreenSnapshots)
|
||||||
|
.HasForeignKey(s => s.InstanceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── OauthAppRegistry ────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<OauthAppRegistry>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(o => o.Id);
|
||||||
|
e.HasIndex(o => o.InstanceId);
|
||||||
|
e.HasIndex(o => o.ClientId).IsUnique();
|
||||||
|
e.HasOne(o => o.Instance)
|
||||||
|
.WithMany(i => i.OauthAppRegistries)
|
||||||
|
.HasForeignKey(o => o.InstanceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AuthentikMetrics ────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<AuthentikMetrics>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.Id);
|
||||||
|
e.Property(a => a.Status).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Operator ────────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<Operator>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(o => o.Id);
|
||||||
|
e.Property(o => o.Role).HasConversion<string>();
|
||||||
|
e.HasIndex(o => o.Email).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── RefreshToken ────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<RefreshToken>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(r => r.Id);
|
||||||
|
e.HasIndex(r => r.Token).IsUnique();
|
||||||
|
e.HasIndex(r => r.OperatorId);
|
||||||
|
e.HasOne(r => r.Operator)
|
||||||
|
.WithMany(o => o.RefreshTokens)
|
||||||
|
.HasForeignKey(r => r.OperatorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ByoiConfig ──────────────────────────────────────────────────
|
||||||
|
modelBuilder.Entity<ByoiConfig>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(b => b.Id);
|
||||||
|
e.Property(b => b.CertPem).HasColumnType("text");
|
||||||
|
e.HasIndex(b => b.InstanceId);
|
||||||
|
e.HasIndex(b => b.Slug).IsUnique();
|
||||||
|
e.HasOne(b => b.Instance)
|
||||||
|
.WithMany(i => i.ByoiConfigs)
|
||||||
|
.HasForeignKey(b => b.InstanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToSnakeCase(string name)
|
||||||
|
{
|
||||||
|
return string.Concat(
|
||||||
|
name.Select((c, i) =>
|
||||||
|
i > 0 && char.IsUpper(c) && !char.IsUpper(name[i - 1])
|
||||||
|
? "_" + char.ToLowerInvariant(c)
|
||||||
|
: char.ToLowerInvariant(c).ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Quartz;
|
||||||
|
using OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quartz job that runs the <see cref="AuthentikGlobalHealthCheck"/> every 2 minutes
|
||||||
|
/// on a separate schedule from the per-instance health checks.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class AuthentikGlobalHealthJob : IJob
|
||||||
|
{
|
||||||
|
private readonly AuthentikGlobalHealthCheck _check;
|
||||||
|
private readonly ILogger<AuthentikGlobalHealthJob> _logger;
|
||||||
|
|
||||||
|
public AuthentikGlobalHealthJob(
|
||||||
|
AuthentikGlobalHealthCheck check,
|
||||||
|
ILogger<AuthentikGlobalHealthJob> logger)
|
||||||
|
{
|
||||||
|
_check = check;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _check.RunGlobalAsync(context.CancellationToken);
|
||||||
|
_logger.LogInformation("Authentik global health: {Status} — {Message}",
|
||||||
|
result.Status, result.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Authentik global health job failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that both <c>ots-admin-{abbrev}</c> and <c>ots-svc-{abbrev}</c> exist
|
||||||
|
/// with <c>userTypeId == 1</c> (SuperAdmin). MUST use <see cref="XiboApiClientExtensions.GetAllPagesAsync{T}"/>
|
||||||
|
/// because Xibo paginates at 10 items by default.
|
||||||
|
///
|
||||||
|
/// <c>saml-usertypeid</c> is JIT-only and does NOT maintain SuperAdmin on existing users —
|
||||||
|
/// this check IS the ongoing enforcement mechanism.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminIntegrityHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<AdminIntegrityHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "AdminIntegrity";
|
||||||
|
public bool AutoRemediate => true;
|
||||||
|
|
||||||
|
public AdminIntegrityHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<AdminIntegrityHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, abbrev) = await ResolveAsync(instance);
|
||||||
|
if (client is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app — cannot verify admin accounts");
|
||||||
|
|
||||||
|
var users = await client.GetAllPagesAsync(
|
||||||
|
(start, length) => client.GetUsersAsync(start, length));
|
||||||
|
|
||||||
|
var adminName = $"ots-admin-{abbrev}";
|
||||||
|
var svcName = $"ots-svc-{abbrev}";
|
||||||
|
var problems = new List<string>();
|
||||||
|
|
||||||
|
foreach (var expected in new[] { adminName, svcName })
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(u =>
|
||||||
|
u.TryGetValue("userName", out var n) &&
|
||||||
|
string.Equals(n?.ToString(), expected, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
problems.Add($"{expected} is MISSING");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.TryGetValue("userTypeId", out var typeObj) &&
|
||||||
|
typeObj?.ToString() != "1")
|
||||||
|
{
|
||||||
|
problems.Add($"{expected} has userTypeId={typeObj} (expected 1)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.Count == 0)
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy, "Admin accounts intact");
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
HealthStatus.Critical,
|
||||||
|
$"Admin integrity issues: {string.Join("; ", problems)}",
|
||||||
|
string.Join("\n", problems));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemediateAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, abbrev) = await ResolveAsync(instance);
|
||||||
|
if (client is null) return false;
|
||||||
|
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
|
||||||
|
var users = await client.GetAllPagesAsync(
|
||||||
|
(start, length) => client.GetUsersAsync(start, length));
|
||||||
|
|
||||||
|
var adminName = $"ots-admin-{abbrev}";
|
||||||
|
var svcName = $"ots-svc-{abbrev}";
|
||||||
|
var allFixed = true;
|
||||||
|
|
||||||
|
foreach (var expected in new[] { adminName, svcName })
|
||||||
|
{
|
||||||
|
var user = users.FirstOrDefault(u =>
|
||||||
|
u.TryGetValue("userName", out var n) &&
|
||||||
|
string.Equals(n?.ToString(), expected, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
// Recreate missing account
|
||||||
|
var email = $"{expected}@otssigns.internal";
|
||||||
|
var password = GenerateRandomPassword(32);
|
||||||
|
var createResp = await client.CreateUserAsync(new CreateUserRequest(
|
||||||
|
expected, email, password, UserTypeId: 1, HomePageId: 1));
|
||||||
|
|
||||||
|
if (!createResp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to recreate {User}: {Err}", expected, createResp.Error?.Content);
|
||||||
|
allFixed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instance.Id,
|
||||||
|
Actor = "HealthCheckEngine:AdminIntegrity",
|
||||||
|
Action = "RecreateUser",
|
||||||
|
Target = expected,
|
||||||
|
Outcome = "Success",
|
||||||
|
Detail = "User was missing — recreated as SuperAdmin",
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fix userTypeId if wrong
|
||||||
|
if (user.TryGetValue("userTypeId", out var typeObj) && typeObj?.ToString() != "1")
|
||||||
|
{
|
||||||
|
var userId = int.Parse(user["userId"]?.ToString() ?? "0");
|
||||||
|
if (userId == 0) { allFixed = false; continue; }
|
||||||
|
|
||||||
|
var updateResp = await client.UpdateUserAsync(userId, new UpdateUserRequest(
|
||||||
|
UserName: null, Email: null, Password: null, UserTypeId: 1,
|
||||||
|
HomePageId: null, Retired: null));
|
||||||
|
|
||||||
|
if (!updateResp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to fix userTypeId for {User}: {Err}",
|
||||||
|
expected, updateResp.Error?.Content);
|
||||||
|
allFixed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instance.Id,
|
||||||
|
Actor = "HealthCheckEngine:AdminIntegrity",
|
||||||
|
Action = "FixUserType",
|
||||||
|
Target = expected,
|
||||||
|
Outcome = "Success",
|
||||||
|
Detail = $"Changed userTypeId from {typeObj} to 1 (SuperAdmin)",
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return allFixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(IXiboApiClient? Client, string Abbrev)> ResolveAsync(Instance instance)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null) return (null, abbrev);
|
||||||
|
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret)) return (null, abbrev);
|
||||||
|
|
||||||
|
var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
return (client, abbrev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRandomPassword(int length)
|
||||||
|
{
|
||||||
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||||
|
return System.Security.Cryptography.RandomNumberGenerator.GetString(chars, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes the central Authentik instance at <c>GET /api/v3/-/health/ready/</c>.
|
||||||
|
/// Measures latency and writes an <see cref="AuthentikMetrics"/> row.
|
||||||
|
/// If down: <c>Severity = Critical</c>, message "Central Authentik is DOWN — all customer web UI logins failing".
|
||||||
|
/// This is a fleet-wide P1 alert. Runs every 2 minutes on a separate schedule.
|
||||||
|
///
|
||||||
|
/// This check is NOT per-instance — it runs once globally. The engine skips it for
|
||||||
|
/// per-instance checks. Instead it is scheduled independently as a Quartz job.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthentikGlobalHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IAuthentikClient _authentikClient;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<AuthentikGlobalHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "AuthentikGlobal";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public AuthentikGlobalHealthCheck(
|
||||||
|
IAuthentikClient authentikClient,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<AuthentikGlobalHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_authentikClient = authentikClient;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// This check doesn't use the instance parameter — it checks global Authentik health.
|
||||||
|
return await RunGlobalAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core logic — callable from the Quartz job without an instance context.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<HealthCheckResult> RunGlobalAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
AuthentikMetricsStatus metricsStatus;
|
||||||
|
string? errorMessage = null;
|
||||||
|
HealthCheckResult result;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _authentikClient.CheckHealthAsync();
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
metricsStatus = AuthentikMetricsStatus.Healthy;
|
||||||
|
result = new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"Authentik healthy (latency: {sw.ElapsedMilliseconds}ms)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
metricsStatus = AuthentikMetricsStatus.Critical;
|
||||||
|
errorMessage = $"HTTP {response.StatusCode}";
|
||||||
|
result = new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
"Central Authentik is DOWN — all customer web UI logins failing",
|
||||||
|
$"Health endpoint returned {response.StatusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
metricsStatus = AuthentikMetricsStatus.Critical;
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
result = new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
"Central Authentik is DOWN — all customer web UI logins failing",
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metrics row
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
db.AuthentikMetrics.Add(new AuthentikMetrics
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CheckedAt = DateTime.UtcNow,
|
||||||
|
Status = metricsStatus,
|
||||||
|
LatencyMs = (int)sw.ElapsedMilliseconds,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Broadcast alert if critical
|
||||||
|
if (result.Status == HealthStatus.Critical)
|
||||||
|
{
|
||||||
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||||||
|
await hub.Clients.All.SendAlertRaised("Critical", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the per-instance SAML provider in Authentik is active by checking
|
||||||
|
/// the provider exists using the stored <see cref="Instance.AuthentikProviderId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthentikSamlProviderHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IAuthentikClient _authentikClient;
|
||||||
|
private readonly ILogger<AuthentikSamlProviderHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "AuthentikSamlProvider";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public AuthentikSamlProviderHealthCheck(
|
||||||
|
IAuthentikClient authentikClient,
|
||||||
|
ILogger<AuthentikSamlProviderHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_authentikClient = authentikClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(instance.AuthentikProviderId))
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
"No Authentik provider ID stored — SAML not provisioned");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(instance.AuthentikProviderId, out var providerId))
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Invalid Authentik provider ID: {instance.AuthentikProviderId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _authentikClient.GetSamlProviderAsync(providerId);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode && response.Content is not null)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"SAML provider {providerId} is active in Authentik");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"SAML provider {providerId} not found or inaccessible",
|
||||||
|
response.Error?.Content);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Failed to check SAML provider: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For Pro plan BYOI customers: checks certificate expiry from <see cref="ByoiConfig"/>.
|
||||||
|
/// Alerts at 60-day (Warning), 30-day (Warning), 7-day (Critical) thresholds.
|
||||||
|
/// AutoRemediate=false — customer must rotate their IdP certificate via the portal.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ByoiCertExpiryHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
/// <summary>Alert thresholds in days (descending).</summary>
|
||||||
|
internal static readonly int[] AlertThresholdDays = [60, 30, 7];
|
||||||
|
|
||||||
|
/// <summary>Days at or below which severity escalates to Critical.</summary>
|
||||||
|
internal const int CriticalThresholdDays = 7;
|
||||||
|
|
||||||
|
public string CheckName => "ByoiCertExpiry";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Only applies to instances with an enabled BYOI config
|
||||||
|
var byoiConfig = instance.ByoiConfigs.FirstOrDefault(b => b.Enabled);
|
||||||
|
if (byoiConfig is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
"No BYOI config — check not applicable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Pro customers have BYOI
|
||||||
|
if (instance.Customer.Plan != CustomerPlan.Pro)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
"Non-Pro plan — BYOI check not applicable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var daysRemaining = (byoiConfig.CertExpiry - DateTime.UtcNow).TotalDays;
|
||||||
|
|
||||||
|
if (daysRemaining <= 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"BYOI certificate has EXPIRED (expired {Math.Abs((int)daysRemaining)} days ago)",
|
||||||
|
"Customer must rotate their IdP certificate via the portal immediately"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysRemaining <= CriticalThresholdDays)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"BYOI certificate expires in {(int)daysRemaining} days",
|
||||||
|
"Urgent: customer must rotate their IdP certificate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check warning thresholds (60 and 30 days)
|
||||||
|
foreach (var threshold in AlertThresholdDays)
|
||||||
|
{
|
||||||
|
if (threshold <= CriticalThresholdDays) continue;
|
||||||
|
if (daysRemaining <= threshold)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
$"BYOI certificate expires in {(int)daysRemaining} days (threshold: {threshold}d)",
|
||||||
|
"Customer should plan certificate rotation"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"BYOI certificate valid for {(int)daysRemaining} more days"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the count of authorised displays does not exceed the customer's licensed
|
||||||
|
/// <see cref="Customer.ScreenCount"/>. Uses <see cref="XiboApiClientExtensions.GetAllPagesAsync{T}"/>
|
||||||
|
/// with <c>authorised=1</c> filter to get all authorised displays.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DisplayAuthorisedHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<DisplayAuthorisedHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "DisplayAuthorised";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public DisplayAuthorisedHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<DisplayAuthorisedHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, _) = await ResolveAsync(instance);
|
||||||
|
if (client is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app — cannot check displays");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var displays = await client.GetAllPagesAsync(
|
||||||
|
(start, length) => client.GetDisplaysAsync(start, length, authorised: 1));
|
||||||
|
|
||||||
|
var authorisedCount = displays.Count;
|
||||||
|
var licensed = instance.Customer.ScreenCount;
|
||||||
|
|
||||||
|
if (authorisedCount <= licensed)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"Authorised displays: {authorisedCount}/{licensed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
$"Authorised displays ({authorisedCount}) exceeds license ({licensed})",
|
||||||
|
$"Over-provisioned by {authorisedCount - licensed} display(s)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Failed to check displays: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(IXiboApiClient? Client, string Abbrev)> ResolveAsync(Instance instance)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null) return (null, abbrev);
|
||||||
|
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret)) return (null, abbrev);
|
||||||
|
|
||||||
|
var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
return (client, abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies all 4 expected Xibo groups exist for the instance:
|
||||||
|
/// <c>{abbrev}-viewer</c>, <c>{abbrev}-editor</c>, <c>{abbrev}-admin</c>, <c>ots-it-{abbrev}</c>.
|
||||||
|
/// Uses <see cref="XiboApiClientExtensions.GetAllPagesAsync{T}"/> to avoid pagination truncation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupStructureHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<GroupStructureHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "GroupStructure";
|
||||||
|
public bool AutoRemediate => true;
|
||||||
|
|
||||||
|
public GroupStructureHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<GroupStructureHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, abbrev) = await ResolveAsync(instance);
|
||||||
|
if (client is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app — cannot verify groups");
|
||||||
|
|
||||||
|
var expected = ExpectedGroups(abbrev);
|
||||||
|
var groups = await client.GetAllPagesAsync(
|
||||||
|
(start, length) => client.GetGroupsAsync(start, length));
|
||||||
|
|
||||||
|
var existing = groups
|
||||||
|
.Select(g => g.TryGetValue("group", out var n) ? n?.ToString() : null)
|
||||||
|
.Where(n => n is not null)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var missing = expected.Where(e => !existing.Contains(e)).ToList();
|
||||||
|
|
||||||
|
if (missing.Count == 0)
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy, "All 4 expected groups present");
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
HealthStatus.Critical,
|
||||||
|
$"Missing groups: {string.Join(", ", missing)}",
|
||||||
|
$"Expected: {string.Join(", ", expected)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemediateAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, abbrev) = await ResolveAsync(instance);
|
||||||
|
if (client is null) return false;
|
||||||
|
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
|
||||||
|
var expected = ExpectedGroups(abbrev);
|
||||||
|
var groups = await client.GetAllPagesAsync(
|
||||||
|
(start, length) => client.GetGroupsAsync(start, length));
|
||||||
|
|
||||||
|
var existing = groups
|
||||||
|
.Select(g => g.TryGetValue("group", out var n) ? n?.ToString() : null)
|
||||||
|
.Where(n => n is not null)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var allFixed = true;
|
||||||
|
foreach (var name in expected.Where(e => !existing.Contains(e)))
|
||||||
|
{
|
||||||
|
var resp = await client.CreateGroupAsync(new CreateGroupRequest(name, $"Auto-created by health check for {abbrev}"));
|
||||||
|
if (resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instance.Id,
|
||||||
|
Actor = "HealthCheckEngine:GroupStructure",
|
||||||
|
Action = "CreateGroup",
|
||||||
|
Target = name,
|
||||||
|
Outcome = "Success",
|
||||||
|
Detail = $"Recreated missing group {name}",
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to create group {Group}: {Err}", name, resp.Error?.Content);
|
||||||
|
allFixed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return allFixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ExpectedGroups(string abbrev) =>
|
||||||
|
[
|
||||||
|
$"{abbrev}-viewer",
|
||||||
|
$"{abbrev}-editor",
|
||||||
|
$"{abbrev}-admin",
|
||||||
|
$"ots-it-{abbrev}",
|
||||||
|
];
|
||||||
|
|
||||||
|
private async Task<(IXiboApiClient? Client, string Abbrev)> ResolveAsync(Instance instance)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null) return (null, abbrev);
|
||||||
|
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret)) return (null, abbrev);
|
||||||
|
|
||||||
|
var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
return (client, abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the <c>invite-{abbrev}</c> flow exists in Authentik by searching for it
|
||||||
|
/// in the invitation stages list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InvitationFlowHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IAuthentikClient _authentikClient;
|
||||||
|
private readonly ILogger<InvitationFlowHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "InvitationFlow";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public InvitationFlowHealthCheck(
|
||||||
|
IAuthentikClient authentikClient,
|
||||||
|
ILogger<InvitationFlowHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_authentikClient = authentikClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var expectedName = $"invite-{abbrev}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Search Authentik groups for evidence of the invitation flow
|
||||||
|
// The invitation is created as a stage invitation; we verify via the
|
||||||
|
// Authentik API by searching for it by name.
|
||||||
|
var groupResponse = await _authentikClient.ListGroupsAsync(expectedName);
|
||||||
|
|
||||||
|
if (groupResponse.IsSuccessStatusCode && groupResponse.Content?.Results is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var found = groupResponse.Content.Results.Any(g =>
|
||||||
|
g.TryGetValue("name", out var n) &&
|
||||||
|
string.Equals(n?.ToString(), expectedName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (found)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"Invitation flow '{expectedName}' exists in Authentik");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If groups don't show it, it's still possible the invitation was created
|
||||||
|
// as a separate stage object. Log as degraded since we can't fully confirm.
|
||||||
|
return new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
$"Invitation flow '{expectedName}' not found in Authentik",
|
||||||
|
"The invitation may exist but could not be verified via group search");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Failed to check invitation flow: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Renci.SshNet;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies connectivity to the instance's MySQL database by running a simple query
|
||||||
|
/// via SSH against the Docker Swarm host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MySqlConnectHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<MySqlConnectHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "MySqlConnect";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public MySqlConnectHealthCheck(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<MySqlConnectHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dbName = instance.MysqlDatabase;
|
||||||
|
if (string.IsNullOrEmpty(dbName))
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No MySQL database configured");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||||
|
var mysqlHost = await settings.GetAsync(Core.Services.SettingsService.MySqlHost, "localhost");
|
||||||
|
var mysqlPort = await settings.GetAsync(Core.Services.SettingsService.MySqlPort, "3306");
|
||||||
|
var mysqlUser = await settings.GetAsync(Core.Services.SettingsService.MySqlAdminUser, "root");
|
||||||
|
var mysqlPass = await settings.GetAsync(Core.Services.SettingsService.MySqlAdminPassword, "");
|
||||||
|
|
||||||
|
using var sshClient = CreateSshClient(sshInfo);
|
||||||
|
sshClient.Connect();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Simple connectivity test — SELECT 1 against the instance database
|
||||||
|
var cmd = $"mysql -h {mysqlHost} -P {mysqlPort} -u {mysqlUser} " +
|
||||||
|
$"-p'{mysqlPass}' -e 'SELECT 1' {dbName} 2>&1";
|
||||||
|
var output = RunSshCommand(sshClient, cmd);
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"MySQL connection to {dbName} successful");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sshClient.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"MySQL connection failed for {dbName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
|
||||||
|
{
|
||||||
|
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||||||
|
?? throw new InvalidOperationException("SSH Swarm host not configured.");
|
||||||
|
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||||||
|
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||||||
|
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||||||
|
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||||||
|
if (!int.TryParse(portStr, out var port)) port = 22;
|
||||||
|
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||||||
|
{
|
||||||
|
var authMethods = new List<AuthenticationMethod>();
|
||||||
|
if (!string.IsNullOrEmpty(info.KeyPath))
|
||||||
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||||||
|
if (!string.IsNullOrEmpty(info.Password))
|
||||||
|
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||||||
|
if (authMethods.Count == 0)
|
||||||
|
{
|
||||||
|
var defaultKeyPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||||
|
if (File.Exists(defaultKeyPath))
|
||||||
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
|
||||||
|
}
|
||||||
|
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||||||
|
return new SshClient(connInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RunSshCommand(SshClient client, string command)
|
||||||
|
{
|
||||||
|
using var cmd = client.RunCommand(command);
|
||||||
|
if (cmd.ExitStatus != 0)
|
||||||
|
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||||||
|
return cmd.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Renci.SshNet;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies NFS paths for the instance are accessible by running <c>ls</c> via SSH.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NfsAccessHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<NfsAccessHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "NfsAccess";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public NfsAccessHealthCheck(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<NfsAccessHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var nfsPath = instance.NfsPath;
|
||||||
|
if (string.IsNullOrEmpty(nfsPath))
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No NFS path configured");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||||
|
var nfsServer = await settings.GetAsync(Core.Services.SettingsService.NfsServer);
|
||||||
|
var nfsExport = await settings.GetAsync(Core.Services.SettingsService.NfsExport);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(nfsServer) || string.IsNullOrEmpty(nfsExport))
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "NFS server/export not configured");
|
||||||
|
|
||||||
|
using var sshClient = CreateSshClient(sshInfo);
|
||||||
|
sshClient.Connect();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Mount temporarily and check the path is listable
|
||||||
|
var mountPoint = $"/tmp/healthcheck-nfs-{Guid.NewGuid():N}";
|
||||||
|
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{nfsExport} {mountPoint}");
|
||||||
|
var output = RunSshCommand(sshClient, $"ls {mountPoint}/{nfsPath} 2>&1");
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"NFS path accessible: {nfsPath}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||||||
|
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sshClient.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"NFS access check failed for {nfsPath}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
|
||||||
|
{
|
||||||
|
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||||||
|
?? throw new InvalidOperationException("SSH Swarm host not configured.");
|
||||||
|
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||||||
|
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||||||
|
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||||||
|
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||||||
|
if (!int.TryParse(portStr, out var port)) port = 22;
|
||||||
|
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||||||
|
{
|
||||||
|
var authMethods = new List<AuthenticationMethod>();
|
||||||
|
if (!string.IsNullOrEmpty(info.KeyPath))
|
||||||
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||||||
|
if (!string.IsNullOrEmpty(info.Password))
|
||||||
|
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||||||
|
if (authMethods.Count == 0)
|
||||||
|
{
|
||||||
|
var defaultKeyPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||||
|
if (File.Exists(defaultKeyPath))
|
||||||
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
|
||||||
|
}
|
||||||
|
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||||||
|
return new SshClient(connInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RunSshCommand(SshClient client, string command)
|
||||||
|
{
|
||||||
|
using var cmd = client.RunCommand(command);
|
||||||
|
if (cmd.ExitStatus != 0)
|
||||||
|
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||||||
|
return cmd.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunSshCommandAllowFailure(SshClient client, string command)
|
||||||
|
{
|
||||||
|
using var cmd = client.RunCommand(command);
|
||||||
|
// Intentionally ignore exit code — cleanup operations
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the age of the OAuth2 application credentials from <see cref="OauthAppRegistry.CreatedAt"/>.
|
||||||
|
/// Alerts Warning at 180 days, Critical at 365 days. AutoRemediate=false — suggests
|
||||||
|
/// a "rotate-oauth2" job instead.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OauthAppAgeHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
/// <summary>Days at which severity escalates to Warning.</summary>
|
||||||
|
internal const int WarningThresholdDays = 180;
|
||||||
|
|
||||||
|
/// <summary>Days at which severity escalates to Critical.</summary>
|
||||||
|
internal const int CriticalThresholdDays = 365;
|
||||||
|
|
||||||
|
public string CheckName => "OauthAppAge";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var oauthApp = instance.OauthAppRegistries
|
||||||
|
.OrderByDescending(o => o.CreatedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (oauthApp is null)
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
"No OAuth app registered"));
|
||||||
|
|
||||||
|
var ageDays = (DateTime.UtcNow - oauthApp.CreatedAt).TotalDays;
|
||||||
|
|
||||||
|
if (ageDays >= CriticalThresholdDays)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"OAuth2 credentials are {(int)ageDays} days old (critical threshold: {CriticalThresholdDays}d)",
|
||||||
|
"Create a 'rotate-credentials' job to rotate the OAuth2 application"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ageDays >= WarningThresholdDays)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
$"OAuth2 credentials are {(int)ageDays} days old (warning threshold: {WarningThresholdDays}d)",
|
||||||
|
"Schedule credential rotation before they reach 365 days"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"OAuth2 credentials are {(int)ageDays} days old"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the OAuth2 app in <see cref="OauthAppRegistry"/> can still authenticate
|
||||||
|
/// by testing a <c>client_credentials</c> flow against the Xibo CMS instance.
|
||||||
|
/// AutoRemediate=false — credential rotation requires a separate job.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OauthAppHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<OauthAppHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "OauthApp";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public OauthAppHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<OauthAppHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app registered");
|
||||||
|
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(secret))
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
"OAuth client secret not found in Bitwarden — cannot authenticate");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Attempt to create a client (which fetches a token via client_credentials)
|
||||||
|
var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
|
||||||
|
// If we got here, the token was obtained successfully
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy, "OAuth2 client_credentials flow successful");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"OAuth2 authentication failed: {ex.Message}",
|
||||||
|
"Credential rotation job may be required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
OTSSignsOrchestrator.Server/Health/Checks/StackHealthCheck.cs
Normal file
127
OTSSignsOrchestrator.Server/Health/Checks/StackHealthCheck.cs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
using Renci.SshNet;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the Docker stack is healthy by running <c>docker stack ps {stackName}</c>
|
||||||
|
/// via SSH and checking that all services report Running state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StackHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<StackHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "StackHealth";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public StackHealthCheck(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<StackHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var stackName = instance.DockerStackName;
|
||||||
|
if (string.IsNullOrEmpty(stackName))
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No Docker stack name configured");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||||
|
|
||||||
|
using var sshClient = CreateSshClient(sshInfo);
|
||||||
|
sshClient.Connect();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get task status for all services in the stack
|
||||||
|
var output = RunSshCommand(sshClient,
|
||||||
|
$"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'");
|
||||||
|
|
||||||
|
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var notRunning = new List<string>();
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var parts = line.Split('|');
|
||||||
|
if (parts.Length < 3) continue;
|
||||||
|
|
||||||
|
var name = parts[0].Trim();
|
||||||
|
var currentState = parts[1].Trim();
|
||||||
|
var desiredState = parts[2].Trim();
|
||||||
|
|
||||||
|
// Only check tasks whose desired state is Running
|
||||||
|
if (desiredState.Equals("Running", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!currentState.StartsWith("Running", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
notRunning.Add($"{name}: {currentState}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notRunning.Count == 0)
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"All services in {stackName} are Running");
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"{notRunning.Count} service(s) not running in {stackName}",
|
||||||
|
string.Join("\n", notRunning));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sshClient.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"SSH check failed for {stackName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
|
||||||
|
{
|
||||||
|
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||||||
|
?? throw new InvalidOperationException("SSH Swarm host not configured.");
|
||||||
|
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||||||
|
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||||||
|
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||||||
|
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||||||
|
if (!int.TryParse(portStr, out var port)) port = 22;
|
||||||
|
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||||||
|
{
|
||||||
|
var authMethods = new List<AuthenticationMethod>();
|
||||||
|
if (!string.IsNullOrEmpty(info.KeyPath))
|
||||||
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||||||
|
if (!string.IsNullOrEmpty(info.Password))
|
||||||
|
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||||||
|
if (authMethods.Count == 0)
|
||||||
|
{
|
||||||
|
var defaultKeyPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||||
|
if (File.Exists(defaultKeyPath))
|
||||||
|
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
|
||||||
|
}
|
||||||
|
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||||||
|
return new SshClient(connInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RunSshCommand(SshClient client, string command)
|
||||||
|
{
|
||||||
|
using var cmd = client.RunCommand(command);
|
||||||
|
if (cmd.ExitStatus != 0)
|
||||||
|
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||||||
|
return cmd.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
|
||||||
|
}
|
||||||
145
OTSSignsOrchestrator.Server/Health/Checks/ThemeHealthCheck.cs
Normal file
145
OTSSignsOrchestrator.Server/Health/Checks/ThemeHealthCheck.cs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the Xibo CMS theme is set to <c>otssigns</c> by calling <c>GET /api/settings</c>.
|
||||||
|
/// Auto-remediates by calling <c>PUT /api/settings</c> if the theme is incorrect.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ThemeHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<ThemeHealthCheck> _logger;
|
||||||
|
|
||||||
|
private const string ExpectedTheme = "otssigns";
|
||||||
|
|
||||||
|
public string CheckName => "Theme";
|
||||||
|
public bool AutoRemediate => true;
|
||||||
|
|
||||||
|
public ThemeHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<ThemeHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, _) = await ResolveAsync(instance);
|
||||||
|
if (client is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app — cannot check theme");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settingsResp = await client.GetSettingsAsync();
|
||||||
|
if (!settingsResp.IsSuccessStatusCode)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"GET /settings returned {settingsResp.StatusCode}");
|
||||||
|
|
||||||
|
var settings = settingsResp.Content;
|
||||||
|
if (settings is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "Settings response was null");
|
||||||
|
|
||||||
|
// Xibo returns settings as a list of { setting, value } objects or a dictionary
|
||||||
|
var themeName = ExtractSetting(settings, "THEME_NAME");
|
||||||
|
if (string.Equals(themeName, ExpectedTheme, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy, $"Theme is {ExpectedTheme}");
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Theme is '{themeName}', expected '{ExpectedTheme}'");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Theme check failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemediateAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (client, _) = await ResolveAsync(instance);
|
||||||
|
if (client is null) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await client.UpdateSettingsAsync(
|
||||||
|
new UpdateSettingsRequest(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["THEME_NAME"] = ExpectedTheme,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instance.Id,
|
||||||
|
Actor = "HealthCheckEngine:Theme",
|
||||||
|
Action = "FixTheme",
|
||||||
|
Target = instance.Customer.Abbreviation,
|
||||||
|
Outcome = "Success",
|
||||||
|
Detail = $"Reset THEME_NAME to {ExpectedTheme}",
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogError("Failed to fix theme: {Err}", resp.Error?.Content);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Theme remediation failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractSetting(object settingsObj, string key)
|
||||||
|
{
|
||||||
|
// Settings may come back as a dictionary or a list of objects
|
||||||
|
if (settingsObj is System.Text.Json.JsonElement je)
|
||||||
|
{
|
||||||
|
if (je.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||||
|
je.TryGetProperty(key, out var val))
|
||||||
|
return val.GetString();
|
||||||
|
|
||||||
|
if (je.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in je.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("setting", out var settingProp) &&
|
||||||
|
string.Equals(settingProp.GetString(), key, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
item.TryGetProperty("value", out var valueProp))
|
||||||
|
{
|
||||||
|
return valueProp.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(IXiboApiClient? Client, string Abbrev)> ResolveAsync(Instance instance)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null) return (null, abbrev);
|
||||||
|
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret)) return (null, abbrev);
|
||||||
|
|
||||||
|
var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
return (client, abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the Xibo CMS API is reachable by calling GET /about and expecting a 200 response.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class XiboApiHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<XiboApiHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "XiboApi";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public XiboApiHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<XiboApiHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var client = await ResolveClientAsync(instance);
|
||||||
|
if (client is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app registered — cannot reach API");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAboutAsync();
|
||||||
|
return response.IsSuccessStatusCode
|
||||||
|
? new HealthCheckResult(HealthStatus.Healthy, "Xibo API reachable")
|
||||||
|
: new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Xibo API returned {response.StatusCode}",
|
||||||
|
response.Error?.Content);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, $"Xibo API unreachable: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IXiboApiClient?> ResolveClientAsync(Instance instance)
|
||||||
|
{
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null) return null;
|
||||||
|
|
||||||
|
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Core.Services.SettingsService>();
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret)) return null;
|
||||||
|
|
||||||
|
return await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares the installed Xibo CMS version (from GET /about) against the latest known
|
||||||
|
/// release configured in <c>HealthChecks:LatestXiboVersion</c>. Reports Degraded if behind.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class XiboVersionHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly XiboClientFactory _clientFactory;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<XiboVersionHealthCheck> _logger;
|
||||||
|
|
||||||
|
public string CheckName => "XiboVersion";
|
||||||
|
public bool AutoRemediate => false;
|
||||||
|
|
||||||
|
public XiboVersionHealthCheck(
|
||||||
|
XiboClientFactory clientFactory,
|
||||||
|
IServiceProvider services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<XiboVersionHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_services = services;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var latestVersion = _configuration["HealthChecks:LatestXiboVersion"];
|
||||||
|
if (string.IsNullOrEmpty(latestVersion))
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy, "LatestXiboVersion not configured — skipping");
|
||||||
|
|
||||||
|
var (client, _) = await ResolveAsync(instance);
|
||||||
|
if (client is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "No OAuth app — cannot check version");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAboutAsync();
|
||||||
|
if (!response.IsSuccessStatusCode || response.Content is null)
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical, "GET /about failed");
|
||||||
|
|
||||||
|
string? installedVersion = null;
|
||||||
|
if (response.Content is System.Text.Json.JsonElement je &&
|
||||||
|
je.TryGetProperty("version", out var verProp))
|
||||||
|
{
|
||||||
|
installedVersion = verProp.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(installedVersion))
|
||||||
|
return new HealthCheckResult(HealthStatus.Degraded, "Could not determine installed version");
|
||||||
|
|
||||||
|
if (string.Equals(installedVersion, latestVersion, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new HealthCheckResult(HealthStatus.Healthy,
|
||||||
|
$"Xibo version {installedVersion} is current");
|
||||||
|
|
||||||
|
return new HealthCheckResult(HealthStatus.Degraded,
|
||||||
|
$"Xibo version {installedVersion}, latest is {latestVersion}",
|
||||||
|
"Consider scheduling an upgrade");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(HealthStatus.Critical,
|
||||||
|
$"Version check failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(IXiboApiClient? Client, string Abbrev)> ResolveAsync(Instance instance)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null) return (null, abbrev);
|
||||||
|
|
||||||
|
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret)) return (null, abbrev);
|
||||||
|
|
||||||
|
var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
return (client, abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
289
OTSSignsOrchestrator.Server/Health/HealthCheckEngine.cs
Normal file
289
OTSSignsOrchestrator.Server/Health/HealthCheckEngine.cs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Quartz;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that schedules and runs all <see cref="IHealthCheck"/> implementations
|
||||||
|
/// against every active <see cref="Instance"/>. Persists <see cref="HealthEvent"/> rows,
|
||||||
|
/// aggregates worst-severity to update <see cref="Instance.HealthStatus"/>,
|
||||||
|
/// broadcasts changes via <see cref="FleetHub"/>, and triggers auto-remediation when applicable.
|
||||||
|
///
|
||||||
|
/// Uses Quartz to stagger per-instance jobs across the check interval (avoids thundering herd).
|
||||||
|
/// Concurrency is capped at 4 simultaneous check runs via <see cref="SemaphoreSlim"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HealthCheckEngine : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ISchedulerFactory _schedulerFactory;
|
||||||
|
private readonly ILogger<HealthCheckEngine> _logger;
|
||||||
|
|
||||||
|
/// <summary>Default interval between full health-check sweeps.</summary>
|
||||||
|
internal static readonly TimeSpan DefaultCheckInterval = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public HealthCheckEngine(
|
||||||
|
IServiceProvider services,
|
||||||
|
ISchedulerFactory schedulerFactory,
|
||||||
|
ILogger<HealthCheckEngine> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_schedulerFactory = schedulerFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Wait briefly for the rest of the app to start
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
|
|
||||||
|
var scheduler = await _schedulerFactory.GetScheduler(stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ScheduleInstanceChecks(scheduler, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error scheduling health check sweep");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(DefaultCheckInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load all active instances and schedule staggered Quartz jobs so that
|
||||||
|
/// check start times are spread across the interval.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ScheduleInstanceChecks(IScheduler scheduler, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
|
||||||
|
var instances = await db.Instances
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(i => i.Customer)
|
||||||
|
.Where(i => i.Customer.Status == CustomerStatus.Active)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (instances.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Spread jobs across 80 % of the check interval to leave a buffer
|
||||||
|
var spreadMs = (int)(DefaultCheckInterval.TotalMilliseconds * 0.8);
|
||||||
|
var stepMs = instances.Count > 1 ? spreadMs / (instances.Count - 1) : 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < instances.Count; i++)
|
||||||
|
{
|
||||||
|
var instance = instances[i];
|
||||||
|
var delay = TimeSpan.FromMilliseconds(stepMs * i);
|
||||||
|
|
||||||
|
var jobKey = new JobKey($"health-{instance.Id}", "health-checks");
|
||||||
|
|
||||||
|
// Remove previous trigger if it still exists (idempotent reschedule)
|
||||||
|
if (await scheduler.CheckExists(jobKey, ct))
|
||||||
|
await scheduler.DeleteJob(jobKey, ct);
|
||||||
|
|
||||||
|
var job = JobBuilder.Create<InstanceHealthCheckJob>()
|
||||||
|
.WithIdentity(jobKey)
|
||||||
|
.UsingJobData("instanceId", instance.Id.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var trigger = TriggerBuilder.Create()
|
||||||
|
.WithIdentity($"health-{instance.Id}-trigger", "health-checks")
|
||||||
|
.StartAt(DateTimeOffset.UtcNow.Add(delay))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await scheduler.ScheduleJob(job, trigger, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Scheduled health checks for {Count} active instance(s)", instances.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quartz job that executes all <see cref="IHealthCheck"/> implementations for a single instance.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class InstanceHealthCheckJob : IJob
|
||||||
|
{
|
||||||
|
/// <summary>Global concurrency limiter — max 4 parallel health check runs.</summary>
|
||||||
|
private static readonly SemaphoreSlim s_concurrency = new(4);
|
||||||
|
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<InstanceHealthCheckJob> _logger;
|
||||||
|
|
||||||
|
public InstanceHealthCheckJob(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<InstanceHealthCheckJob> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var instanceIdStr = context.MergedJobDataMap.GetString("instanceId");
|
||||||
|
if (!Guid.TryParse(instanceIdStr, out var instanceId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("InstanceHealthCheckJob: invalid instanceId {Id}", instanceIdStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await s_concurrency.WaitAsync(context.CancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunChecksForInstanceAsync(instanceId, context.CancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
s_concurrency.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunChecksForInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||||||
|
var checks = scope.ServiceProvider.GetServices<IHealthCheck>();
|
||||||
|
|
||||||
|
var instance = await db.Instances
|
||||||
|
.Include(i => i.Customer)
|
||||||
|
.Include(i => i.OauthAppRegistries)
|
||||||
|
.Include(i => i.ByoiConfigs)
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == instanceId, ct);
|
||||||
|
|
||||||
|
if (instance is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("InstanceHealthCheckJob: instance {Id} not found", instanceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
var worstStatus = HealthStatus.Healthy;
|
||||||
|
|
||||||
|
foreach (var check in checks)
|
||||||
|
{
|
||||||
|
// Skip the AuthentikGlobalHealthCheck — it runs on its own schedule
|
||||||
|
if (check.CheckName == "AuthentikGlobal")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
HealthCheckResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await check.RunAsync(instance, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Health check {Check} failed for {Abbrev}", check.CheckName, abbrev);
|
||||||
|
result = new HealthCheckResult(HealthStatus.Critical, $"Check threw exception: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist HealthEvent
|
||||||
|
var healthEvent = new HealthEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instanceId,
|
||||||
|
CheckName = check.CheckName,
|
||||||
|
Status = ToEventStatus(result.Status),
|
||||||
|
Message = result.Message,
|
||||||
|
Remediated = false,
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-remediation
|
||||||
|
if (check.AutoRemediate && result.Status == HealthStatus.Critical)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fixed_ = await check.RemediateAsync(instance, ct);
|
||||||
|
healthEvent.Remediated = fixed_;
|
||||||
|
|
||||||
|
// Append-only audit log
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instanceId,
|
||||||
|
Actor = $"HealthCheckEngine:{check.CheckName}",
|
||||||
|
Action = "AutoRemediate",
|
||||||
|
Target = abbrev,
|
||||||
|
Outcome = fixed_ ? "Success" : "Failed",
|
||||||
|
Detail = result.Detail,
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fixed_)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Auto-remediated {Check} for {Abbrev}", check.CheckName, abbrev);
|
||||||
|
// Downgrade severity since we fixed it
|
||||||
|
healthEvent.Status = HealthEventStatus.Healthy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Remediation for {Check} failed on {Abbrev}", check.CheckName, abbrev);
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instanceId,
|
||||||
|
Actor = $"HealthCheckEngine:{check.CheckName}",
|
||||||
|
Action = "AutoRemediate",
|
||||||
|
Target = abbrev,
|
||||||
|
Outcome = "Error",
|
||||||
|
Detail = ex.Message,
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.HealthEvents.Add(healthEvent);
|
||||||
|
|
||||||
|
// Track worst severity (only from non-remediated results)
|
||||||
|
if (!healthEvent.Remediated)
|
||||||
|
{
|
||||||
|
var status = FromEventStatus(healthEvent.Status);
|
||||||
|
if (status > worstStatus)
|
||||||
|
worstStatus = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update instance health status
|
||||||
|
var previousStatus = instance.HealthStatus;
|
||||||
|
instance.HealthStatus = worstStatus;
|
||||||
|
instance.LastHealthCheck = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Broadcast status change
|
||||||
|
if (previousStatus != worstStatus)
|
||||||
|
{
|
||||||
|
await hub.Clients.All.SendInstanceStatusChanged(
|
||||||
|
instance.CustomerId.ToString(), worstStatus.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HealthEventStatus ToEventStatus(HealthStatus status) => status switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => HealthEventStatus.Healthy,
|
||||||
|
HealthStatus.Degraded => HealthEventStatus.Degraded,
|
||||||
|
HealthStatus.Critical => HealthEventStatus.Critical,
|
||||||
|
_ => HealthEventStatus.Critical,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HealthStatus FromEventStatus(HealthEventStatus status) => status switch
|
||||||
|
{
|
||||||
|
HealthEventStatus.Healthy => HealthStatus.Healthy,
|
||||||
|
HealthEventStatus.Degraded => HealthStatus.Degraded,
|
||||||
|
HealthEventStatus.Critical => HealthStatus.Critical,
|
||||||
|
_ => HealthStatus.Critical,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
OTSSignsOrchestrator.Server/Health/IHealthCheck.cs
Normal file
32
OTSSignsOrchestrator.Server/Health/IHealthCheck.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a single health check execution.
|
||||||
|
/// </summary>
|
||||||
|
public record HealthCheckResult(HealthStatus Status, string Message, string? Detail = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contract for an individual health check that runs against a specific <see cref="Instance"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHealthCheck
|
||||||
|
{
|
||||||
|
/// <summary>Human-readable name written to <see cref="HealthEvent.CheckName"/>.</summary>
|
||||||
|
string CheckName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true the engine will automatically call <see cref="RemediateAsync"/>
|
||||||
|
/// if the check returns <see cref="HealthStatus.Critical"/>.
|
||||||
|
/// </summary>
|
||||||
|
bool AutoRemediate { get; }
|
||||||
|
|
||||||
|
/// <summary>Execute the check for <paramref name="instance"/>.</summary>
|
||||||
|
Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempt automatic remediation. Return true if the issue was fixed.
|
||||||
|
/// The default implementation does nothing and returns false.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> RemediateAsync(Instance instance, CancellationToken ct) => Task.FromResult(false);
|
||||||
|
}
|
||||||
49
OTSSignsOrchestrator.Server/Hubs/FleetHub.cs
Normal file
49
OTSSignsOrchestrator.Server/Hubs/FleetHub.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server→client push-only hub for real-time fleet notifications.
|
||||||
|
/// Desktop clients never send messages via SignalR — they use REST for commands.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public class FleetHub : Hub<IFleetClient>
|
||||||
|
{
|
||||||
|
private readonly ILogger<FleetHub> _logger;
|
||||||
|
|
||||||
|
public FleetHub(ILogger<FleetHub> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var name = Context.User?.FindFirst(ClaimTypes.Name)?.Value ?? "unknown";
|
||||||
|
_logger.LogInformation("FleetHub: operator {Name} connected (connId={ConnectionId})",
|
||||||
|
name, Context.ConnectionId);
|
||||||
|
return base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
var name = Context.User?.FindFirst(ClaimTypes.Name)?.Value ?? "unknown";
|
||||||
|
_logger.LogInformation("FleetHub: operator {Name} disconnected (connId={ConnectionId})",
|
||||||
|
name, Context.ConnectionId);
|
||||||
|
return base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed client interface for FleetHub push messages.
|
||||||
|
/// Inject IHubContext<FleetHub, IFleetClient> to call these from services.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFleetClient
|
||||||
|
{
|
||||||
|
Task SendJobCreated(string jobId, string abbrev, string jobType);
|
||||||
|
Task SendJobProgressUpdate(string jobId, string stepName, int pct, string logLine);
|
||||||
|
Task SendJobCompleted(string jobId, bool success, string summary);
|
||||||
|
Task SendInstanceStatusChanged(string customerId, string status);
|
||||||
|
Task SendAlertRaised(string severity, string message);
|
||||||
|
}
|
||||||
106
OTSSignsOrchestrator.Server/Jobs/ByoiCertExpiryJob.cs
Normal file
106
OTSSignsOrchestrator.Server/Jobs/ByoiCertExpiryJob.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Quartz;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
using OTSSignsOrchestrator.Server.Hubs;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quartz job that runs daily to check BYOI certificate expiry dates across all enabled
|
||||||
|
/// ByoiConfig entries. Alerts at 60, 30, and 7 day thresholds via FleetHub and logs to AuditLog.
|
||||||
|
///
|
||||||
|
/// Severity escalation:
|
||||||
|
/// - > 7 days remaining → "Warning"
|
||||||
|
/// - ≤ 7 days remaining → "Critical"
|
||||||
|
/// </summary>
|
||||||
|
// IMMUTABLE AuditLog — this job only appends, never updates or deletes audit records.
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class ByoiCertExpiryJob : IJob
|
||||||
|
{
|
||||||
|
/// <summary>Alert thresholds in days. Alerts fire when remaining days ≤ threshold.</summary>
|
||||||
|
internal static readonly int[] AlertThresholdDays = [60, 30, 7];
|
||||||
|
|
||||||
|
/// <summary>Days at or below which severity escalates to "Critical".</summary>
|
||||||
|
internal const int CriticalThresholdDays = 7;
|
||||||
|
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<ByoiCertExpiryJob> _logger;
|
||||||
|
|
||||||
|
public ByoiCertExpiryJob(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<ByoiCertExpiryJob> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||||||
|
|
||||||
|
var configs = await db.ByoiConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(b => b.Instance)
|
||||||
|
.ThenInclude(i => i.Customer)
|
||||||
|
.Where(b => b.Enabled)
|
||||||
|
.ToListAsync(context.CancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
var daysRemaining = (config.CertExpiry - DateTime.UtcNow).TotalDays;
|
||||||
|
var abbrev = config.Instance.Customer.Abbreviation;
|
||||||
|
|
||||||
|
if (!ShouldAlert(daysRemaining))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var severity = GetSeverity(daysRemaining);
|
||||||
|
var daysInt = (int)Math.Floor(daysRemaining);
|
||||||
|
var message = daysRemaining <= 0
|
||||||
|
? $"BYOI cert for {abbrev} has EXPIRED."
|
||||||
|
: $"BYOI cert for {abbrev} expires in {daysInt} days.";
|
||||||
|
|
||||||
|
_logger.LogWarning("BYOI cert expiry alert: {Severity} — {Message}", severity, message);
|
||||||
|
|
||||||
|
await hub.Clients.All.SendAlertRaised(severity, message);
|
||||||
|
|
||||||
|
// Append-only audit log
|
||||||
|
db.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = config.InstanceId,
|
||||||
|
Actor = "ByoiCertExpiryJob",
|
||||||
|
Action = "CertExpiryAlert",
|
||||||
|
Target = config.Slug,
|
||||||
|
Outcome = severity,
|
||||||
|
Detail = message,
|
||||||
|
OccurredAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an alert should fire based on remaining days.
|
||||||
|
/// Alerts at ≤ 60, ≤ 30, ≤ 7 days (or already expired).
|
||||||
|
/// </summary>
|
||||||
|
internal static bool ShouldAlert(double daysRemaining)
|
||||||
|
{
|
||||||
|
foreach (var threshold in AlertThresholdDays)
|
||||||
|
{
|
||||||
|
if (daysRemaining <= threshold)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns "Critical" when ≤ 7 days remain, otherwise "Warning".
|
||||||
|
/// </summary>
|
||||||
|
internal static string GetSeverity(double daysRemaining) =>
|
||||||
|
daysRemaining <= CriticalThresholdDays ? "Critical" : "Warning";
|
||||||
|
}
|
||||||
118
OTSSignsOrchestrator.Server/Jobs/DailySnapshotJob.cs
Normal file
118
OTSSignsOrchestrator.Server/Jobs/DailySnapshotJob.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Quartz;
|
||||||
|
using OTSSignsOrchestrator.Server.Clients;
|
||||||
|
using OTSSignsOrchestrator.Server.Data;
|
||||||
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Server.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quartz job scheduled at 2 AM UTC daily (<c>0 0 2 * * ?</c>).
|
||||||
|
/// For each active <see cref="Instance"/>: calls <see cref="XiboApiClientExtensions.GetAllPagesAsync{T}"/>
|
||||||
|
/// with <c>authorised=1</c> to count authorised displays; inserts a <see cref="ScreenSnapshot"/> row.
|
||||||
|
///
|
||||||
|
/// Uses ON CONFLICT DO NOTHING semantics to protect against double-runs.
|
||||||
|
/// THIS DATA CANNOT BE RECOVERED — if the job misses a day, that data is permanently lost.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class DailySnapshotJob : IJob
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<DailySnapshotJob> _logger;
|
||||||
|
|
||||||
|
public DailySnapshotJob(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<DailySnapshotJob> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||||
|
var clientFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<Core.Services.SettingsService>();
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
|
var instances = await db.Instances
|
||||||
|
.Include(i => i.Customer)
|
||||||
|
.Include(i => i.OauthAppRegistries)
|
||||||
|
.Where(i => i.Customer.Status == CustomerStatus.Active)
|
||||||
|
.ToListAsync(context.CancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("DailySnapshotJob: processing {Count} active instance(s) for {Date}",
|
||||||
|
instances.Count, today);
|
||||||
|
|
||||||
|
foreach (var instance in instances)
|
||||||
|
{
|
||||||
|
var abbrev = instance.Customer.Abbreviation;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var oauthApp = instance.OauthAppRegistries.FirstOrDefault();
|
||||||
|
if (oauthApp is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"DailySnapshotJob: skipping {Abbrev} — no OAuth app registered", abbrev);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var secret = await settings.GetAsync(
|
||||||
|
Core.Services.SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (string.IsNullOrEmpty(secret))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"DailySnapshotJob: skipping {Abbrev} — OAuth secret not found", abbrev);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = await clientFactory.CreateAsync(
|
||||||
|
instance.XiboUrl, oauthApp.ClientId, secret);
|
||||||
|
|
||||||
|
var displays = await client.GetAllPagesAsync(
|
||||||
|
(start, length) => client.GetDisplaysAsync(start, length, authorised: 1));
|
||||||
|
|
||||||
|
var screenCount = displays.Count;
|
||||||
|
|
||||||
|
// ON CONFLICT DO NOTHING — protect against double-runs.
|
||||||
|
// Check if a snapshot already exists for this instance + date.
|
||||||
|
var exists = await db.ScreenSnapshots.AnyAsync(
|
||||||
|
s => s.InstanceId == instance.Id && s.SnapshotDate == today,
|
||||||
|
context.CancellationToken);
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
db.ScreenSnapshots.Add(new ScreenSnapshot
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InstanceId = instance.Id,
|
||||||
|
SnapshotDate = today,
|
||||||
|
ScreenCount = screenCount,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"DailySnapshotJob: {Abbrev} — {Count} authorised display(s)",
|
||||||
|
abbrev, screenCount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"DailySnapshotJob: {Abbrev} — snapshot already exists for {Date}, skipping",
|
||||||
|
abbrev, today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// THIS DATA CANNOT BE RECOVERED — log prominently
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"DailySnapshotJob: FAILED to capture snapshot for {Abbrev} on {Date}. " +
|
||||||
|
"This data is permanently lost.", abbrev, today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user