594 lines
34 KiB
Bash
Executable File
594 lines
34 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# Live gate for SITE SECRETS RIDING THE ARTIFACT DEPLOY (design
|
|
# docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md, plan task 13). Drives the real
|
|
# central paths end to end on the running docker rig:
|
|
#
|
|
# - secrets are created / rotated / deleted at central through the Central UI's own
|
|
# /admin/secrets page (ZB.MOM.WW.Secrets.Ui over the central pair's shared SQL store) — the
|
|
# supported operator path; there is no CLI/management verb for central secrets
|
|
# - data connections and artifact deploys go through the ScadaBridge CLI
|
|
# (CLI -> Traefik -> active central -> ArtifactDeploymentService)
|
|
# - the Artifacts badge is read from the Sites page after pressing Re-check (the pending-updates
|
|
# evaluation has no CLI/management surface; it is Blazor-interactive, so a headless browser is
|
|
# the only honest reader)
|
|
# - each site node's own secret store (the library's SQLite `secret` table in
|
|
# /app/data/scadabridge-secrets.db) and LocalDb (`deployed_secrets`, `mirrored_secrets`,
|
|
# `data_connection_definitions` in /app/data/site-localdb.db) are copied out with `docker cp`
|
|
# (the image carries no sqlite3) and read with the host's python3 sqlite3 module
|
|
# - the central row is read (never written) from ZbSecretsHub.zbsecrets.secret with sqlcmd
|
|
#
|
|
# Ciphertext is compared by SHA-256 only; secret VALUES are random throwaways held in shell
|
|
# variables and handed to the browser through the environment — never printed, never on argv.
|
|
#
|
|
# Checks (each prints PASS/FAIL; the run ends "PASS n/8" and exits non-zero on any FAIL):
|
|
# 1 secret + secret-referencing MxGateway connection on site-a -> site-a Out of date, b/c Current
|
|
# 2 deploy site-a -> success, secretsDeployed 1, site-a Current
|
|
# 3 both site-a nodes hold the secret at the central revision + ciphertext (standby <= 30 s)
|
|
# 4 rotate at central -> site-a Out of date -> deploy -> both nodes at the new revision
|
|
# 5 drop the secret: reference -> deploy -> both nodes no longer hold it; an out-of-band secret
|
|
# seeded on site-a-a through the library's own write path (the ZB.MOM.WW.Secrets `secret` CLI
|
|
# run against that node's store) survives
|
|
# 6 reference a non-existent secret -> site-a deploy fails closed with the named message,
|
|
# site-a stays Out of date, nothing changed on either site-a node
|
|
# 7 the same reference on site-b (deliberately mismatched dev KEK) -> deploy fails naming the KekId
|
|
# 8 one DeploySiteSecrets audit row per secret-bearing deploy; no plaintext / base64 ciphertext
|
|
# in any of the 8 nodes' logs
|
|
#
|
|
# Teardown (trap EXIT, also run up-front so a rerun starts clean): gate connections deleted,
|
|
# site-a + site-b redeployed so the removal reaches them, the central gate secret deleted, the
|
|
# out-of-band secret removed from site-a-a, then a fleet-wide artifact deploy so the rig ends with
|
|
# every site Current. The secret library tombstones rather than hard-deletes, so deleted gate
|
|
# names remain as is_deleted=1 rows in the stores — that is the library's contract, not residue.
|
|
#
|
|
# Prereqs: rig up (8 nodes ready), `python3 -m playwright` with chromium, the ScadaBridge CLI
|
|
# built, and scadaproj checked out next to this repo (the `secret` CLI is published from it once
|
|
# into $SECRETS_CLI_DIR).
|
|
#
|
|
# Usage: bash docker/verify-site-secrets.sh
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
CLI="${SCADABRIDGE_CLI:-$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge}"
|
|
URL="${SCADABRIDGE_URL:-http://localhost:9000}"
|
|
A=(--url "$URL" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
|
|
SECRETS_CLI_PROJECT="${SECRETS_CLI_PROJECT:-$HOME/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli}"
|
|
SECRETS_CLI_DIR="${SECRETS_CLI_DIR:-${TMPDIR:-/tmp}/sb-gate-secrets-cli}"
|
|
STANDBY_WINDOW_S="${STANDBY_WINDOW_S:-30}"
|
|
|
|
# DEV-ONLY rig KEK shared by central + site-a (docker/docker-compose.yml, x-secrets-shared-kek-env).
|
|
SITE_A_KEK="zZiBWuoaVMbJmGXToLk9Lakw0iJozXoL/7Gxac3GwJ4="
|
|
MSSQL_CONTAINER="${MSSQL_CONTAINER:-scadabridge-mssql}"
|
|
MSSQL_USER="scadabridge_app"; MSSQL_PASS='ScadaBridge_Dev1#'
|
|
|
|
SECRET="sb-gate-mxgw"
|
|
MISSING="sb-gate-missing"
|
|
OOB="sb-gate-oob"
|
|
CONN_A="sb-gate-conn-a"
|
|
CONN_B="sb-gate-conn-b"
|
|
ACTIVE_A="scadabridge-site-a-a"; STANDBY_A="scadabridge-site-a-b"
|
|
NODES=(central-a central-b site-a-a site-a-b site-b-a site-b-b site-c-a site-c-b)
|
|
|
|
VALUE_1=$(python3 -c 'import secrets; print("gate1-" + secrets.token_hex(16))')
|
|
VALUE_2=$(python3 -c 'import secrets; print("gate2-" + secrets.token_hex(16))')
|
|
VALUE_OOB=$(python3 -c 'import secrets; print("gateoob-" + secrets.token_hex(16))')
|
|
|
|
CHECKS_PASSED=0; CHECKS_FAILED=0
|
|
CHECK_OK=1
|
|
begin_check() { CHECK_OK=1; echo ""; echo "=== Check $1: $2 ==="; }
|
|
ok() { echo " ok $*"; }
|
|
bad() { CHECK_OK=0; echo " FAIL $*"; }
|
|
end_check() {
|
|
if [ "$CHECK_OK" -eq 1 ]; then CHECKS_PASSED=$((CHECKS_PASSED+1)); echo " PASS check $1"
|
|
else CHECKS_FAILED=$((CHECKS_FAILED+1)); echo " FAIL check $1"; fi
|
|
}
|
|
|
|
[ -x "$CLI" ] || { echo "CLI not found at $CLI — build it: dotnet build src/ZB.MOM.WW.ScadaBridge.CLI"; exit 1; }
|
|
python3 -c 'import playwright.sync_api' 2>/dev/null || { echo "python3 playwright missing (pip install playwright && playwright install chromium)"; exit 1; }
|
|
|
|
WORK=$(mktemp -d "${TMPDIR:-/tmp}/sb-gate-site-secrets.XXXXXX")
|
|
HELPER="$WORK/gate.py"
|
|
|
|
# ── python helper: browser (UI) + read-only store readers ──────────────────────────────────────
|
|
cat >"$HELPER" <<'PY'
|
|
import hashlib, json, os, sqlite3, subprocess, sys, tempfile, time, shutil
|
|
|
|
def sha(b):
|
|
return hashlib.sha256(b if isinstance(b, (bytes, bytearray)) else b.encode()).hexdigest()
|
|
|
|
# ---- Central UI -------------------------------------------------------------------------------
|
|
def _browser(fn):
|
|
from playwright.sync_api import sync_playwright
|
|
with sync_playwright() as p:
|
|
b = p.chromium.launch()
|
|
try:
|
|
pg = b.new_page()
|
|
return fn(pg)
|
|
finally:
|
|
b.close()
|
|
|
|
def _wait_circuit(pg):
|
|
# Blazor Server: handlers only work once the circuit is up.
|
|
pg.wait_for_function("() => window.Blazor !== undefined", timeout=30000)
|
|
time.sleep(2)
|
|
|
|
def badges(url):
|
|
def run(pg):
|
|
pg.goto(url + "/admin/sites")
|
|
pg.wait_for_selector("button[aria-label='Re-check pending updates']", timeout=30000)
|
|
_wait_circuit(pg)
|
|
pg.click("button[aria-label='Re-check pending updates']")
|
|
time.sleep(1)
|
|
pg.wait_for_function("() => { const b=document.querySelector(\"button[aria-label='Re-check pending updates']\"); return b && !b.disabled; }", timeout=120000)
|
|
pg.wait_for_function("() => !document.querySelector('[aria-label=\"Artifacts: checking\"]')", timeout=60000)
|
|
out = {}
|
|
for card in pg.query_selector_all(".card"):
|
|
code = card.query_selector("code"); badge = card.query_selector("[aria-label^='Artifacts:']")
|
|
if code and badge:
|
|
out[code.inner_text().strip()] = badge.get_attribute("aria-label").split(": ", 1)[1]
|
|
return out
|
|
print(json.dumps(_browser(run)))
|
|
|
|
def _row_exists(pg, name):
|
|
return pg.query_selector(f"button.rotate-secret[data-secret-name='{name}']") is not None
|
|
|
|
def _secrets_page(pg, url):
|
|
pg.goto(url + "/admin/secrets")
|
|
pg.wait_for_selector("[data-testid='add-secret']", timeout=30000)
|
|
pg.wait_for_function("() => !document.body.innerText.includes('Loading…')", timeout=30000)
|
|
_wait_circuit(pg)
|
|
|
|
def secret_upsert(url, name, desc):
|
|
value = os.environ["SB_GATE_VALUE"]
|
|
def run(pg):
|
|
_secrets_page(pg, url)
|
|
mode = "rotate" if _row_exists(pg, name) else "add"
|
|
for attempt in range(5):
|
|
if mode == "rotate":
|
|
pg.click(f"button.rotate-secret[data-secret-name='{name}']")
|
|
else:
|
|
pg.click("[data-testid='add-secret']")
|
|
try:
|
|
pg.wait_for_selector("[data-testid='editor-submit']", timeout=3000); break
|
|
except Exception:
|
|
time.sleep(1)
|
|
if mode == "add":
|
|
pg.fill("[data-testid='secret-name']", name); pg.press("[data-testid='secret-name']", "Tab")
|
|
pg.fill("[data-testid='secret-description']", desc); pg.press("[data-testid='secret-description']", "Tab")
|
|
pg.fill("[data-testid='secret-value']", value); pg.press("[data-testid='secret-value']", "Tab")
|
|
pg.click("[data-testid='editor-submit']")
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
err = pg.query_selector("[data-testid='editor-error']")
|
|
if err:
|
|
return "error: " + err.inner_text()
|
|
if pg.query_selector("[data-testid='editor-submit']") is None and _row_exists(pg, name):
|
|
return mode
|
|
time.sleep(0.3)
|
|
return "error: editor did not close"
|
|
print(_browser(run))
|
|
|
|
def secret_delete(url, name):
|
|
def run(pg):
|
|
_secrets_page(pg, url)
|
|
if not _row_exists(pg, name):
|
|
return "absent"
|
|
for attempt in range(5):
|
|
pg.click(f"button.delete-secret[data-secret-name='{name}']")
|
|
try:
|
|
pg.wait_for_selector("[data-testid='confirm-delete']", timeout=3000); break
|
|
except Exception:
|
|
time.sleep(1)
|
|
pg.click("[data-testid='confirm-delete']")
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
err = pg.query_selector("[data-testid='delete-error']")
|
|
if err:
|
|
return "error: " + err.inner_text()
|
|
if not _row_exists(pg, name):
|
|
return "deleted"
|
|
time.sleep(0.3)
|
|
return "error: row still listed"
|
|
print(_browser(run))
|
|
|
|
# ---- read-only store readers ------------------------------------------------------------------
|
|
def _copy_db(container, path):
|
|
"""docker cp the db + its WAL (NOT the -shm: SQLite rebuilds the wal-index from the WAL on
|
|
open, which is safer than trusting a copied live shared-memory file)."""
|
|
d = tempfile.mkdtemp(prefix="sbgate-")
|
|
base = os.path.basename(path)
|
|
for suffix in ("-wal", ""):
|
|
r = subprocess.run(["docker", "cp", f"{container}:{path}{suffix}", os.path.join(d, base + suffix)],
|
|
capture_output=True)
|
|
if r.returncode != 0 and suffix == "":
|
|
raise SystemExit(f"docker cp {container}:{path} failed: {r.stderr.decode().strip()}")
|
|
return d, os.path.join(d, base)
|
|
|
|
def _query(container, path, sql):
|
|
# Two consecutive reads must agree — guards against a checkpoint racing the two docker cp's.
|
|
last = None
|
|
for _ in range(4):
|
|
d, db = _copy_db(container, path)
|
|
try:
|
|
con = sqlite3.connect(db); rows = con.execute(sql).fetchall(); con.close()
|
|
finally:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
if rows == last:
|
|
return rows
|
|
last = rows
|
|
return last
|
|
|
|
def site_secrets(container):
|
|
rows = _query(container, "/app/data/scadabridge-secrets.db",
|
|
"SELECT name, revision, kek_id, is_deleted, ciphertext FROM secret WHERE name LIKE 'sb-gate-%' ORDER BY name")
|
|
print(json.dumps({n: {"rev": r, "kek": k, "deleted": bool(dl), "sha": sha(c)} for n, r, k, dl, c in rows}, sort_keys=True))
|
|
|
|
def site_localdb(container):
|
|
db = "/app/data/site-localdb.db"
|
|
dep = _query(container, db, "SELECT name, revision, is_removed, row_json FROM deployed_secrets WHERE name LIKE 'sb-gate-%' ORDER BY name")
|
|
mir = _query(container, db, "SELECT name, revision FROM mirrored_secrets WHERE name LIKE 'sb-gate-%' ORDER BY name")
|
|
dcs = _query(container, db, "SELECT name, configuration FROM data_connection_definitions WHERE name LIKE 'sb-gate-%' ORDER BY name")
|
|
print(json.dumps({
|
|
"deployed": {n: {"rev": r, "removed": bool(x), "sha": sha(j)} for n, r, x, j in dep},
|
|
"mirrored": {n: r for n, r in mir},
|
|
"connections": {n: sha(c or "") for n, c in dcs},
|
|
}, sort_keys=True))
|
|
|
|
def central_secret(name):
|
|
q = ("SET NOCOUNT ON; SELECT name, revision, kek_id, is_deleted, "
|
|
"LOWER(CONVERT(varchar(64), HASHBYTES('SHA2_256', ciphertext), 2)), "
|
|
"CONVERT(varchar(max), ciphertext, 2) "
|
|
f"FROM zbsecrets.secret WHERE name = N'{name}'")
|
|
r = subprocess.run(["docker", "exec", os.environ["MSSQL_CONTAINER"], "/opt/mssql-tools18/bin/sqlcmd",
|
|
"-S", "localhost", "-U", os.environ["MSSQL_USER"], "-P", os.environ["MSSQL_PASS"],
|
|
"-C", "-d", "ZbSecretsHub", "-h", "-1", "-W", "-s", "|", "-Q", q],
|
|
capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise SystemExit("sqlcmd failed: " + r.stderr.strip())
|
|
lines = [l for l in r.stdout.splitlines() if l.strip()]
|
|
if not lines:
|
|
print(json.dumps(None)); return
|
|
n, rev, kek, dl, h, hexct = lines[0].split("|")
|
|
import base64
|
|
b64 = base64.b64encode(bytes.fromhex(hexct)).decode()
|
|
if sha(bytes.fromhex(hexct)) != h:
|
|
raise SystemExit("ciphertext hex truncated by sqlcmd")
|
|
# b64 is only used by check 8's log grep; it is written to a file by the caller, never printed.
|
|
print(json.dumps({"rev": int(rev), "kek": kek, "deleted": dl == "1", "sha": h, "b64": b64}))
|
|
|
|
cmd, args = sys.argv[1], sys.argv[2:]
|
|
{"badges": lambda: badges(*args),
|
|
"secret-upsert": lambda: secret_upsert(*args),
|
|
"secret-delete": lambda: secret_delete(*args),
|
|
"site-secrets": lambda: site_secrets(*args),
|
|
"site-localdb": lambda: site_localdb(*args),
|
|
"central-secret": lambda: central_secret(*args)}[cmd]()
|
|
PY
|
|
export MSSQL_CONTAINER MSSQL_USER MSSQL_PASS
|
|
gp() { python3 "$HELPER" "$@"; }
|
|
jget() { python3 -c 'import json,sys
|
|
d=json.loads(sys.argv[1])
|
|
for k in sys.argv[2].split("."):
|
|
if d is None: break
|
|
d = d.get(k) if isinstance(d, dict) else None
|
|
print("" if d is None else (json.dumps(d) if isinstance(d,(dict,list,bool)) else d))' "$1" "$2"; }
|
|
|
|
UNWRAP='
|
|
import json,sys
|
|
d=json.load(sys.stdin)
|
|
if isinstance(d, dict) and "data" in d and len(d) <= 2:
|
|
d = d["data"]
|
|
'
|
|
|
|
site_id() {
|
|
"$CLI" site list "${A[@]}" | python3 -c "$UNWRAP"'
|
|
import sys
|
|
print(next(str(s["id"]) for s in d if s["siteIdentifier"]==sys.argv[1]))' "$1"
|
|
}
|
|
site_hash() {
|
|
"$CLI" site list "${A[@]}" | python3 -c "$UNWRAP"'
|
|
import sys
|
|
print(next((s.get("artifactRevisionHash") or "-") for s in d if s["siteIdentifier"]==sys.argv[1]))' "$1"
|
|
}
|
|
conn_id() { # conn_id <name> -> id or empty
|
|
"$CLI" data-connection list "${A[@]}" | python3 -c "$UNWRAP"'
|
|
import sys
|
|
print(next((str(c["id"]) for c in d if c["name"]==sys.argv[1]), ""))' "$1"
|
|
}
|
|
mxgw_config() { # mxgw_config <apiKeyValue>
|
|
python3 -c 'import json,sys; print(json.dumps({"Endpoint":"http://sb-gate-no-such-gateway:5000","ApiKey":sys.argv[1],"ClientName":"sb-gate","ReadTimeoutMs":5000}))' "$1"
|
|
}
|
|
upsert_conn() { # upsert_conn <name> <siteId> <apiKeyValue>
|
|
local id; id=$(conn_id "$1")
|
|
if [ -z "$id" ]; then
|
|
"$CLI" data-connection create "${A[@]}" --site-id "$2" --name "$1" --protocol MxGateway \
|
|
--primary-config "$(mxgw_config "$3")" >/dev/null
|
|
else
|
|
"$CLI" data-connection update "${A[@]}" --id "$id" --name "$1" --protocol MxGateway \
|
|
--primary-config "$(mxgw_config "$3")" >/dev/null
|
|
fi
|
|
}
|
|
# deploy <siteId|all> -> sets DEPLOY_OUT (raw CLI output) and DEPLOY_RC; never aborts the script
|
|
DEPLOY_OUT=""; DEPLOY_RC=0
|
|
deploy() {
|
|
DEPLOY_RC=0
|
|
if [ "$1" = "all" ]; then
|
|
DEPLOY_OUT=$("$CLI" site deploy-artifacts "${A[@]}" 2>&1) || DEPLOY_RC=$?
|
|
else
|
|
DEPLOY_OUT=$("$CLI" site deploy-artifacts "${A[@]}" --site-id "$1" 2>&1) || DEPLOY_RC=$?
|
|
fi
|
|
}
|
|
# site_result <siteIdentifier> <field> -> the field from DEPLOY_OUT's siteResults
|
|
site_result() {
|
|
printf '%s' "$DEPLOY_OUT" | python3 -c '
|
|
import json,sys
|
|
raw=sys.stdin.read()
|
|
try:
|
|
d=json.loads(raw[raw.index("{"):])
|
|
except Exception:
|
|
print(""); sys.exit(0)
|
|
r=next((x for x in d.get("siteResults",[]) if x.get("siteId")==sys.argv[1]), None)
|
|
v=None if r is None else r.get(sys.argv[2])
|
|
print("" if v is None else (json.dumps(v) if isinstance(v,bool) else v))' "$1" "$2"
|
|
}
|
|
badge_of() { jget "$1" "$2"; }
|
|
|
|
ensure_secrets_cli() {
|
|
[ -f "$SECRETS_CLI_DIR/ZB.MOM.WW.Secrets.Cli.dll" ] && return 0
|
|
echo " publishing the ZB.MOM.WW.Secrets 'secret' CLI to $SECRETS_CLI_DIR ..."
|
|
dotnet publish "$SECRETS_CLI_PROJECT" -c Release -o "$SECRETS_CLI_DIR" >/dev/null
|
|
}
|
|
# Runs the library's own `secret` CLI against site-a-a's LOCAL store, in a throwaway container that
|
|
# shares that node's data volume and carries that node's KEK — the library's normal SQLite write path.
|
|
site_a_secret_cli() {
|
|
local verb="$1"; shift
|
|
docker run --rm --volumes-from "$ACTIVE_A" -v "$SECRETS_CLI_DIR:/cli:ro" -w /cli \
|
|
-e Secrets__SqlitePath=/data/scadabridge-secrets.db -e "ZB_SECRETS_MASTER_KEY=$SITE_A_KEK" \
|
|
-e SB_GATE_VALUE --entrypoint sh scadabridge:latest \
|
|
-c "exec dotnet /cli/ZB.MOM.WW.Secrets.Cli.dll $verb \"\$@\"" _ "$@"
|
|
}
|
|
# `secret set <name> <value>` takes the value positionally: hand it over through the container's
|
|
# environment (SB_GATE_VALUE) so it never appears on this host's argv; the container's inner sh
|
|
# expands it.
|
|
site_a_secret_set() { # site_a_secret_set <name> <description> (value in $SB_GATE_VALUE)
|
|
docker run --rm --volumes-from "$ACTIVE_A" -v "$SECRETS_CLI_DIR:/cli:ro" -w /cli \
|
|
-e Secrets__SqlitePath=/data/scadabridge-secrets.db -e "ZB_SECRETS_MASTER_KEY=$SITE_A_KEK" \
|
|
-e SB_GATE_VALUE --entrypoint sh scadabridge:latest \
|
|
-c 'exec dotnet /cli/ZB.MOM.WW.Secrets.Cli.dll set "$1" "$SB_GATE_VALUE" --description "$2"' _ "$1" "$2"
|
|
}
|
|
|
|
# ── teardown (idempotent; also the pre-clean) ──────────────────────────────────────────────────
|
|
teardown() {
|
|
local rc=$?
|
|
set +e
|
|
echo ""
|
|
echo "=== teardown ==="
|
|
local id
|
|
for c in "$CONN_A" "$CONN_B"; do
|
|
id=$(conn_id "$c" 2>/dev/null)
|
|
if [ -n "$id" ]; then
|
|
"$CLI" data-connection delete "${A[@]}" --id "$id" >/dev/null 2>&1 \
|
|
&& echo " removed data connection $c (id=$id)" || echo " WARN could not remove data connection $c"
|
|
fi
|
|
done
|
|
# Removal must reach both sites that ever held the gate secret BEFORE central forgets it.
|
|
[ -n "${SITE_A_ID:-}" ] && { deploy "$SITE_A_ID"; echo " site-a redeploy rc=$DEPLOY_RC"; }
|
|
[ -n "${SITE_B_ID:-}" ] && { deploy "$SITE_B_ID"; echo " site-b redeploy rc=$DEPLOY_RC"; }
|
|
echo " central $SECRET: $(gp secret-delete "$URL" "$SECRET" 2>&1 | tail -1)"
|
|
if [ -f "$SECRETS_CLI_DIR/ZB.MOM.WW.Secrets.Cli.dll" ]; then
|
|
local live
|
|
live=$(gp site-secrets "$ACTIVE_A" 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print("1" if d.get(sys.argv[1],{}).get("deleted") is False else "")' "$OOB" 2>/dev/null)
|
|
if [ -n "$live" ]; then
|
|
site_a_secret_cli rm "$OOB" >/dev/null 2>&1 && echo " removed out-of-band $OOB from site-a-a" \
|
|
|| echo " WARN could not remove out-of-band $OOB"
|
|
fi
|
|
fi
|
|
deploy all; echo " fleet-wide artifact redeploy rc=$DEPLOY_RC"
|
|
echo " badges after teardown: $(gp badges "$URL" 2>&1 | tail -1)"
|
|
[ "${1:-}" = "pre" ] || rm -rf "$WORK"
|
|
return $rc
|
|
}
|
|
|
|
echo "=== ScadaBridge site-secrets artifact-deploy live gate ==="
|
|
echo "management url: $URL"
|
|
GATE_START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
SITE_A_ID=$(site_id site-a); SITE_B_ID=$(site_id site-b); SITE_C_ID=$(site_id site-c)
|
|
echo "sites: site-a=$SITE_A_ID site-b=$SITE_B_ID site-c=$SITE_C_ID"
|
|
ensure_secrets_cli
|
|
|
|
echo ""
|
|
echo "=== pre-clean (idempotent rerun) ==="
|
|
teardown pre || true
|
|
trap teardown EXIT
|
|
GATE_START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
# Docker log checkpoint for check 8: only lines written during this run are grepped.
|
|
LOG_SINCE="$GATE_START"
|
|
|
|
BASE=$(gp badges "$URL")
|
|
echo "baseline badges: $BASE"
|
|
for s in site-a site-b site-c; do
|
|
[ "$(badge_of "$BASE" "$s")" = "current" ] || { echo "baseline: $s is not Current — the gate needs a Current rig to start"; exit 1; }
|
|
done
|
|
|
|
# ── 1 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 1 "secret + referencing connection on site-a -> site-a Out of date, site-b/c unchanged"
|
|
R=$(SB_GATE_VALUE="$VALUE_1" gp secret-upsert "$URL" "$SECRET" "site-secrets live gate (throwaway)")
|
|
[ "$R" = "add" ] && ok "created $SECRET at central via /admin/secrets" || bad "create $SECRET: $R"
|
|
C1=$(gp central-secret "$SECRET")
|
|
[ "$(jget "$C1" deleted)" = "false" ] && ok "central row $SECRET r$(jget "$C1" rev) kek $(jget "$C1" kek) sha $(jget "$C1" sha | cut -c1-12)" \
|
|
|| bad "central row for $SECRET not found live"
|
|
H_A_BEFORE=$(site_hash site-a)
|
|
upsert_conn "$CONN_A" "$SITE_A_ID" "secret:$SECRET" && ok "created MxGateway connection $CONN_A on site-a (ApiKey=secret:$SECRET, unbound)" \
|
|
|| bad "create $CONN_A"
|
|
B=$(gp badges "$URL"); echo " badges: $B"
|
|
[ "$(badge_of "$B" site-a)" = "out of date" ] && ok "site-a Out of date" || bad "site-a is '$(badge_of "$B" site-a)', expected out of date"
|
|
[ "$(badge_of "$B" site-b)" = "current" ] && ok "site-b Current (unchanged)" || bad "site-b is '$(badge_of "$B" site-b)'"
|
|
[ "$(badge_of "$B" site-c)" = "current" ] && ok "site-c Current (unchanged)" || bad "site-c is '$(badge_of "$B" site-c)'"
|
|
end_check 1
|
|
|
|
# ── 2 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 2 "deploy artifacts to site-a -> success, secretsDeployed 1, site-a Current"
|
|
T0=$(python3 -c 'import time; print(time.time())')
|
|
deploy "$SITE_A_ID"
|
|
T_DEPLOYED=$(python3 -c 'import time; print(time.time())')
|
|
echo " deploy rc=$DEPLOY_RC ($(python3 -c "print(round($T_DEPLOYED-$T0,1))") s): $(printf '%s' "$DEPLOY_OUT" | tr -d '\n' | cut -c1-240)"
|
|
[ "$DEPLOY_RC" -eq 0 ] && [ "$(site_result site-a success)" = "true" ] && ok "site-a deploy succeeded" || bad "site-a deploy failed (rc=$DEPLOY_RC)"
|
|
[ "$(site_result site-a secretsDeployed)" = "1" ] && ok "secretsDeployed: 1" || bad "secretsDeployed='$(site_result site-a secretsDeployed)', expected 1"
|
|
B=$(gp badges "$URL"); echo " badges: $B"
|
|
[ "$(badge_of "$B" site-a)" = "current" ] && ok "site-a Current" || bad "site-a is '$(badge_of "$B" site-a)', expected current"
|
|
end_check 2
|
|
|
|
# wait_node <container> <python predicate over the site-secrets JSON d> -> sets WAIT_S (or empty on timeout)
|
|
WAIT_S=""
|
|
wait_node() {
|
|
local c="$1" pred="$2" start now j
|
|
start=$(python3 -c 'import time; print(time.time())'); WAIT_S=""
|
|
while :; do
|
|
j=$(gp site-secrets "$c")
|
|
if python3 -c "import json,sys; d=json.loads(sys.argv[1]); sys.exit(0 if ($pred) else 1)" "$j"; then
|
|
now=$(python3 -c 'import time; print(time.time())')
|
|
WAIT_S=$(python3 -c "print(round($now-${3:-$start},1))"); LAST_NODE_JSON="$j"; return 0
|
|
fi
|
|
now=$(python3 -c 'import time; print(time.time())')
|
|
python3 -c "import sys; sys.exit(0 if $now-$start < $STANDBY_WINDOW_S else 1)" || { LAST_NODE_JSON="$j"; return 1; }
|
|
sleep 1
|
|
done
|
|
}
|
|
matches_central() { # python predicate: node row == central row C (rev + sha), live
|
|
echo "d.get('$SECRET') is not None and not d['$SECRET']['deleted'] and d['$SECRET']['rev']==$(jget "$1" rev) and d['$SECRET']['sha']=='$(jget "$1" sha)' and d['$SECRET']['kek']=='$(jget "$1" kek)'"
|
|
}
|
|
|
|
# ── 3 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 3 "both site-a nodes hold $SECRET at the central revision (standby within ${STANDBY_WINDOW_S}s)"
|
|
C=$(gp central-secret "$SECRET")
|
|
PRED=$(matches_central "$C")
|
|
if wait_node "$ACTIVE_A" "$PRED" "$T_DEPLOYED"; then ok "site-a-a (active) r$(jget "$C" rev), ciphertext sha256 $(jget "$C" sha | cut -c1-16)… == central (seen ${WAIT_S}s after the deploy returned)"
|
|
else bad "site-a-a does not match central: $LAST_NODE_JSON"; fi
|
|
if wait_node "$STANDBY_A" "$PRED" "$T_DEPLOYED"; then STANDBY_3="$WAIT_S"; ok "site-a-b (standby) converged ${WAIT_S}s after the deploy returned"
|
|
else STANDBY_3="timeout"; bad "site-a-b did not converge within ${STANDBY_WINDOW_S}s: $LAST_NODE_JSON"; fi
|
|
L=$(gp site-localdb "$STANDBY_A")
|
|
[ "$(jget "$L" "mirrored.$SECRET")" = "$(jget "$C" rev)" ] && ok "site-a-b mirrored_secrets ledger r$(jget "$C" rev)" || bad "site-a-b ledger: $L"
|
|
end_check 3
|
|
|
|
# ── 4 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 4 "rotate at central -> site-a Out of date -> deploy -> both nodes at the new revision"
|
|
R=$(SB_GATE_VALUE="$VALUE_2" gp secret-upsert "$URL" "$SECRET" "site-secrets live gate (throwaway, rotated)")
|
|
[ "$R" = "rotate" ] && ok "rotated $SECRET via /admin/secrets" || bad "rotate: $R"
|
|
C2=$(gp central-secret "$SECRET")
|
|
if [ "$(jget "$C2" rev)" -gt "$(jget "$C1" rev)" ] && [ "$(jget "$C2" sha)" != "$(jget "$C1" sha)" ]; then
|
|
ok "central r$(jget "$C1" rev) -> r$(jget "$C2" rev), ciphertext changed"
|
|
else bad "central row did not advance: before r$(jget "$C1" rev) after r$(jget "$C2" rev)"; fi
|
|
B=$(gp badges "$URL"); echo " badges: $B"
|
|
[ "$(badge_of "$B" site-a)" = "out of date" ] && ok "site-a Out of date after the rotation" || bad "site-a is '$(badge_of "$B" site-a)'"
|
|
T0=$(python3 -c 'import time; print(time.time())'); deploy "$SITE_A_ID"; T_DEPLOYED=$(python3 -c 'import time; print(time.time())')
|
|
[ "$DEPLOY_RC" -eq 0 ] && [ "$(site_result site-a success)" = "true" ] && [ "$(site_result site-a secretsDeployed)" = "1" ] \
|
|
&& ok "redeploy succeeded, secretsDeployed 1" || bad "redeploy: rc=$DEPLOY_RC $(printf '%s' "$DEPLOY_OUT" | tr -d '\n' | cut -c1-240)"
|
|
PRED=$(matches_central "$C2")
|
|
if wait_node "$ACTIVE_A" "$PRED" "$T_DEPLOYED"; then ok "site-a-a at r$(jget "$C2" rev) (${WAIT_S}s)"; else bad "site-a-a: $LAST_NODE_JSON"; fi
|
|
if wait_node "$STANDBY_A" "$PRED" "$T_DEPLOYED"; then STANDBY_4="$WAIT_S"; ok "site-a-b at r$(jget "$C2" rev) (${WAIT_S}s after the deploy returned)"
|
|
else STANDBY_4="timeout"; bad "site-a-b: $LAST_NODE_JSON"; fi
|
|
B=$(gp badges "$URL")
|
|
[ "$(badge_of "$B" site-a)" = "current" ] && ok "site-a Current again" || bad "site-a is '$(badge_of "$B" site-a)'"
|
|
end_check 4
|
|
|
|
# ── 5 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 5 "drop the secret: reference -> deploy -> both nodes drop $SECRET; out-of-band $OOB survives"
|
|
SB_GATE_VALUE="$VALUE_OOB" site_a_secret_set "$OOB" "site-secrets gate out-of-band" >/dev/null 2>&1 || true
|
|
J=$(gp site-secrets "$ACTIVE_A")
|
|
if [ "$(python3 -c 'import json,sys; d=json.loads(sys.argv[1]); print(d.get(sys.argv[2],{}).get("deleted"))' "$J" "$OOB")" = "False" ]; then
|
|
ok "seeded out-of-band $OOB on site-a-a through the library's own write path"
|
|
else bad "out-of-band seed not visible on site-a-a: $J"; fi
|
|
upsert_conn "$CONN_A" "$SITE_A_ID" "gate-literal-not-a-secret-ref" && ok "$CONN_A no longer references a secret" || bad "update $CONN_A"
|
|
T0=$(python3 -c 'import time; print(time.time())'); deploy "$SITE_A_ID"; T_DEPLOYED=$(python3 -c 'import time; print(time.time())')
|
|
[ "$DEPLOY_RC" -eq 0 ] && [ "$(site_result site-a success)" = "true" ] && ok "deploy succeeded (secretsDeployed='$(site_result site-a secretsDeployed)')" \
|
|
|| bad "deploy: rc=$DEPLOY_RC $(printf '%s' "$DEPLOY_OUT" | tr -d '\n' | cut -c1-240)"
|
|
GONE="(d.get('$SECRET') is None or d['$SECRET']['deleted'])"
|
|
if wait_node "$ACTIVE_A" "$GONE" "$T_DEPLOYED"; then ok "site-a-a no longer holds $SECRET (${WAIT_S}s)"; else bad "site-a-a still holds $SECRET: $LAST_NODE_JSON"; fi
|
|
if wait_node "$STANDBY_A" "$GONE" "$T_DEPLOYED"; then STANDBY_5="$WAIT_S"; ok "site-a-b no longer holds $SECRET (${WAIT_S}s after the deploy returned)"
|
|
else STANDBY_5="timeout"; bad "site-a-b still holds $SECRET: $LAST_NODE_JSON"; fi
|
|
J=$(gp site-secrets "$ACTIVE_A")
|
|
[ "$(python3 -c 'import json,sys; d=json.loads(sys.argv[1]); print(d.get(sys.argv[2],{}).get("deleted"))' "$J" "$OOB")" = "False" ] \
|
|
&& ok "out-of-band $OOB still live on site-a-a" || bad "out-of-band $OOB lost: $J"
|
|
L=$(gp site-localdb "$ACTIVE_A")
|
|
[ "$(jget "$L" "deployed.$SECRET.removed")" = "true" ] && ok "deployed_secrets.$SECRET is_removed=1 (kept for the standby)" || bad "deployed_secrets: $L"
|
|
end_check 5
|
|
|
|
# ── 6 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 6 "reference non-existent $MISSING -> site-a deploy fails closed, stays Out of date, site untouched"
|
|
SNAP_BEFORE="$(site_hash site-a)|$(gp site-secrets "$ACTIVE_A")|$(gp site-localdb "$ACTIVE_A")|$(gp site-secrets "$STANDBY_A")|$(gp site-localdb "$STANDBY_A")"
|
|
upsert_conn "$CONN_A" "$SITE_A_ID" "secret:$MISSING" && ok "$CONN_A now references secret:$MISSING" || bad "update $CONN_A"
|
|
deploy "$SITE_A_ID"
|
|
ERR=$(site_result site-a errorMessage)
|
|
echo " deploy rc=$DEPLOY_RC error: $ERR"
|
|
EXPECT="Secret '$MISSING' referenced by data connection(s) $CONN_A is not in the central secret store"
|
|
[ "$(site_result site-a success)" = "false" ] && ok "site-a deploy reported failure" || bad "site-a deploy did not fail: $(printf '%s' "$DEPLOY_OUT" | tr -d '\n' | cut -c1-240)"
|
|
case "$ERR" in *"$EXPECT"*) ok "named fail-closed message";; *) bad "message does not contain: $EXPECT";; esac
|
|
B=$(gp badges "$URL"); echo " badges: $B"
|
|
[ "$(badge_of "$B" site-a)" = "out of date" ] && ok "site-a stays Out of date" || bad "site-a is '$(badge_of "$B" site-a)'"
|
|
sleep 3
|
|
SNAP_AFTER="$(site_hash site-a)|$(gp site-secrets "$ACTIVE_A")|$(gp site-localdb "$ACTIVE_A")|$(gp site-secrets "$STANDBY_A")|$(gp site-localdb "$STANDBY_A")"
|
|
[ "$SNAP_BEFORE" = "$SNAP_AFTER" ] && ok "nothing changed: stamped hash, both nodes' secret rows, deployed_secrets, ledgers and the $CONN_A definition identical" \
|
|
|| { bad "site state changed"; echo " before: $SNAP_BEFORE"; echo " after: $SNAP_AFTER"; }
|
|
end_check 6
|
|
|
|
# ── 7 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 7 "the same reference on site-b (mismatched dev KEK) -> deploy fails naming the KekId"
|
|
C=$(gp central-secret "$SECRET")
|
|
KEK=$(jget "$C" kek)
|
|
upsert_conn "$CONN_B" "$SITE_B_ID" "secret:$SECRET" && ok "created $CONN_B on site-b (ApiKey=secret:$SECRET)" || bad "create $CONN_B"
|
|
deploy "$SITE_B_ID"
|
|
ERR=$(site_result site-b errorMessage)
|
|
echo " deploy rc=$DEPLOY_RC error: $ERR"
|
|
[ "$(site_result site-b success)" = "false" ] && ok "site-b deploy reported failure" || bad "site-b deploy did not fail: $(printf '%s' "$DEPLOY_OUT" | tr -d '\n' | cut -c1-240)"
|
|
case "$ERR" in *"$SECRET"*"$KEK"*) ok "message names $SECRET and the row's KekId $KEK";; *) bad "message does not name $SECRET + KekId $KEK";; esac
|
|
B=$(gp badges "$URL"); echo " badges: $B"
|
|
[ "$(badge_of "$B" site-b)" = "out of date" ] && ok "site-b not stamped (Out of date)" || bad "site-b is '$(badge_of "$B" site-b)'"
|
|
end_check 7
|
|
|
|
# ── 8 ────────────────────────────────────────────────────────────────────────────────────────
|
|
begin_check 8 "DeploySiteSecrets audit rows; no plaintext / base64 ciphertext in any node log"
|
|
AUD=$("$CLI" audit-config query "${A[@]}" --action DeploySiteSecrets --entity-type SiteSecrets --from "$GATE_START" --page-size 50 2>/dev/null)
|
|
ROWS=$(printf '%s' "$AUD" | python3 -c '
|
|
import json,sys
|
|
d=json.load(sys.stdin)
|
|
rows=[]
|
|
for e in sorted(d.get("entries",[]), key=lambda e: e["timestamp"]):
|
|
s=json.loads(e.get("afterStateJson") or "{}")
|
|
names=",".join("%s@r%s" % (x.get("name"), x.get("revision")) for x in (s.get("secrets") or []))
|
|
miss=",".join(s.get("missing") or [])
|
|
rows.append("%s|%s|%s|%s|%s" % (e["entityId"], s.get("success"), names or "-", miss or "-", s.get("deploymentId")))
|
|
print("\n".join(rows))')
|
|
echo "$ROWS" | sed 's/^/ audit: /'
|
|
R2=$(jget "$C1" rev); R4=$(jget "$C2" rev)
|
|
EXPECTED="site-a|True|$SECRET@r$R2|-
|
|
site-a|True|$SECRET@r$R4|-
|
|
site-a|False|-|$MISSING
|
|
site-b|False|$SECRET@r$R4|-"
|
|
GOT=$(printf '%s\n' "$ROWS" | cut -d'|' -f1-4)
|
|
[ "$GOT" = "$EXPECTED" ] && ok "exactly 4 rows, one per secret-bearing deploy (2, 4, 6-fail-closed, 7-kek-mismatch); the no-secret deploy of check 5 wrote none" \
|
|
|| { bad "audit rows differ from expected"; echo " expected:"; echo "$EXPECTED" | sed 's/^/ /'; }
|
|
[ "$(printf '%s\n' "$ROWS" | cut -d'|' -f5 | sort -u | grep -c .)" = "4" ] && ok "4 distinct deploymentIds" || bad "deploymentIds not distinct"
|
|
# Material to hunt for: both plaintext values and both ciphertexts (base64, as they appear in any
|
|
# JSON serialization). Written to a pattern file so they never reach argv or the output.
|
|
PAT="$WORK/patterns"; : >"$PAT"; chmod 600 "$PAT"
|
|
printf '%s\n%s\n%s\n' "$VALUE_1" "$VALUE_2" "$VALUE_OOB" >>"$PAT"
|
|
jget "$C1" b64 >>"$PAT"; jget "$C2" b64 >>"$PAT"
|
|
[ "$(grep -c . "$PAT")" = "5" ] && [ "$(awk 'length($0) < 20' "$PAT" | grep -c .)" = "0" ] \
|
|
&& ok "5 material patterns (3 plaintext, 2 base64 ciphertext) armed" || bad "pattern file malformed"
|
|
HITS=0
|
|
for n in "${NODES[@]}"; do
|
|
h=$(docker logs --since "$LOG_SINCE" "scadabridge-$n" 2>&1 | grep -c -F -f "$PAT" || true)
|
|
HITS=$((HITS + h)); [ "$h" = "0" ] || echo " scadabridge-$n: $h hit(s)"
|
|
done
|
|
AUDH=$(printf '%s' "$AUD" | grep -c -F -f "$PAT" || true)
|
|
[ "$HITS" = "0" ] && ok "0 hits across all 8 nodes' logs since $LOG_SINCE" || bad "$HITS log line(s) carry secret material"
|
|
[ "$AUDH" = "0" ] && ok "0 hits in the DeploySiteSecrets audit payloads" || bad "audit payload carries secret material"
|
|
end_check 8
|
|
|
|
echo ""
|
|
echo "=== Summary ==="
|
|
echo "standby convergence (site-a-b, seconds after the deploy call returned): check3=${STANDBY_3:-?} check4=${STANDBY_4:-?} check5=${STANDBY_5:-?}"
|
|
echo "PASS $CHECKS_PASSED/8"
|
|
[ "$CHECKS_FAILED" -eq 0 ] || exit 1
|