An operator standing at the SCADA console should not complete a Duo push to ask a question, and nobody outside the plant should reach the assistant at all. The ai.yokogawa.tech Caddy block now admits remote_ip 10.0.0.21 (yau-poc-cicore1, static) and returns 403 to everything else. Applied on lin001 2026-08-28; snapshot at ~/Caddyfile.bak-ai-scadaonly-20260828. This also settles why the console could not reach the assistant at all: auth.yokogawa.tech has no pinpoint record on the DC, so a LAN browser got a correct 302 to the portal and then died on DNS. It went unnoticed because the device agents write to Influx over the /api/v2/write MFA bypass and never touch the portal - no browser had ever hit Authelia from inside the VNet. Verified before applying that an IP matcher can work here: Caddy sees real client addresses, and WireGuard peers arrive masqueraded as 172.19.0.6 so they do not match and are refused along with the internet. What this costs, recorded in section 14 as a shortcut and not as a security control: it is an IP allowlist on a flat network with no OT/IT boundary, so anything that can take 10.0.0.21 inherits unauthenticated access; Langfuse traces are now anonymous, so there is no record of who asked what; and the assistant is out of browser reach over the VPN. It is in scope for the section 2 OT/safety review, which is still outstanding. api.yokogawa.tech is unchanged and still fully gated - Phase 9 publishing depends on Remote-User/Remote-Groups and stays there. The now-inert ai.yokogawa.tech entry in the Authelia rule is deliberately left in place so restoring the gate is a Caddy reload rather than an Authelia restart that logs out every user on the host. verify.sh treats 403-from-lin001 as the pass for the deny arm and states plainly that the allow arm can only be proved from cicore1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
7.7 KiB
Bash
162 lines
7.7 KiB
Bash
#!/usr/bin/env bash
|
||
# Verify the AI stack on lin001. Read-only: it starts nothing and changes nothing.
|
||
#
|
||
# ./scripts/verify.sh
|
||
#
|
||
# "docker ps showing Up" is not proof of anything. What this checks instead:
|
||
# a 302 to the auth portal on every public hostname, clean logs, pg-ai
|
||
# unreachable from outside its own network, agent_ro genuinely read-only, and
|
||
# no host ports published by anything we added.
|
||
#
|
||
# Exit code is the number of failed checks, so it is usable in CI.
|
||
|
||
set -uo pipefail
|
||
|
||
PASS=0
|
||
FAIL=0
|
||
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
|
||
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||
head_() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||
|
||
head_ "Containers"
|
||
for name in pg-ai cube ai-api ai-web langfuse lf-db; do
|
||
if docker ps --format '{{.Names}}' | grep -qx "$name"; then
|
||
state=$(docker inspect -f '{{.State.Status}}' "$name")
|
||
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name")
|
||
if [ "$state" = "running" ] && [ "$health" != "unhealthy" ]; then
|
||
ok "$name running (health: $health)"
|
||
else
|
||
bad "$name state=$state health=$health"
|
||
fi
|
||
else
|
||
bad "$name is not running"
|
||
fi
|
||
done
|
||
|
||
head_ "No published host ports on anything we added"
|
||
# openplc-runtime publishing 502 is the one deliberate exception on this host,
|
||
# and it is not ours. Everything in the AI stack must publish nothing.
|
||
for name in pg-ai cube ai-api ai-web langfuse lf-db; do
|
||
ports=$(docker port "$name" 2>/dev/null || true)
|
||
if [ -z "$ports" ]; then
|
||
ok "$name publishes no host port"
|
||
else
|
||
bad "$name publishes: $ports"
|
||
fi
|
||
done
|
||
|
||
head_ "pg-ai network isolation"
|
||
if docker inspect pg-ai -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw proxy; then
|
||
bad "pg-ai is attached to the proxy network - it must be ai-internal only"
|
||
else
|
||
ok "pg-ai is not on the proxy network"
|
||
fi
|
||
|
||
head_ "Public endpoints - expect 302 to the auth portal"
|
||
# Run this from OUTSIDE the VNet as well. lin001 resolves yokogawa.tech through
|
||
# the DC, which holds pinpoint records only - ai and influx have one, lf, api
|
||
# and cube do not. So on this host those three do not resolve at all, and that
|
||
# says nothing about whether they work from a browser. The check reports the
|
||
# two cases separately rather than calling both a failure.
|
||
for host in lf.yokogawa.tech cube.yokogawa.tech api.yokogawa.tech; do
|
||
if ! getent hosts "$host" >/dev/null 2>&1; then
|
||
printf ' [33m??[0m %s does not resolve FROM THIS HOST (no DC pinpoint record) - check it from outside the VNet
|
||
' "$host"
|
||
continue
|
||
fi
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' -I "https://$host" --max-time 10 || echo "000")
|
||
case "$code" in
|
||
302|303) ok "$host -> $code (auth portal)" ;;
|
||
200) bad "$host -> 200 WITHOUT AUTH - check 'import authelia' in ~/Caddyfile" ;;
|
||
403) bad "$host -> 403 - Caddy is serving it but Authelia has no access_control rule, so default_policy: deny applies. Add the hostname (authelia/access-rules.md)" ;;
|
||
000) bad "$host unreachable - Caddy has no certificate, or nothing is listening" ;;
|
||
*) bad "$host -> $code" ;;
|
||
esac
|
||
done
|
||
|
||
head_ "ai.yokogawa.tech is closed to everything except the SCADA console"
|
||
# Applied 2026-08-28: the block admits remote_ip 10.0.0.21 (cicore1) only and
|
||
# 403s everything else. lin001 is NOT 10.0.0.21 as Caddy sees it, so from here
|
||
# 403 is the PASS - it proves the deny arm works and that this host, the VPN and
|
||
# the internet are all shut out.
|
||
#
|
||
# THIS SCRIPT CANNOT PROVE THE ALLOW ARM. A typo in the matcher gives 403 to
|
||
# cicore1 too and looks identical from here. Somebody must open
|
||
# https://ai.yokogawa.tech on cicore1 and get the UI with no login. There is no
|
||
# way around that, and it is the same gap as the Phase 7 end-to-end check.
|
||
for u in "https://ai.yokogawa.tech" "https://ai.yokogawa.tech/ask"; do
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$u" \
|
||
-H 'Content-Type: application/json' -d '{}' --max-time 10 || echo "000")
|
||
case "$code" in
|
||
403) ok "$u -> 403 from this host (deny arm working)" ;;
|
||
302|303) bad "$u -> $code - still going through Authelia; the SCADA-only block was not applied or was reverted" ;;
|
||
200) bad "$u -> 200 FROM THIS HOST - the matcher is not restricting anything. The assistant is open to the LAN, the VPN and, via the public A record, the internet" ;;
|
||
404) bad "$u -> 404 - the /ask route is missing from the Caddy block; ai-web is answering" ;;
|
||
000) bad "$u unreachable - Caddy has no certificate, or nothing is listening" ;;
|
||
*) bad "$u -> $code" ;;
|
||
esac
|
||
done
|
||
printf ' [33m>>[0m ALLOW ARM UNPROVEN: open https://ai.yokogawa.tech on cicore1 (10.0.0.21) - expect the UI, no login\n'
|
||
# api.yokogawa.tech has no pinpoint DNS record, so it is unresolvable from
|
||
# inside the VNet. A bundle that hard-codes it loads fine here and fails on a
|
||
# control-room PC. Check what was actually built into the image.
|
||
if docker exec ai-web sh -c 'grep -rqs "api\.yokogawa\.tech" /usr/share/nginx/html' 2>/dev/null; then
|
||
bad "the ai-web bundle hard-codes api.yokogawa.tech - cicore1 cannot resolve it; rebuild with VITE_API_BASE empty"
|
||
else
|
||
ok "ai-web bundle carries no cross-origin API hostname"
|
||
fi
|
||
|
||
# Runs BEFORE the agent_ro test on purpose. That test deliberately attempts an
|
||
# INSERT it is not allowed to make, which pg-ai logs as "permission denied for
|
||
# table equipment" - in the other order verify.sh flags, every single run, an
|
||
# error line it created itself.
|
||
head_ "Recent errors in the logs"
|
||
for name in pg-ai cube ai-api ai-web; do
|
||
errors=$(docker logs --tail 200 "$name" 2>&1 | grep -icE 'error|fatal|panic' || true)
|
||
[ "${errors:-0}" -eq 0 ] && ok "$name logs clean (last 200 lines)" \
|
||
|| bad "$name has $errors error lines - docker logs --tail 200 $name"
|
||
done
|
||
|
||
head_ "agent_ro is read-only"
|
||
if docker exec pg-ai psql -U agent_ro -d plant -tAc 'SELECT count(*) FROM equipment' >/dev/null 2>&1; then
|
||
ok "agent_ro can SELECT"
|
||
else
|
||
bad "agent_ro cannot SELECT"
|
||
fi
|
||
if docker exec pg-ai psql -U agent_ro -d plant -tAc \
|
||
"INSERT INTO equipment (equipment_id) VALUES ('VERIFY-DELETE-ME')" >/dev/null 2>&1; then
|
||
bad "agent_ro CAN INSERT - this is a Phase 1 failure, fix db/003_roles.sql now"
|
||
docker exec pg-ai psql -U postgres -d plant -c \
|
||
"DELETE FROM equipment WHERE equipment_id='VERIFY-DELETE-ME'" >/dev/null 2>&1
|
||
else
|
||
ok "agent_ro INSERT is rejected"
|
||
fi
|
||
|
||
head_ "Reference data"
|
||
missing_eq=$(docker exec pg-ai psql -U postgres -d plant -tAc \
|
||
"SELECT count(*) FROM equipment WHERE coalesce(array_length(aliases,1),0)=0" 2>/dev/null || echo "?")
|
||
missing_tag=$(docker exec pg-ai psql -U postgres -d plant -tAc \
|
||
"SELECT count(*) FROM tags WHERE coalesce(array_length(aliases,1),0)=0" 2>/dev/null || echo "?")
|
||
[ "$missing_eq" = "0" ] && ok "every equipment item has an alias" || bad "$missing_eq equipment items have no alias"
|
||
[ "$missing_tag" = "0" ] && ok "every tag has an alias" || bad "$missing_tag tags have no alias"
|
||
|
||
if docker exec pg-ai psql -U postgres -d plant -tAc \
|
||
"SELECT 1 FROM pg_extension WHERE extname='vector'" 2>/dev/null | grep -q 1; then
|
||
ok "pgvector extension present"
|
||
else
|
||
bad "pgvector extension missing"
|
||
fi
|
||
|
||
head_ "Fixture data"
|
||
if docker exec pg-ai psql -U postgres -d plant -tAc \
|
||
"SELECT 1 FROM information_schema.schemata WHERE schema_name='fixture'" 2>/dev/null | grep -q 1; then
|
||
rows=$(docker exec pg-ai psql -U postgres -d plant -tAc \
|
||
"SELECT count(*) FROM fixture.alarm_history" 2>/dev/null)
|
||
printf ' \033[33m!!\033[0m fixture schema present (%s alarm rows) - answers are TEST DATA, not plant history\n' "$rows"
|
||
fi
|
||
|
||
head_ "Disk"
|
||
df -h / /datadisk | sed 's/^/ /'
|
||
|
||
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
|
||
exit "$FAIL"
|