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("");