From 58f605609d4dae1990e29e1000cc75a3b45cf731 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 13 Aug 2026 08:28:05 -0400 Subject: [PATCH] feat(infra): redundant GLAuth pair for FallbackServers failover drills Two local glauth containers (zb-ldap-primary :3893 / zb-ldap-backup :3894) simulating the NAM two-DC topology, so the primary can be killed during a drill without touching the shared 10.100.0.35 instance. Drill recipe + verified-PASS record (HistorianGateway, 2026-08-13) in the README. --- infra/glauth-redundant/README.md | 51 +++++++ infra/glauth-redundant/config.toml | 169 ++++++++++++++++++++++ infra/glauth-redundant/docker-compose.yml | 36 +++++ 3 files changed, 256 insertions(+) create mode 100644 infra/glauth-redundant/README.md create mode 100644 infra/glauth-redundant/config.toml create mode 100644 infra/glauth-redundant/docker-compose.yml diff --git a/infra/glauth-redundant/README.md b/infra/glauth-redundant/README.md new file mode 100644 index 0000000..3d92f40 --- /dev/null +++ b/infra/glauth-redundant/README.md @@ -0,0 +1,51 @@ +# Redundant LDAP test pair (primary + backup "DC") + +Two GLAuth containers on the **dev Mac** that simulate the NAM two-DC topology +(`uswap-vdc-nam1`/`nam2`) for exercising `ZB.MOM.WW.Auth.Ldap` **0.2.x +`FallbackServers` sticky failover**. Deliberately local — the shared +`zb-shared-glauth` on `10.100.0.35:3893` serves every other dev stack and must +never be stopped for a drill. + +| | container | host port | from a container | +|---|---|---|---| +| primary | `zb-ldap-primary` | `3893` | `host.docker.internal:3893` | +| backup | `zb-ldap-backup` | `3894` | `host.docker.internal:3894` | + +Both mount the same `config.toml` — identical directory contents, like a real +replicated pair. It is a copy of [`../glauth/config.toml`](../glauth/config.toml) +with one difference: the serviceaccount password here is the well-known local +value `serviceaccount123` (the shared instance's is rotated out-of-band, SEC-36). +Same users otherwise (`multi-role`/`password`, etc.). + +```bash +docker compose up -d # start the pair +``` + +## Consumer wiring (any of the four apps) + +``` +Ldap__Server: host.docker.internal # primary +Ldap__Port: "3893" +Ldap__FallbackServers__0: host.docker.internal:3894 +Ldap__Transport: None +Ldap__AllowInsecure: "true" +``` + +First wired consumer: `HistorianGateway/docker/docker-compose.yml` (Auth pin +`0.2.1`, dashboard login enabled). Note its Production-env container needs +`Serilog__WriteTo__0__Name: Console` or it logs **nothing** — the drill's +failover lines would be invisible. + +## Failover drill (verified PASS 2026-08-13 against HistorianGateway) + +1. login → served by primary, no warnings +2. `docker stop zb-ldap-primary` → login OK; app logs + `WRN LDAP endpoint …:3893 failed system-side at connect` + + `INF LDAP sticky preference moved to …:3894` +3. `docker start zb-ldap-primary` → login OK, **still served by the backup** + (sticky by design — verified by bind counts: primary got zero new binds) +4. `docker stop zb-ldap-backup` → login OK; warn on `:3894`, sticky moves back + to `:3893` + +The sticky preference lives in the app's singleton `LdapAuthService`, so an app +restart resets it to the configured primary. diff --git a/infra/glauth-redundant/config.toml b/infra/glauth-redundant/config.toml new file mode 100644 index 0000000..2a06d61 --- /dev/null +++ b/infra/glauth-redundant/config.toml @@ -0,0 +1,169 @@ +[ldap] + enabled = true + listen = "0.0.0.0:3893" + +[ldaps] + enabled = false + +[backend] + datastore = "config" + baseDN = "dc=zb,dc=local" + +[behaviors] + # Dev: do not lock out on failed binds (avoids surprises during testing). + LimitFailedBinds = false + +# ── Groups ─────────────────────────────────────────────────────────── +# ScadaBridge role groups (55xx) — DB-mapped (LdapGroupMappings) +[[groups]] + name = "SCADA-Admins" + gidnumber = 5501 +[[groups]] + name = "SCADA-Designers" + gidnumber = 5502 +[[groups]] + name = "SCADA-Deploy-All" + gidnumber = 5503 +[[groups]] + name = "SCADA-Deploy-SiteA" + gidnumber = 5504 +[[groups]] + name = "SCADA-Viewers" + gidnumber = 5505 + +# OPC-UA permission groups (560x) — OtOpcUa + MxGateway OPC write model +[[groups]] + name = "ReadOnly" + gidnumber = 5601 +[[groups]] + name = "WriteOperate" + gidnumber = 5602 +[[groups]] + name = "WriteTune" + gidnumber = 5603 +[[groups]] + name = "WriteConfigure" + gidnumber = 5604 +[[groups]] + name = "AlarmAck" + gidnumber = 5605 + +# MxGateway dashboard groups (561x) — config-mapped (GroupToRole) +[[groups]] + name = "GwAdmin" + gidnumber = 5610 +[[groups]] + name = "GwReader" + gidnumber = 5611 + +# OtOpcUa AdminUI role groups (57xx) — DB-mapped (LdapGroupRoleMapping) +[[groups]] + name = "OtOpcUa-Admins" + gidnumber = 5701 +[[groups]] + name = "OtOpcUa-Designers" + gidnumber = 5702 +[[groups]] + name = "OtOpcUa-Viewers" + gidnumber = 5703 + +# ── Users ──────────────────────────────────────────────────────────── +# All passwords are "password". The serviceaccount password is rotated +# out-of-band (SEC-36) and is not recorded here. +# sha256("password") = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 + +# The single bind account every app uses (search-then-bind). +[[users]] + name = "serviceaccount" + uidnumber = 5999 + primarygroup = 5601 + passsha256 = "af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419" + [[users.capabilities]] + action = "search" + object = "*" + +# Cross-app: member of EVERY group → all roles in all three apps. +[[users]] + name = "multi-role" + givenname = "Multi" + sn = "Role" + mail = "multi-role@zb.local" + uidnumber = 5005 + primarygroup = 5501 + othergroups = [5502, 5503, 5504, 5505, 5601, 5602, 5603, 5604, 5605, 5610, 5611, 5701, 5702, 5703] + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +# Administrator everywhere (admin-equivalent of each app). +[[users]] + name = "admin" + uidnumber = 5001 + primarygroup = 5501 + othergroups = [5610, 5701] + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +# ScadaBridge single-role testers +[[users]] + name = "designer" + uidnumber = 5002 + primarygroup = 5502 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "deployer" + uidnumber = 5003 + primarygroup = 5503 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "site-deployer" + uidnumber = 5004 + primarygroup = 5504 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +# MxGateway dashboard Viewer tester. +# NOTE: tester usernames must NOT case-collide with a group name — glauth exposes +# each group as cn= under ou=users, so a case-insensitive (cn=X) search +# would match both the user and the group (ambiguous → auth fails). Hence gw-viewer +# (not "gwreader" which collides with the GwReader group), opc-* below, etc. +[[users]] + name = "gw-viewer" + uidnumber = 5106 + primarygroup = 5611 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +# OPC-UA permission testers +[[users]] + name = "opc-readonly" + uidnumber = 5101 + primarygroup = 5601 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "opc-writeop" + uidnumber = 5102 + primarygroup = 5602 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "opc-writetune" + uidnumber = 5103 + primarygroup = 5603 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "opc-writeconfig" + uidnumber = 5104 + primarygroup = 5604 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "opc-alarmack" + uidnumber = 5105 + primarygroup = 5605 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +# OtOpcUa single-role testers (admin covers OtOpcUa-Admins) +[[users]] + name = "otdesigner" + uidnumber = 5202 + primarygroup = 5702 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +[[users]] + name = "otviewer" + uidnumber = 5203 + primarygroup = 5703 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" diff --git a/infra/glauth-redundant/docker-compose.yml b/infra/glauth-redundant/docker-compose.yml new file mode 100644 index 0000000..eac4998 --- /dev/null +++ b/infra/glauth-redundant/docker-compose.yml @@ -0,0 +1,36 @@ +# Redundant LDAP test pair — two GLAuth instances simulating a primary + backup DC, +# for exercising ZB.MOM.WW.Auth.Ldap 0.2.x `FallbackServers` sticky failover. +# +# Runs LOCALLY on the dev Mac (not on 10.100.0.35) precisely so the "primary DC" +# can be stopped/started during a failover drill without touching the shared +# zb-shared-glauth that every other dev stack authenticates against. +# +# Both instances mount the SAME config.toml — identical directory contents, like a +# real replicated DC pair. The config is a copy of ../glauth/config.toml with ONE +# difference: the serviceaccount password here is the well-known local-dev value +# `serviceaccount123` (the shared instance's is rotated out-of-band, SEC-36). +# +# primary : ldap://localhost:3893 (containers reach it at host.docker.internal:3893) +# backup : ldap://localhost:3894 (containers reach it at host.docker.internal:3894) +# +# Drill: docker stop zb-ldap-primary → logins fail over to the backup (sticky) +# docker start zb-ldap-primary → preference STAYS on the backup by design +# docker stop zb-ldap-backup → next login walks back to the primary +name: zb-ldap-redundant +services: + ldap-primary: + image: glauth/glauth:latest + container_name: zb-ldap-primary + restart: unless-stopped + ports: + - "3893:3893" + volumes: + - ./config.toml:/app/config/config.cfg:ro + ldap-backup: + image: glauth/glauth:latest + container_name: zb-ldap-backup + restart: unless-stopped + ports: + - "3894:3893" + volumes: + - ./config.toml:/app/config/config.cfg:ro