From 76af3156fac1b04ded686cbd0c5a1ccfef5ff32d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:48:22 +1000 Subject: [PATCH] Serve the operator page and /ask from one origin api.yokogawa.tech has a public A record but no pinpoint record on the DC, so it does not resolve from inside the VNet at all. The browser called it by hostname, which means an operator on cicore1 would have loaded the page and had every question fail on DNS - the exact gap Phase 7's gate exists to catch, and one an engineer's laptop cannot see. Caddy now routes /ask under ai.yokogawa.tech to ai-api, inside a route block so import authelia still runs first: forward_auth sorts after handle in the default directive order, and outside a route the handles would be terminal and the gate would never run. Only /ask is routed - the Phase 9 publisher rule is scoped to api.yokogawa.tech and a wider route here would leave it inert. Also fixes the fallback it replaces. The build arg defaults to "", and `?? "https://api.yokogawa.tech"` does not catch an empty string, so the documented real-deployment build resolved the API base to "" and posted /ask at ai-web, which 404s it. verify.sh and deploy.sh now check both the route and whether the built bundle carries the hostname. Co-Authored-By: Claude Opus 5 --- BUILD-AI-CONTAINERS.md | 9 +++++--- README.md | 20 +++++++++++----- api/main.py | 26 ++++++++++++--------- caddy/ai-routes.caddy | 52 ++++++++++++++++++++++++++++++++++++------ compose/ai-compose.yml | 7 +++--- scripts/deploy.sh | 14 ++++++++++-- scripts/verify.sh | 21 +++++++++++++++++ web/Dockerfile | 5 ++-- web/src/App.tsx | 26 +++++++++++++-------- 9 files changed, 136 insertions(+), 44 deletions(-) diff --git a/BUILD-AI-CONTAINERS.md b/BUILD-AI-CONTAINERS.md index 4171b5a..7bb37a1 100644 --- a/BUILD-AI-CONTAINERS.md +++ b/BUILD-AI-CONTAINERS.md @@ -195,6 +195,8 @@ ai.yokogawa.tech { **Azure hairpin:** LAN hosts cannot reach the VM's public IP from inside the VNet. For an operator on `cicore1` to reach `ai.yokogawa.tech` by hostname, the DC needs a pinpoint record → `10.0.0.17`, the same treatment `influx.yokogawa.tech` already has. **Raise this early** — it is a dependency on someone else and it will not surface until Phase 7. +**Done 2026-08-27** for `ai.yokogawa.tech` only. `api` and `cube` have public A records but **no pinpoint record**, so they do not resolve inside the VNet at all. The operator UI therefore does not call `api.yokogawa.tech`: Caddy routes `/ask` under `ai.yokogawa.tech` to `ai-api` and the page is same-origin. Only `/ask` — the Phase 9 publisher rule is scoped to `api.yokogawa.tech`, and a wider route there would make it inert. See `caddy/ai-routes.caddy`. + --- ## 6. Working around the pending `imh` @@ -595,13 +597,14 @@ local time changes the answer. **Tasks** 1. React + Vite: question box, answer pane, **"show working"** panel (class, query, row count, citations with revision and effective date). 2. Procedural and Advisory answers carry a visible scope banner stating what the assistant did *not* do. Operators must not infer this from tone. -3. `ai-web` behind Caddy + Authelia at `ai.yokogawa.tech`. -4. **Pinpoint DNS on the DC** → `10.0.0.17` so `cicore1` can resolve it (Azure hairpin). Ask Dan. +3. `ai-web` behind Caddy + Authelia at `ai.yokogawa.tech`, with `/ask` routed to `ai-api` under the same hostname so the page is same-origin. Build `ai-web` with `VITE_API_BASE` empty; a bundle carrying `api.yokogawa.tech` works from outside and fails on every control-room PC. +4. ~~**Pinpoint DNS on the DC** → `10.0.0.17` so `cicore1` can resolve it (Azure hairpin). Ask Dan.~~ **Done 2026-08-27.** 5. Confirm the operator's AD account is a **direct** member of `HTTPS_UserAccess` and Duo-enrolled. **Gate** - [ ] `curl -sI https://ai.yokogawa.tech` → 302 to the auth portal -- [ ] An operator on `cicore1` reaches the UI **by hostname** and gets an answer end to end +- [ ] `curl -s -o /dev/null -w '%{http_code}' -X POST https://ai.yokogawa.tech/ask` → 302, **not 404**. A 404 is ai-web answering, which means the `/ask` route is missing and every question will fail. +- [ ] An operator on `cicore1` reaches the UI **by hostname** and gets an answer end to end — this is the check that catches a name resolvable from outside and not from inside. Doing it from an engineer's laptop proves nothing about it. - [ ] Citations show document number, revision and effective date - [ ] The scope banner appears on every Procedural and Advisory answer diff --git a/README.md b/README.md index d876d54..a6afd34 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,20 @@ Phase 5"; eval cases `H26` and `H27` fail until they are settled. ./scripts/verify.sh ``` -Each prints the manual DNS/Caddy/Authelia steps. Phase 7 also needs a -**pinpoint DNS record on the DC** → `10.0.0.17` so an operator on `cicore1` can -resolve `ai.yokogawa.tech` — Azure hairpin means LAN hosts cannot reach the -VM's public IP from inside the VNet. `influx.yokogawa.tech` already has this -treatment. **Raise it early**; it depends on someone else and will not surface -until you try it. +Each prints the manual DNS/Caddy/Authelia steps. + +**DNS is done as of 27 August 2026**: `ai`, `api` and `cube` all resolve to +`20.211.144.151`, and the DC carries the pinpoint record `ai.yokogawa.tech` → +`10.0.0.17` that the Azure hairpin requires. The Caddyfile blocks and Authelia +rules for the three names still have to be applied on the host — until they +are, the names resolve and nothing answers. + +**`api.yokogawa.tech` has no pinpoint record and does not resolve inside the +VNet.** That is why the Phase 7 Caddy block routes `/ask` under +`ai.yokogawa.tech` to `ai-api` and the page is same-origin: a cross-origin +build loads on a control-room PC and then fails every question on DNS. Only +`/ask` is routed there — see the warning in +[`caddy/ai-routes.caddy`](caddy/ai-routes.caddy) before widening it. ### 7. Phase 8 — validate diff --git a/api/main.py b/api/main.py index f2a8c05..389f074 100644 --- a/api/main.py +++ b/api/main.py @@ -41,19 +41,23 @@ app = FastAPI( ), ) -# The browser reaches the API by its own public hostname, so a same-site -# origin is all that is ever needed. +# The operator's page is same-origin as of 2026-08-27, so it makes no +# cross-origin call and none of this applies to it. CORS is kept for the +# tunnelled build and for anything calling api.yokogawa.tech directly. # -# NO_LLM_STUB additionally allows a tunnelled origin, because the public -# hostnames do not resolve yet and the whole chain has to be exercisable -# without them. It is deliberately tied to the stub flag so it disappears when -# the flag does. +# NO_LLM_STUB additionally allows a tunnelled origin, because the whole chain +# has to be exercisable without Caddy and Authelia. It is deliberately tied to +# the stub flag so it disappears when the flag does. # -# The alternative - proxying /api through ai-web's nginx to make the page -# same-origin - was rejected: it creates a second route to the API that does -# not pass through the api.yokogawa.tech Caddy block, and that block is where -# the Phase 9 publisher rule for ^/docs/.* lives. A convenience path around an -# authorisation rule is how the rule stops meaning anything. +# Earlier this file recorded the same-origin option as rejected, on the grounds +# that a second route to the API bypasses the api.yokogawa.tech Caddy block, +# where the Phase 9 publisher rule for ^/docs/.* lives. That objection was to +# proxying the WHOLE API through ai-web's nginx, and it still stands. What was +# built instead is narrower: Caddy routes ONLY /ask under ai.yokogawa.tech, +# through the same `import authelia` gate; /docs has no route there and 404s. +# The reason it had to change is that api.yokogawa.tech has no pinpoint DNS +# record, so an operator on cicore1 cannot resolve it at all. See +# caddy/ai-routes.caddy, which carries the do-not-widen warning. _ORIGINS = ["https://ai.yokogawa.tech"] if settings().no_llm_stub: _ORIGINS.append("http://localhost:8080") diff --git a/caddy/ai-routes.caddy b/caddy/ai-routes.caddy index 5e3415c..6a18884 100644 --- a/caddy/ai-routes.caddy +++ b/caddy/ai-routes.caddy @@ -15,11 +15,12 @@ # DNS is not managed on this host. Each hostname needs an A record -> # 20.211.144.151 before Caddy can issue a certificate. Ask Dan. # -# Azure hairpin: LAN hosts cannot reach the VM public IP from inside the VNet. -# For an operator on cicore1 to reach ai.yokogawa.tech by hostname, the DC needs -# a pinpoint record -> 10.0.0.17, the same treatment influx.yokogawa.tech has. -# Raise this early - it is a dependency on someone else and will not surface -# until Phase 7. +# DONE 2026-08-27: ai, api and cube .yokogawa.tech all resolve to +# 20.211.144.151, and the DC carries the pinpoint record +# ai.yokogawa.tech -> 10.0.0.17 for the Azure hairpin (same treatment as +# influx.yokogawa.tech). api and cube have NO pinpoint record and are +# unresolvable from inside the VNet - which is why /ask is routed under +# ai.yokogawa.tech below rather than called cross-origin. # # Add each block at the phase that needs it, not all at once. A hostname with a # Caddyfile block and no Authelia rule is a hole. @@ -56,6 +57,16 @@ api.yokogawa.tech { # edit the shared snippet to add it - every other service on the host # imports it, and this is not the change to make on their behalf. # +# CHECKED 2026-08-27: the snippet on lin001 is +# forward_auth authelia:9091 { +# uri /api/authz/forward-auth +# copy_headers Remote-User Remote-Groups Remote-Name Remote-Email +# } +# It already copies all four headers, so Phase 9 needs NO change here - +# only request_body if 50MB uploads are wanted. Note the uri differs from +# the one written below. Re-check before Phase 9 rather than trusting this +# line; it is a file other people edit. +# # If the snippet does not copy them, replace the Phase 6 block with: # # api.yokogawa.tech { @@ -81,7 +92,34 @@ api.yokogawa.tech { # matcher would be a third place to keep in step, and the first to be forgotten. # --- Phase 7 ----------------------------------------------------------------- +# /ask is served under THIS hostname as well as under api.yokogawa.tech, so the +# operator's page is same-origin. That is not a convenience: LAN hosts resolve +# yokogawa.tech names through the DC, which holds pinpoint records only, and +# there is one for ai.yokogawa.tech and none for api.yokogawa.tech. Cross-origin +# means an operator on cicore1 loads the page and every question fails on DNS. +# +# ONLY /ask. Do NOT widen this matcher. +# - The Phase 9 publisher rule is `domain: api.yokogawa.tech` + +# `resources: ^/docs/.*` (authelia/access-rules.md). A /docs route under this +# hostname would not match it, and approving a procedure revision would stop +# being gated on AI_DocPublishers. ai-api re-checks Remote-Groups itself, so +# it would not actually fail open - but the rule would be inert and nobody +# would know. +# - `/docs*` under this hostname hits the second handle and is served by +# ai-web, which 404s it. That is the intended outcome. Leave it that way. +# +# `route` is required. `import authelia` expands to forward_auth, which sorts +# AFTER handle in Caddy's default directive order - outside a route block the +# handles would be terminal and the gate would never run, silently serving the +# UI to anyone. Inside `route`, directives run in written order. ai.yokogawa.tech { - import authelia - reverse_proxy ai-web:80 + route { + import authelia + handle /ask* { + reverse_proxy ai-api:8000 + } + handle { + reverse_proxy ai-web:80 + } + } } diff --git a/compose/ai-compose.yml b/compose/ai-compose.yml index 4dbab85..7521f53 100644 --- a/compose/ai-compose.yml +++ b/compose/ai-compose.yml @@ -171,9 +171,10 @@ services: context: /home/azureuser/ai/web dockerfile: Dockerfile args: - # Empty in a real deployment: the browser reaches the API at its own - # public hostname through Caddy. Set only for a tunnelled demo build, - # which has no Caddy and no Authelia in front of it. + # Empty in a real deployment: the page is same-origin, because Caddy + # routes /ask under ai.yokogawa.tech to ai-api. Set only for a + # tunnelled demo build, which has no Caddy and no Authelia in front + # of it. VITE_API_BASE: "${VITE_API_BASE:-}" image: yau/ai-web:local container_name: ai-web diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9783cf8..cbcc55d 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -147,8 +147,16 @@ deploy_web() { say "Building and starting ai-web" docker compose -f "$COMPOSE" up -d --build ai-web manual_steps "ai.yokogawa.tech" - warn "Azure hairpin: cicore1 cannot reach the public IP from inside the VNet." - warn "The DC needs a pinpoint record ai.yokogawa.tech -> 10.0.0.17. Ask Dan." + warn "The ai.yokogawa.tech Caddy block routes /ask to ai-api so the page is" + warn "same-origin: api.yokogawa.tech has NO pinpoint record and does not" + warn "resolve inside the VNet. Do not widen that route past /ask - the" + warn "Phase 9 publisher rule is scoped to api.yokogawa.tech. See" + warn "caddy/ai-routes.caddy." + # A bundle built with VITE_API_BASE set to a public hostname works from + # outside and fails on every control-room PC. Catch it here, not on the day. + if docker exec ai-web sh -c 'grep -rqs "api\.yokogawa\.tech" /usr/share/nginx/html' 2>/dev/null; then + warn "THIS BUILD hard-codes api.yokogawa.tech. Rebuild with VITE_API_BASE empty." + fi } # --- The parts a human must do ----------------------------------------------- @@ -161,6 +169,8 @@ manual_steps() { 1. DNS A record -> 20.211.144.151. Ask Dan; DNS is not managed here. Caddy cannot issue a certificate without it. + DONE 2026-08-27 for ai, api and cube, plus the DC pinpoint record + ai.yokogawa.tech -> 10.0.0.17. Nothing to do for those three. 2. Append the block from caddy/ai-routes.caddy to ~/Caddyfile. Keep 'import authelia'. Omitting it silently makes the service public. diff --git a/scripts/verify.sh b/scripts/verify.sh index cd779f4..d1361ed 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -63,6 +63,27 @@ for host in lf.yokogawa.tech cube.yokogawa.tech api.yokogawa.tech ai.yokogawa.te esac done +head_ "The operator page and the API share an origin" +# The browser posts to /ask on ai.yokogawa.tech. If the route is missing, ai-web +# serves the SPA's 404 and every question fails - with the UI itself looking +# perfectly healthy. Unauthenticated this must be the auth portal redirect, NOT +# a 404 from ai-web. +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "https://ai.yokogawa.tech/ask" -H 'Content-Type: application/json' -d '{}' --max-time 10 || echo "000") +case "$code" in + 302|303) ok "ai.yokogawa.tech/ask -> $code (routed to ai-api, gated)" ;; + 404) bad "ai.yokogawa.tech/ask -> 404 - the /ask route is missing from the Caddy block; ai-web is answering" ;; + 000) bad "ai.yokogawa.tech/ask unreachable" ;; + *) bad "ai.yokogawa.tech/ask -> $code" ;; +esac +# 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 + 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" diff --git a/web/Dockerfile b/web/Dockerfile index f67ce11..04344ea 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,8 +5,9 @@ COPY package.json ./ RUN npm install COPY . . -# Where the browser should send /ask. Empty means the built-in default, which -# is the public hostname. A tunnelled demo build overrides it: +# Where the browser should send /ask. Empty - the default - means same-origin: +# Caddy routes /ask under ai.yokogawa.tech to ai-api, so no hostname is baked +# into the bundle at all. A tunnelled demo build overrides it: # --build-arg VITE_API_BASE=http://localhost:8001 # Vite inlines this at BUILD time, so a change needs a rebuild, not a restart. ARG VITE_API_BASE="" diff --git a/web/src/App.tsx b/web/src/App.tsx index 2c2b4d5..95ca145 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,19 +1,25 @@ import { useState } from "react"; import type { AnswerBody, AskResponse, Citation } from "./types"; -// Same-origin in dev, otherwise VITE_API_BASE at build time, falling back to -// the public hostname. The browser carries the Authelia session cookie either -// way; there is no token handling in this app because Authelia authenticates -// at the edge. +// Same-origin, always, unless a build explicitly overrides it. Caddy routes +// /ask under ai.yokogawa.tech to ai-api (caddy/ai-routes.caddy), so the page +// and the API share an origin and the browser attaches the Authelia session +// cookie with no cross-origin handling at all. // -// VITE_API_BASE exists because the public hostname does not resolve yet. A -// tunnelled build points it at http://localhost:8001 so the whole chain can be -// exercised before DNS, Caddy and Authelia are in place - which also means a -// tunnelled build has NO AUTHENTICATION in front of it. It is reachable only -// through an SSH tunnel from one machine, and it is not a deployment. +// It is same-origin because an operator on cicore1 CANNOT resolve +// api.yokogawa.tech: LAN hosts resolve through the DC, which holds a pinpoint +// record for ai.yokogawa.tech and none for api. A cross-origin build loads and +// then fails every question on DNS. +// +// VITE_API_BASE remains for a tunnelled build, which points it at +// http://localhost:8001 - that has NO AUTHENTICATION in front of it, is +// reachable only through an SSH tunnel from one machine, and is not a +// deployment. The empty-string check is deliberate: the build arg defaults to +// "" (web/Dockerfile), and `??` does not catch an empty string - it read as a +// valid base and sent /ask to the origin with no route for it. const API = import.meta.env.DEV ? "/api" - : (import.meta.env.VITE_API_BASE ?? "https://api.yokogawa.tech"); + : (import.meta.env.VITE_API_BASE || ""); export default function App() { const [question, setQuestion] = useState("");