Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f02071c9a2 | |||
| 993e012e55 | |||
| 961e09430a | |||
| a1a7646b33 | |||
| e4d0d82f7f | |||
| 2915755a7c |
+62
-12
@@ -1,20 +1,63 @@
|
|||||||
# docker-dev
|
# docker-dev
|
||||||
|
|
||||||
Mac-friendly four-node OtOpcUa fleet for manual UI exercise + integration smoke tests. Spins up an Akka cluster + SQL Server + OpenLDAP + Traefik in front of two admin nodes.
|
Mac-friendly multi-cluster OtOpcUa fleet for manual UI exercise + integration smoke tests. Spins up **three isolated Akka clusters** + SQL Server + OpenLDAP + Traefik on the same Compose network. All three clusters share the single `OtOpcUa` ConfigDb — multi-tenancy is enforced by per-row `ServerCluster.ClusterId` scoping. Akka.Cluster gossip stays isolated between meshes because their seed-node lists are disjoint, even though they share the same system name `otopcua`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
### Shared infrastructure
|
||||||
|
|
||||||
| Service | Role | Ports |
|
| Service | Role | Ports |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sql` | SQL Server 2022 (`ConfigDb` backing store) | host `14330` → container `1433` |
|
| `sql` | SQL Server 2022 — single `OtOpcUa` ConfigDb shared by all three clusters | host `14330` → container `1433` |
|
||||||
| `ldap` | OpenLDAP with dev users `alice` / `bob` | host `3893` → container `1389` |
|
| `ldap` | OpenLDAP with dev users `alice` / `bob` | host `3893` → container `1389` |
|
||||||
| `admin-a` | OtOpcUa.Host, `OTOPCUA_ROLES=admin`, cluster seed | internal `9000` |
|
| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8080` |
|
||||||
| `admin-b` | OtOpcUa.Host, `OTOPCUA_ROLES=admin`, joins admin-a | internal `9000` |
|
|
||||||
| `driver-a` | OtOpcUa.Host, `OTOPCUA_ROLES=driver` | host `4840` → container `4840` |
|
|
||||||
| `driver-b` | OtOpcUa.Host, `OTOPCUA_ROLES=driver` | host `4841` → container `4840` |
|
|
||||||
| `traefik` | Routes `:80` to whichever admin-* currently passes `/health/active` | host `80`, dashboard `8080` |
|
|
||||||
|
|
||||||
All six containers share an Akka cluster bound to port `4053` inside the Compose network. The Akka `PublicHostname` of each container matches its Compose service name; the seed-node list points at `admin-a` so the other three join via that.
|
### Main cluster — split admin/driver roles
|
||||||
|
|
||||||
|
| Service | Role | Ports |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin-a` | `OTOPCUA_ROLES=admin`, cluster seed | internal `9000` |
|
||||||
|
| `admin-b` | `OTOPCUA_ROLES=admin`, joins admin-a | internal `9000` |
|
||||||
|
| `driver-a` | `OTOPCUA_ROLES=driver` | host `4840` → container `4840` |
|
||||||
|
| `driver-b` | `OTOPCUA_ROLES=driver` | host `4841` → container `4840` |
|
||||||
|
|
||||||
|
### Site A cluster — 2-node fused admin+driver
|
||||||
|
|
||||||
|
| Service | Role | Ports |
|
||||||
|
|---|---|---|
|
||||||
|
| `site-a-1` | `OTOPCUA_ROLES=admin,driver`, cluster seed | host `4842` → container `4840` |
|
||||||
|
| `site-a-2` | `OTOPCUA_ROLES=admin,driver`, joins site-a-1 | host `4843` → container `4840` |
|
||||||
|
|
||||||
|
### Site B cluster — 2-node fused admin+driver
|
||||||
|
|
||||||
|
| Service | Role | Ports |
|
||||||
|
|---|---|---|
|
||||||
|
| `site-b-1` | `OTOPCUA_ROLES=admin,driver`, cluster seed | host `4844` → container `4840` |
|
||||||
|
| `site-b-2` | `OTOPCUA_ROLES=admin,driver`, joins site-b-1 | host `4845` → container `4840` |
|
||||||
|
|
||||||
|
All containers bind Akka remoting to port `4053` inside their own network namespace; the `PublicHostname` of each matches its Compose service name. Akka mesh isolation is enforced purely by disjoint seed lists. Configuration-side isolation is enforced by `ServerCluster.ClusterId` — see "Multi-tenancy" below.
|
||||||
|
|
||||||
|
## Multi-tenancy
|
||||||
|
|
||||||
|
All eight host nodes write to the same `OtOpcUa` ConfigDb. The `ServerCluster` table differentiates the three Akka meshes: each Akka cluster maps to one row, and each `ClusterNode` row's `ClusterId` ties the runtime node back to its owning cluster scope.
|
||||||
|
|
||||||
|
A one-shot `cluster-seed` Compose service (image `mcr.microsoft.com/mssql-tools`) waits for SQL + the EF auto-migration to complete and then INSERTs the rows below. The seed is **idempotent** — `IF NOT EXISTS` guards every insert — so re-runs on `docker compose up` are no-ops:
|
||||||
|
|
||||||
|
| Akka mesh | `ServerCluster.ClusterId` | `ClusterNode.NodeId` rows |
|
||||||
|
|---|---|---|
|
||||||
|
| Main | `MAIN` | `driver-a`, `driver-b` (OPC UA publishers) |
|
||||||
|
| Site A | `SITE-A` | `site-a-1`, `site-a-2` |
|
||||||
|
| Site B | `SITE-B` | `site-b-1`, `site-b-2` |
|
||||||
|
|
||||||
|
`ClusterNode` is the table for **OPC UA-publishing nodes** (not every Akka cluster member), which is why the main cluster's `admin-a` / `admin-b` don't get rows — they're control-plane-only.
|
||||||
|
|
||||||
|
Each `ClusterNode.NodeId` matches the node's `Cluster__PublicHostname` env value (Compose service name) — that's the lookup the runtime uses to resolve its own membership. `ApplicationUri` follows the `urn:OtOpcUa:<NodeId>` convention.
|
||||||
|
|
||||||
|
The SQL lives at `seed/seed-clusters.sql`; the wait-and-apply wrapper lives at `seed/entrypoint.sh`. To re-seed manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-dev/docker-compose.yml run --rm cluster-seed
|
||||||
|
```
|
||||||
|
|
||||||
## Bring up
|
## Bring up
|
||||||
|
|
||||||
@@ -22,12 +65,16 @@ All six containers share an Akka cluster bound to port `4053` inside the Compose
|
|||||||
# from the repo root
|
# from the repo root
|
||||||
docker compose -f docker-dev/docker-compose.yml up -d --build
|
docker compose -f docker-dev/docker-compose.yml up -d --build
|
||||||
|
|
||||||
# wait ~15 seconds for SQL to come up + the cluster to form
|
# wait ~20 seconds for SQL to come up + all three clusters to form
|
||||||
|
|
||||||
open http://localhost # Blazor admin UI via Traefik
|
open http://localhost # main cluster admin UI
|
||||||
open http://localhost:8080 # Traefik dashboard
|
open http://site-a.localhost # site A admin UI
|
||||||
|
open http://site-b.localhost # site B admin UI
|
||||||
|
open http://localhost:8080 # Traefik dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On macOS, `*.localhost` resolves to `127.0.0.1` automatically. On Linux add `127.0.0.1 site-a.localhost site-b.localhost` to `/etc/hosts` if your resolver doesn't.
|
||||||
|
|
||||||
The first build takes a few minutes (.NET SDK image + restore + publish). Subsequent rebuilds are faster with Docker's layer cache.
|
The first build takes a few minutes (.NET SDK image + restore + publish). Subsequent rebuilds are faster with Docker's layer cache.
|
||||||
|
|
||||||
## Auth (dev only)
|
## Auth (dev only)
|
||||||
@@ -58,5 +105,8 @@ The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across r
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This compose is for the **local Mac/Linux developer rig**. The team's CI + soak runs go to the remote docker host at `10.100.0.35` (see `docs/v2/dev-environment.md`); the file there mirrors this one with adjusted port bindings.
|
- This compose is for the **local Mac/Linux developer rig**. The team's CI + soak runs go to the remote docker host at `10.100.0.35` (see `docs/v2/dev-environment.md`); the file there mirrors this one with adjusted port bindings.
|
||||||
- The OPC UA driver endpoints (`opc.tcp://localhost:4840`, `opc.tcp://localhost:4841`) are reachable directly from the host — Traefik is only in front of the admin HTTP surface.
|
- The OPC UA driver endpoints are reachable directly from the host (Traefik is only in front of the admin HTTP surface):
|
||||||
|
- Main: `opc.tcp://localhost:4840` (driver-a), `opc.tcp://localhost:4841` (driver-b)
|
||||||
|
- Site A: `opc.tcp://localhost:4842` (site-a-1), `opc.tcp://localhost:4843` (site-a-2)
|
||||||
|
- Site B: `opc.tcp://localhost:4844` (site-b-1), `opc.tcp://localhost:4845` (site-b-2)
|
||||||
- Galaxy + Wonderware drivers can't run in Linux containers (they need the Windows-only mxaccessgw + Historian SDK). On non-Windows, `DriverInstanceActor.ShouldStub(driverType, roles)` returns `true` for those types and the actor goes straight to a `Stubbed` state that returns deterministic success.
|
- Galaxy + Wonderware drivers can't run in Linux containers (they need the Windows-only mxaccessgw + Historian SDK). On non-Windows, `DriverInstanceActor.ShouldStub(driverType, roles)` returns `true` for those types and the actor goes straight to a `Stubbed` state that returns deterministic success.
|
||||||
|
|||||||
+149
-11
@@ -1,18 +1,41 @@
|
|||||||
# docker-dev/ — Mac-friendly four-node fleet for v2 development + manual UI exercise.
|
# docker-dev/ — Mac-friendly multi-cluster fleet for v2 development + manual UI exercise.
|
||||||
#
|
#
|
||||||
# Stack:
|
# Stack (3 separate Akka clusters — all share the single `OtOpcUa` ConfigDb):
|
||||||
# sql SQL Server 2022 (ConfigDb backing store)
|
# sql SQL Server 2022 — hosts the one ConfigDb that all three clusters use
|
||||||
# ldap OpenLDAP with the dev users from C:\publish\glauth\auth.md mirrored in
|
# ldap OpenLDAP with the dev users from C:\publish\glauth\auth.md mirrored in
|
||||||
# admin-a OtOpcUa.Host with OTOPCUA_ROLES=admin (cluster seed)
|
#
|
||||||
# admin-b OtOpcUa.Host with OTOPCUA_ROLES=admin (joins admin-a)
|
# Main cluster (existing — split-role admin / driver pair on a single Akka mesh):
|
||||||
# driver-a OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
# admin-a OtOpcUa.Host with OTOPCUA_ROLES=admin (seed)
|
||||||
# driver-b OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
# admin-b OtOpcUa.Host with OTOPCUA_ROLES=admin (joins admin-a)
|
||||||
# traefik Routes :80 to whichever admin-* currently passes /health/active
|
# driver-a OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
||||||
|
# driver-b OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
||||||
|
#
|
||||||
|
# Site A cluster (2-node fused admin+driver):
|
||||||
|
# site-a-1, site-a-2 OTOPCUA_ROLES=admin,driver, seed = site-a-1
|
||||||
|
#
|
||||||
|
# Site B cluster (2-node fused admin+driver):
|
||||||
|
# site-b-1, site-b-2 OTOPCUA_ROLES=admin,driver, seed = site-b-1
|
||||||
|
#
|
||||||
|
# traefik PathPrefix → main cluster admin-a/admin-b; Host(`site-a.localhost`) →
|
||||||
|
# site-a-*; Host(`site-b.localhost`) → site-b-*. Add the two site hosts to
|
||||||
|
# your /etc/hosts (or rely on macOS `.localhost` auto-resolution).
|
||||||
|
#
|
||||||
|
# Multi-tenancy: ConfigDb is one schema with a `ServerCluster` table; each Akka cluster
|
||||||
|
# corresponds to a row in it (ClusterId = "MAIN" / "SITE-A" / "SITE-B"), and each node's
|
||||||
|
# `ClusterNode.NodeId` points back at the row that owns it. After first boot, sign in to
|
||||||
|
# any cluster's Admin UI and create the matching ServerCluster + ClusterNode rows via
|
||||||
|
# /clusters and /hosts so the runtime knows what configuration scope applies.
|
||||||
|
#
|
||||||
|
# Akka mesh isolation: same system name "otopcua" + same remoting port 4053 inside each
|
||||||
|
# container's own network namespace, but with disjoint seed-node lists — gossip never
|
||||||
|
# crosses between the three meshes.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# docker compose -f docker-dev/docker-compose.yml up -d --build
|
# docker compose -f docker-dev/docker-compose.yml up -d --build
|
||||||
# open http://localhost # Blazor admin UI via Traefik
|
# open http://localhost # main cluster Blazor admin UI
|
||||||
# open http://localhost:8080 # Traefik dashboard
|
# open http://site-a.localhost # site A admin UI
|
||||||
|
# open http://site-b.localhost # site B admin UI
|
||||||
|
# open http://localhost:8080 # Traefik dashboard
|
||||||
#
|
#
|
||||||
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
|
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
|
||||||
|
|
||||||
@@ -34,6 +57,20 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
# ── Cluster seed (one-shot) ────────────────────────────────────────────────
|
||||||
|
# Waits for SQL + the host containers' EF auto-migration, then INSERTs the
|
||||||
|
# three ServerCluster rows and the six ClusterNode rows that scope each Akka
|
||||||
|
# mesh inside the shared OtOpcUa ConfigDb. Idempotent — re-runs are no-ops.
|
||||||
|
cluster-seed:
|
||||||
|
image: mcr.microsoft.com/mssql-tools:latest
|
||||||
|
depends_on:
|
||||||
|
sql:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./seed:/seed:ro
|
||||||
|
entrypoint: ["/bin/bash", "/seed/entrypoint.sh"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
image: bitnami/openldap:2.6
|
image: bitnami/openldap:2.6
|
||||||
environment:
|
environment:
|
||||||
@@ -113,6 +150,103 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4841:4840"
|
- "4841:4840"
|
||||||
|
|
||||||
|
# ── Site A cluster (2-node fused admin+driver) ──────────────────────────────
|
||||||
|
# Shares the OtOpcUa ConfigDb with the main + site-b clusters; multi-tenancy is
|
||||||
|
# enforced by ServerCluster.ClusterId rows (configure via /clusters after boot).
|
||||||
|
# Akka isolation comes from the disjoint seed list (seed = site-a-1).
|
||||||
|
|
||||||
|
site-a-1:
|
||||||
|
<<: *otopcua-host
|
||||||
|
environment:
|
||||||
|
OTOPCUA_ROLES: "admin,driver"
|
||||||
|
ASPNETCORE_URLS: "http://+:9000"
|
||||||
|
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||||
|
Cluster__Hostname: "0.0.0.0"
|
||||||
|
Cluster__Port: "4053"
|
||||||
|
Cluster__PublicHostname: "site-a-1"
|
||||||
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
|
||||||
|
Cluster__Roles__0: "admin"
|
||||||
|
Cluster__Roles__1: "driver"
|
||||||
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
|
Authentication__Ldap__Server: "ldap"
|
||||||
|
Authentication__Ldap__Port: "1389"
|
||||||
|
Authentication__Ldap__AllowInsecureLdap: "true"
|
||||||
|
ports:
|
||||||
|
- "4842:4840"
|
||||||
|
|
||||||
|
site-a-2:
|
||||||
|
<<: *otopcua-host
|
||||||
|
depends_on:
|
||||||
|
sql: { condition: service_healthy }
|
||||||
|
site-a-1: { condition: service_started }
|
||||||
|
environment:
|
||||||
|
OTOPCUA_ROLES: "admin,driver"
|
||||||
|
ASPNETCORE_URLS: "http://+:9000"
|
||||||
|
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||||
|
Cluster__Hostname: "0.0.0.0"
|
||||||
|
Cluster__Port: "4053"
|
||||||
|
Cluster__PublicHostname: "site-a-2"
|
||||||
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
|
||||||
|
Cluster__Roles__0: "admin"
|
||||||
|
Cluster__Roles__1: "driver"
|
||||||
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
|
Authentication__Ldap__Server: "ldap"
|
||||||
|
Authentication__Ldap__Port: "1389"
|
||||||
|
Authentication__Ldap__AllowInsecureLdap: "true"
|
||||||
|
ports:
|
||||||
|
- "4843:4840"
|
||||||
|
|
||||||
|
# ── Site B cluster (2-node fused admin+driver) ──────────────────────────────
|
||||||
|
|
||||||
|
site-b-1:
|
||||||
|
<<: *otopcua-host
|
||||||
|
environment:
|
||||||
|
OTOPCUA_ROLES: "admin,driver"
|
||||||
|
ASPNETCORE_URLS: "http://+:9000"
|
||||||
|
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||||
|
Cluster__Hostname: "0.0.0.0"
|
||||||
|
Cluster__Port: "4053"
|
||||||
|
Cluster__PublicHostname: "site-b-1"
|
||||||
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-b-1:4053"
|
||||||
|
Cluster__Roles__0: "admin"
|
||||||
|
Cluster__Roles__1: "driver"
|
||||||
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
|
Authentication__Ldap__Server: "ldap"
|
||||||
|
Authentication__Ldap__Port: "1389"
|
||||||
|
Authentication__Ldap__AllowInsecureLdap: "true"
|
||||||
|
ports:
|
||||||
|
- "4844:4840"
|
||||||
|
|
||||||
|
site-b-2:
|
||||||
|
<<: *otopcua-host
|
||||||
|
depends_on:
|
||||||
|
sql: { condition: service_healthy }
|
||||||
|
site-b-1: { condition: service_started }
|
||||||
|
environment:
|
||||||
|
OTOPCUA_ROLES: "admin,driver"
|
||||||
|
ASPNETCORE_URLS: "http://+:9000"
|
||||||
|
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||||
|
Cluster__Hostname: "0.0.0.0"
|
||||||
|
Cluster__Port: "4053"
|
||||||
|
Cluster__PublicHostname: "site-b-2"
|
||||||
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-b-1:4053"
|
||||||
|
Cluster__Roles__0: "admin"
|
||||||
|
Cluster__Roles__1: "driver"
|
||||||
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
|
Authentication__Ldap__Server: "ldap"
|
||||||
|
Authentication__Ldap__Port: "1389"
|
||||||
|
Authentication__Ldap__AllowInsecureLdap: "true"
|
||||||
|
ports:
|
||||||
|
- "4845:4840"
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.1
|
||||||
command:
|
command:
|
||||||
@@ -128,3 +262,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- admin-a
|
- admin-a
|
||||||
- admin-b
|
- admin-b
|
||||||
|
- site-a-1
|
||||||
|
- site-a-2
|
||||||
|
- site-b-1
|
||||||
|
- site-b-2
|
||||||
|
|||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker-dev cluster-seed entrypoint. Waits for the host containers to finish
|
||||||
|
# their EF Core auto-migration (which creates the ServerCluster table), then
|
||||||
|
# applies the idempotent seed script.
|
||||||
|
#
|
||||||
|
# Image: mcr.microsoft.com/mssql-tools (Debian + sqlcmd at /opt/mssql-tools18/bin).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SQLCMD="/opt/mssql-tools18/bin/sqlcmd"
|
||||||
|
SERVER="${SQL_HOST:-sql},1433"
|
||||||
|
USER="${SQL_USER:-sa}"
|
||||||
|
PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}"
|
||||||
|
DB="${SQL_DATABASE:-OtOpcUa}"
|
||||||
|
|
||||||
|
run_sql() {
|
||||||
|
"$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$DB" -No -b -h -1 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[cluster-seed] waiting for SQL Server to accept connections..."
|
||||||
|
until run_sql -Q "SELECT 1" >/dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "[cluster-seed] SQL Server up."
|
||||||
|
|
||||||
|
echo "[cluster-seed] waiting for $DB.ServerCluster (host containers must finish EF migration)..."
|
||||||
|
until run_sql -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "[cluster-seed] schema ready."
|
||||||
|
|
||||||
|
echo "[cluster-seed] applying seed-clusters.sql..."
|
||||||
|
run_sql -i /seed/seed-clusters.sql
|
||||||
|
|
||||||
|
echo "[cluster-seed] done."
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- docker-dev cluster seed. Idempotent — safe to re-run on every `docker compose up`.
|
||||||
|
--
|
||||||
|
-- Populates:
|
||||||
|
-- ServerCluster MAIN, SITE-A, SITE-B
|
||||||
|
-- ClusterNode driver-a, driver-b → MAIN
|
||||||
|
-- site-a-1, site-a-2 → SITE-A
|
||||||
|
-- site-b-1, site-b-2 → SITE-B
|
||||||
|
--
|
||||||
|
-- ServerCluster.NodeCount + RedundancyMode are coupled by CHECK constraint:
|
||||||
|
-- NodeCount=1 ⇒ RedundancyMode='None'
|
||||||
|
-- NodeCount=2 ⇒ RedundancyMode∈('Warm','Hot')
|
||||||
|
--
|
||||||
|
-- Each ClusterNode.ApplicationUri MUST be globally unique (UX_ClusterNode_ApplicationUri).
|
||||||
|
-- Convention: urn:OtOpcUa:<NodeId>.
|
||||||
|
--
|
||||||
|
-- Host = Compose service name (resolves inside the otopcua-dev network).
|
||||||
|
-- OpcUaPort stays at the container-internal 4840; the host-side port mapping is in
|
||||||
|
-- docker-compose.yml ports: blocks and is irrelevant to ClusterNode rows.
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- ServerCluster
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'MAIN')
|
||||||
|
INSERT INTO dbo.ServerCluster
|
||||||
|
(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy)
|
||||||
|
VALUES
|
||||||
|
('MAIN', 'Main cluster', 'zb', 'docker-dev',
|
||||||
|
2, 'Warm', 1,
|
||||||
|
'docker-dev seed — admin-a/admin-b control-plane, driver-a/driver-b OPC UA publishers.',
|
||||||
|
'docker-dev-seed');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-A')
|
||||||
|
INSERT INTO dbo.ServerCluster
|
||||||
|
(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy)
|
||||||
|
VALUES
|
||||||
|
('SITE-A', 'Site A', 'zb', 'site-a',
|
||||||
|
2, 'Warm', 1,
|
||||||
|
'docker-dev seed — 2-node fused admin+driver cluster.',
|
||||||
|
'docker-dev-seed');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-B')
|
||||||
|
INSERT INTO dbo.ServerCluster
|
||||||
|
(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy)
|
||||||
|
VALUES
|
||||||
|
('SITE-B', 'Site B', 'zb', 'site-b',
|
||||||
|
2, 'Warm', 1,
|
||||||
|
'docker-dev seed — 2-node fused admin+driver cluster.',
|
||||||
|
'docker-dev-seed');
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- ClusterNode — main cluster OPC UA publishers
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a')
|
||||||
|
INSERT INTO dbo.ClusterNode
|
||||||
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES ('driver-a', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b')
|
||||||
|
INSERT INTO dbo.ClusterNode
|
||||||
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES ('driver-b', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- ClusterNode — site A
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1')
|
||||||
|
INSERT INTO dbo.ClusterNode
|
||||||
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES ('site-a-1', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2')
|
||||||
|
INSERT INTO dbo.ClusterNode
|
||||||
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES ('site-a-2', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- ClusterNode — site B
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1')
|
||||||
|
INSERT INTO dbo.ClusterNode
|
||||||
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES ('site-b-1', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2')
|
||||||
|
INSERT INTO dbo.ClusterNode
|
||||||
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES ('site-b-2', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- Summary (logged by sqlcmd output)
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
|
||||||
|
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
|
||||||
|
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
# docker-dev companion to scripts/install/traefik-dynamic.yml. Same routing rules,
|
# docker-dev companion to scripts/install/traefik-dynamic.yml. Routes three
|
||||||
# but the upstream targets are the Compose service names (admin-a, admin-b) on
|
# Akka clusters that share the Compose network:
|
||||||
# port 9000 instead of the Windows hostnames a bare-metal deployment would use.
|
#
|
||||||
|
# - Main cluster (default): PathPrefix(`/`) → admin-a / admin-b.
|
||||||
|
# - Site A cluster: Host(`site-a.localhost`) → site-a-1 / site-a-2.
|
||||||
|
# - Site B cluster: Host(`site-b.localhost`) → site-b-1 / site-b-2.
|
||||||
|
#
|
||||||
|
# Host-header rules are more specific than PathPrefix, so they win over the
|
||||||
|
# default router for the site hostnames automatically — no priority field needed.
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
@@ -9,6 +15,16 @@ http:
|
|||||||
rule: "PathPrefix(`/`)"
|
rule: "PathPrefix(`/`)"
|
||||||
service: otopcua-admin
|
service: otopcua-admin
|
||||||
|
|
||||||
|
otopcua-site-a:
|
||||||
|
entryPoints: ["web"]
|
||||||
|
rule: "Host(`site-a.localhost`)"
|
||||||
|
service: otopcua-site-a
|
||||||
|
|
||||||
|
otopcua-site-b:
|
||||||
|
entryPoints: ["web"]
|
||||||
|
rule: "Host(`site-b.localhost`)"
|
||||||
|
service: otopcua-site-b
|
||||||
|
|
||||||
services:
|
services:
|
||||||
otopcua-admin:
|
otopcua-admin:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
@@ -19,3 +35,23 @@ http:
|
|||||||
path: /health/active
|
path: /health/active
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
|
|
||||||
|
otopcua-site-a:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://site-a-1:9000"
|
||||||
|
- url: "http://site-a-2:9000"
|
||||||
|
healthCheck:
|
||||||
|
path: /health/active
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
otopcua-site-b:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://site-b-1:9000"
|
||||||
|
- url: "http://site-b-2:9000"
|
||||||
|
healthCheck:
|
||||||
|
path: /health/active
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<Routes/>
|
<Routes/>
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/nav-state.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@* Minimal layout for the login page: no side rail, no brand block. The page
|
||||||
|
renders its own centred card. Mirrors ScadaLink CentralUI's LoginLayout. *@
|
||||||
|
@Body
|
||||||
@@ -1,24 +1,9 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<header class="app-bar">
|
@* Layout chrome ported from ScadaLink CentralUI: no separate top bar — brand sits
|
||||||
<span class="brand"><span class="mark">▮</span> OtOpcUa</span>
|
at the top of the side rail. The sidebar itself is the interactive island
|
||||||
<span class="crumb">›</span>
|
(<NavSidebar/>); MainLayout stays statically rendered so the Body RenderFragment
|
||||||
<span class="crumb">admin console</span>
|
doesn't have to cross an interactive boundary. *@
|
||||||
<span class="spacer"></span>
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized>
|
|
||||||
<span class="meta">@context.User.Identity?.Name</span>
|
|
||||||
<span class="conn-pill" data-state="connected">
|
|
||||||
<span class="dot"></span><span>signed in</span>
|
|
||||||
</span>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<span class="conn-pill" data-state="disconnected">
|
|
||||||
<span class="dot"></span><span>signed out</span>
|
|
||||||
</span>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="app-shell d-flex flex-column flex-lg-row">
|
<div class="app-shell d-flex flex-column flex-lg-row">
|
||||||
@* Hamburger toggle: visible only on viewports <lg.
|
@* Hamburger toggle: visible only on viewports <lg.
|
||||||
@@ -34,47 +19,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||||
<nav class="side-rail">
|
<NavSidebar />
|
||||||
<div class="rail-eyebrow">Navigation</div>
|
|
||||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
|
||||||
<div class="rail-eyebrow">Scripting</div>
|
|
||||||
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
|
||||||
|
|
||||||
<div class="rail-eyebrow">Live</div>
|
|
||||||
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
|
||||||
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
|
||||||
|
|
||||||
<div class="rail-foot">
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized>
|
|
||||||
<div class="rail-eyebrow">Session</div>
|
|
||||||
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
|
||||||
<div class="rail-roles">
|
|
||||||
@string.Join(", ", context.User.Claims
|
|
||||||
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/auth/logout">
|
|
||||||
<AntiforgeryToken />
|
|
||||||
<button class="rail-btn" type="submit">Sign out</button>
|
|
||||||
</form>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<div class="rail-eyebrow">Session</div>
|
|
||||||
<a class="rail-btn" href="/login">Sign in</a>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@* A collapsible sidebar nav section: an uppercase-eyebrow button that toggles
|
||||||
|
the visibility of its child nav items. Mirrors the ScadaLink NavSection at
|
||||||
|
/Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
|
||||||
|
but uses OtOpcUa's rail-eyebrow + rail-link classes. *@
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="rail-eyebrow-toggle"
|
||||||
|
@onclick="OnToggle"
|
||||||
|
aria-expanded="@(Expanded ? "true" : "false")">
|
||||||
|
<span class="rail-eyebrow-chevron">@(Expanded ? "▼" : "▶")</span>
|
||||||
|
<span class="rail-eyebrow-label">@Title</span>
|
||||||
|
</button>
|
||||||
|
@if (Expanded)
|
||||||
|
{
|
||||||
|
<div class="rail-section-body">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Section label shown in the eyebrow (e.g. "Scripting").</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether the section is expanded — its child links rendered.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool Expanded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the eyebrow button is clicked.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnToggle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The section's child nav links, rendered only while expanded.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public RenderFragment? ChildContent { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
@rendermode InteractiveServer
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@implements IDisposable
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@* Interactive sidebar — extracted from MainLayout so the layout itself can stay
|
||||||
|
statically rendered (layouts can't take RenderFragment Body across an interactive
|
||||||
|
boundary). Hosts the collapsible NavSection groups and cookie persistence. *@
|
||||||
|
|
||||||
|
<nav class="side-rail">
|
||||||
|
<div class="brand"><span class="mark">▮</span> OtOpcUa</div>
|
||||||
|
|
||||||
|
<NavSection Title="Navigation"
|
||||||
|
Expanded="@_expanded.Contains("nav")"
|
||||||
|
OnToggle="@(() => ToggleAsync("nav"))">
|
||||||
|
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
|
<NavSection Title="Scripting"
|
||||||
|
Expanded="@_expanded.Contains("scripting")"
|
||||||
|
OnToggle="@(() => ToggleAsync("scripting"))">
|
||||||
|
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
|
<NavSection Title="Live"
|
||||||
|
Expanded="@_expanded.Contains("live")"
|
||||||
|
OnToggle="@(() => ToggleAsync("live"))">
|
||||||
|
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
||||||
|
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
|
<div class="rail-foot">
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||||
|
<div class="rail-roles">
|
||||||
|
@string.Join(", ", context.User.Claims
|
||||||
|
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
<button class="rail-btn" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<a class="rail-btn" href="/login">Sign in</a>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// Expanded-section state persists in the `otopcua_nav` cookie via
|
||||||
|
// wwwroot/js/nav-state.js (window.navState.get/.set). Same pattern as
|
||||||
|
// ScadaLink CentralUI's NavMenu.
|
||||||
|
|
||||||
|
private static readonly string[] SectionIds = { "nav", "scripting", "live" };
|
||||||
|
|
||||||
|
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
// Seed from the URL so the current page's section is expanded on the
|
||||||
|
// initial render — works even before JS interop is ready.
|
||||||
|
EnsureCurrentSectionExpanded();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
string saved;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException) { return; }
|
||||||
|
catch (InvalidOperationException) { return; }
|
||||||
|
|
||||||
|
foreach (var id in saved.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||||
|
_expanded.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EnsureCurrentSectionExpanded())
|
||||||
|
await PersistAsync();
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (EnsureCurrentSectionExpanded())
|
||||||
|
{
|
||||||
|
_ = PersistAsync();
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleAsync(string id)
|
||||||
|
{
|
||||||
|
if (!_expanded.Remove(id))
|
||||||
|
_expanded.Add(id);
|
||||||
|
await PersistAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureCurrentSectionExpanded()
|
||||||
|
{
|
||||||
|
var section = CurrentSection();
|
||||||
|
return section is not null && _expanded.Add(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? CurrentSection()
|
||||||
|
{
|
||||||
|
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
var firstSegment = relative.Split('?', '#')[0]
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return firstSegment switch
|
||||||
|
{
|
||||||
|
null or "" => "nav",
|
||||||
|
"fleet" or "hosts" or "clusters" or "reservations" or "certificates" or "role-grants" => "nav",
|
||||||
|
"virtual-tags" or "scripted-alarms" or "scripts" or "script-log" => "scripting",
|
||||||
|
"deployments" or "alerts" or "alarms-historian" => "live",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException) { }
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@page "/alerts"
|
@page "/alerts"
|
||||||
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
|
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
|
||||||
AlarmTransitionEvent entries. Engine wiring (ScriptedAlarmActor publish on the `alerts`
|
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
|
||||||
topic) lands with F9; until then the connection stays open and the table is empty. *@
|
and the AB CIP ALMD bridge. *@
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
@using Microsoft.AspNetCore.SignalR.Client
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
<section class="panel notice rise" style="animation-delay:.02s">
|
||||||
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
|
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
|
||||||
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
|
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
|
||||||
ScriptedAlarmActor, native AB CIP ALMD bridge (F9), Galaxy alarm bridge (future).
|
ScriptedAlarmActor, native driver alarm bridges (AB CIP ALMD, Galaxy where wired).
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (_rows.Count == 0)
|
@if (_rows.Count == 0)
|
||||||
{
|
{
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.08s">
|
<section class="panel notice rise mt-3" style="animation-delay:.08s">
|
||||||
No alarms yet. Engine wiring (F9 ScriptedAlarmActor) is pending; once it ships the table
|
No alarms in the current window. The table will populate as soon as a
|
||||||
below will start populating in real time.
|
ScriptedAlarmActor or driver alarm bridge publishes a transition.
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
@page "/login"
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy
|
@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy
|
||||||
would lock operators out of the only way in (Admin-001). Static-rendered on purpose:
|
would lock operators out of the only way in (Admin-001). Static-rendered on purpose:
|
||||||
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
||||||
Calling SignInAsync from an interactive circuit would be too late. *@
|
Calling SignInAsync from an interactive circuit would be too late.
|
||||||
|
|
||||||
|
Uses LoginLayout (no side rail) so the page renders as a clean centred card. *@
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
|
|
||||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||||
@@ -32,12 +35,6 @@
|
|||||||
|
|
||||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
|
|
||||||
font-size:.78rem;color:var(--ink-faint)">
|
|
||||||
LDAP bind against the configured directory (per Q5 of the AdminUI rebuild plan:
|
|
||||||
generic error in production; specific reason when <span class="mono">Authentication:Ldap:AllowInsecureLdap=true</span>).
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
||||||
where TApp : IComponent
|
where TApp : IComponent
|
||||||
{
|
{
|
||||||
|
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||||
|
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||||
|
// UseAuthentication() — see Program.cs.
|
||||||
app.MapRazorComponents<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
|
||||||
|
|||||||
@@ -49,6 +49,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's
|
||||||
|
.sidebar .brand styling — used now that the top app-bar was dropped. */
|
||||||
|
.side-rail .brand {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.side-rail .brand .mark { color: var(--accent); }
|
||||||
|
|
||||||
.rail-eyebrow {
|
.rail-eyebrow {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -58,6 +71,36 @@
|
|||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible variant — rendered by NavSection.razor. Looks like .rail-eyebrow
|
||||||
|
plus a leading chevron; clicking flips chevron + expanded state. */
|
||||||
|
.rail-eyebrow-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 0.45rem 0.6rem 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rail-eyebrow-toggle:hover { color: var(--ink); }
|
||||||
|
.rail-eyebrow-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.7rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.rail-section-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-link {
|
.rail-link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Sidebar nav collapse state — persisted in the `otopcua_nav` cookie so it
|
||||||
|
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
|
||||||
|
// JS interop (window.navState.get / .set). Mirrors the ScadaLink pattern at
|
||||||
|
// /Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js.
|
||||||
|
window.navState = {
|
||||||
|
// Returns the raw cookie value (comma-separated expanded section ids), or
|
||||||
|
// an empty string when the cookie is absent.
|
||||||
|
get: function () {
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)otopcua_nav=([^;]*)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
},
|
||||||
|
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
|
||||||
|
// (JS must write it) and not sensitive.
|
||||||
|
set: function (value) {
|
||||||
|
const oneYearSeconds = 60 * 60 * 24 * 365;
|
||||||
|
document.cookie = "otopcua_nav=" + encodeURIComponent(value) +
|
||||||
|
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,6 +28,11 @@ var hasDriver = roles.Contains("driver");
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Razor class library static assets (_content/<libname>/...) only auto-enable in
|
||||||
|
// the Development environment. Opt in explicitly so the AdminUI's CSS/JS works
|
||||||
|
// regardless of ASPNETCORE_ENVIRONMENT.
|
||||||
|
builder.WebHost.UseStaticWebAssets();
|
||||||
|
|
||||||
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
||||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
||||||
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
||||||
@@ -111,6 +116,9 @@ if (hasAdmin)
|
|||||||
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
||||||
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
||||||
builder.Services.AddAdminUI();
|
builder.Services.AddAdminUI();
|
||||||
|
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
||||||
|
// inside interactive components (NavSidebar's session block).
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddOtOpcUaAdminClients();
|
builder.Services.AddOtOpcUaAdminClients();
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,12 @@ builder.Services.AddOtOpcUaObservability();
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
|
// Razor class library static assets (_content/<libname>/...) are served via endpoint
|
||||||
|
// routing, NOT the UseStaticFiles middleware — so we MUST mark the static-asset
|
||||||
|
// endpoints AllowAnonymous, otherwise the AddOtOpcUaAuth fallback RequireAuthenticatedUser
|
||||||
|
// policy 401s every CSS/JS request and the login page renders unstyled.
|
||||||
|
app.MapStaticAssets().AllowAnonymous();
|
||||||
|
|
||||||
if (hasAdmin)
|
if (hasAdmin)
|
||||||
{
|
{
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -12,13 +13,20 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
|||||||
|
|
||||||
public static class AuthEndpoints
|
public static class AuthEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public sealed record TokenResponse(string Token);
|
public sealed record TokenResponse(string Token);
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous();
|
// The login endpoint serves two callers with different ergonomics:
|
||||||
|
// - Browser form POST (application/x-www-form-urlencoded) → redirect dance
|
||||||
|
// - API JSON POST (application/json) → 204 / 401 / 503 status codes
|
||||||
|
// DisableAntiforgery: the login form is the entry point — anonymous by definition,
|
||||||
|
// no prior session, so XSRF doesn't apply. AllowAnonymous: override the
|
||||||
|
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
|
||||||
|
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
|
||||||
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
||||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||||
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
||||||
@@ -26,15 +34,35 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
LoginRequest request,
|
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var isForm = http.Request.HasFormContentType;
|
||||||
|
string username, password, returnUrl;
|
||||||
|
|
||||||
|
if (isForm)
|
||||||
|
{
|
||||||
|
var form = await http.Request.ReadFormAsync(ct);
|
||||||
|
username = form["username"].ToString();
|
||||||
|
password = form["password"].ToString();
|
||||||
|
returnUrl = form["returnUrl"].ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<LoginRequest>(
|
||||||
|
http.Request.Body,
|
||||||
|
new JsonSerializerOptions(JsonSerializerDefaults.Web),
|
||||||
|
ct);
|
||||||
|
username = body?.Username ?? string.Empty;
|
||||||
|
password = body?.Password ?? string.Empty;
|
||||||
|
returnUrl = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
LdapAuthResult result;
|
LdapAuthResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await ldap.AuthenticateAsync(request.Username, request.Password, ct);
|
result = await ldap.AuthenticateAsync(username, password, ct);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@@ -42,13 +70,20 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return Results.Unauthorized();
|
{
|
||||||
|
if (!isForm) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(returnUrl))
|
||||||
|
qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
return Results.Redirect("/login" + qs);
|
||||||
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, result.Username ?? request.Username),
|
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||||
new(JwtTokenService.UsernameClaimType, result.Username ?? request.Username),
|
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? request.Username),
|
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||||
};
|
};
|
||||||
foreach (var role in result.Roles)
|
foreach (var role in result.Roles)
|
||||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
@@ -57,7 +92,9 @@ public static class AuthEndpoints
|
|||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
return Results.NoContent();
|
|
||||||
|
if (!isForm) return Results.NoContent();
|
||||||
|
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult Ping(HttpContext http) =>
|
private static IResult Ping(HttpContext http) =>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
|
|||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
return new(false, null, null, [], [], "Password is required");
|
return new(false, null, null, [], [], "Password is required");
|
||||||
|
|
||||||
|
if (_options.DevStubMode)
|
||||||
|
{
|
||||||
|
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||||
|
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||||
return new(false, null, username, [], [],
|
return new(false, null, username, [], [],
|
||||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||||
public bool AllowInsecureLdap { get; set; }
|
public bool AllowInsecureLdap { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
|
||||||
|
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||||
|
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||||
|
/// </summary>
|
||||||
|
public bool DevStubMode { get; set; }
|
||||||
|
|
||||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<JwtTokenService>();
|
services.AddSingleton<JwtTokenService>();
|
||||||
services.AddScoped<ILdapAuthService, LdapAuthService>();
|
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||||
|
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||||
|
// The driver-branch in Host/Program.cs registers the same way; consistent lifetime
|
||||||
|
// across both paths keeps ValidateScopes-on-Build clean.
|
||||||
|
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
services.AddDataProtection()
|
services.AddDataProtection()
|
||||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
|||||||
Reference in New Issue
Block a user