feat(deploy): LAN stack — run APESS in the room so the API can reach the boards
Fixes the reachability gap: a cloud-hosted API can't reach boards behind the
workshop NAT, so live board features (claim status, Build & flash, module
feeds) fail. This stack runs web + API on one box on the workshop WiFi, where
the API CAN reach each board's LAN IP.
- deploy/lan/: docker-compose (web + internal API), nginx.lan.conf (SPA +
same-origin /api reverse-proxy with WS/SSE support), .env.example, README
(launch, point the fleet's APESS_URL at the box, run the room).
- Web is IP-agnostic: nginx serves the SPA and proxies /api on the same origin,
so no box IP is baked into the build.
- api.ts: API_BASE now supports a relative same-origin base ("/api"); wsUrl()
derives ws scheme+host from the page for it (absolute prod base unchanged).
- Dockerfile.web: VITE_API_BASE build arg (empty=prod default, "/api"=LAN).
Verified E2E with a real `docker compose up`: SPA 200, /api/healthz ok,
self-register→unclaimed round-trip through the proxy, /api/ws → 101 Switching
Protocols, and 0 hosted-URL refs in the /api build. Web 220 + api 56 green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2c18e67c1b
commit
95c947be5b
@@ -15,6 +15,7 @@ dist-ssr
|
|||||||
# Fleet onboarding secrets/output (claim codes, FLEET_SECRET, board assignments)
|
# Fleet onboarding secrets/output (claim codes, FLEET_SECRET, board assignments)
|
||||||
deploy/uno-q/kit-codes/
|
deploy/uno-q/kit-codes/
|
||||||
deploy/uno-q/fleet.csv
|
deploy/uno-q/fleet.csv
|
||||||
|
deploy/lan/.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ RUN corepack enable && corepack prepare [email protected] --activate
|
|||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile --config.confirmModulesPurge=false || pnpm install --no-frozen-lockfile
|
RUN pnpm install --frozen-lockfile --config.confirmModulesPurge=false || pnpm install --no-frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Where the SPA points its API/WS/SSE calls. Empty (prod default) → the hosted
|
||||||
|
# API; "/api" → same-origin behind the LAN reverse proxy (see deploy/lan/).
|
||||||
|
ARG VITE_API_BASE=""
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# APESS 2026 — gw-03 deploy
|
# APESS 2026 — gw-03 deploy
|
||||||
|
|
||||||
|
> **Running the live workshop?** This cloud deploy is the public site / remote-judging
|
||||||
|
> path. A cloud API **cannot reach boards behind the room's NAT**, so the live
|
||||||
|
> board features (claim status, Build & flash, module live feeds) need the API on
|
||||||
|
> the workshop LAN — see **[`lan/`](lan/README.md)** to run the whole stack on a
|
||||||
|
> box in the room. Same images, different front door.
|
||||||
|
|
||||||
Drops `apess-web` (static SPA) and `apess-api` (collective backend) next to the existing ClawBooks stack on `zeroclaw-gw-03` (Architect). Reuses ClawBooks' Traefik v3.6 instance via the shared `clawbooks-net` Docker network.
|
Drops `apess-web` (static SPA) and `apess-api` (collective backend) next to the existing ClawBooks stack on `zeroclaw-gw-03` (Architect). Reuses ClawBooks' Traefik v3.6 instance via the shared `clawbooks-net` Docker network.
|
||||||
|
|
||||||
- `apess.redclaw.dev` → `apess-web` (nginx static)
|
- `apess.redclaw.dev` → `apess-web` (nginx static)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Copy to .env and fill in. Do NOT commit .env.
|
||||||
|
ADMIN_CODE=adm-xxxxxxxx
|
||||||
|
JUDGE_CODE=jdg-xxxxxxxx
|
||||||
|
# Shared secret each board presents to POST /nodes/self-register. Must equal the
|
||||||
|
# FLEET_SECRET baked into every board's apess-node.env (gen-kit-codes.sh).
|
||||||
|
FLEET_SECRET=change-me
|
||||||
|
# Host port for the web UI. Use 8080 if the box can't bind privileged :80.
|
||||||
|
WEB_PORT=80
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# APESS on a local LAN (run it in the room)
|
||||||
|
|
||||||
|
Run the whole platform on **one box on the workshop WiFi** — a laptop, NUC, or
|
||||||
|
mini-PC. This is the fix for the reachability gap: the API's live board features
|
||||||
|
(claim status, Build & flash, the module live feeds) all work by the API calling
|
||||||
|
each board at its **LAN IP**. A cloud-hosted API can't reach boards behind the
|
||||||
|
room's NAT; an API *on the LAN* can.
|
||||||
|
|
||||||
|
```
|
||||||
|
workshop WiFi (one subnet)
|
||||||
|
┌────────────┐ ┌──────────────────────────┐ ┌───────────────┐
|
||||||
|
│ attendee │────▶│ APESS box (this stack) │────▶│ Uno Q boards │
|
||||||
|
│ laptops │ http│ nginx :80 ─/api▶ api │ http│ :8080 gateway │
|
||||||
|
│ + phones │ │ reaches boards ─┼────▶│ │
|
||||||
|
└────────────┘ └──────────────────────────┘ └───────────────┘
|
||||||
|
http://<box-lan-ip>/ API → board LAN IPs
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything (attendees, judges at `/judge`, instructor at `/admin`) is served
|
||||||
|
from `http://<box-lan-ip>/`. The API rides the same origin under `/api`, so the
|
||||||
|
web build has no IP baked in — it works at whatever address the box gets.
|
||||||
|
|
||||||
|
## 1. Launch the stack
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy/lan
|
||||||
|
cp .env.example .env # set ADMIN_CODE / JUDGE_CODE / FLEET_SECRET
|
||||||
|
docker compose --env-file .env -f docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the box's LAN IP (attendees will use it):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# macOS
|
||||||
|
ipconfig getifaddr en0
|
||||||
|
# Linux
|
||||||
|
hostname -I | awk '{print $1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Sanity check (replace with your IP):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s http://<box-lan-ip>/api/healthz # ok
|
||||||
|
open http://<box-lan-ip>/ # the landing page
|
||||||
|
```
|
||||||
|
|
||||||
|
> Can't bind `:80` (no root / port in use)? `WEB_PORT=8080 docker compose …` and
|
||||||
|
> use `http://<box-lan-ip>:8080/` everywhere below.
|
||||||
|
|
||||||
|
## 2. Point the fleet at the box
|
||||||
|
|
||||||
|
Boards announce + get proxied through the box, so their `APESS_URL` is the box's
|
||||||
|
`/api`. Mint the kit codes/env/QRs with the LAN addresses:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ../uno-q
|
||||||
|
FLEET_SECRET=<same-as-.env> \
|
||||||
|
APESS_URL=http://<box-lan-ip>/api \
|
||||||
|
APESS_WEB=http://<box-lan-ip> \
|
||||||
|
./gen-kit-codes.sh 15
|
||||||
|
./provision-fleet.sh fleet.csv # pushes each board's env (APESS_URL=…/api) + flashes rain
|
||||||
|
./gen-qr-sheet.sh # QRs now encode http://<box-lan-ip>/workshop?kit=…&code=…
|
||||||
|
```
|
||||||
|
|
||||||
|
The board's self-register hits `http://<box-lan-ip>/api/nodes/self-register`;
|
||||||
|
the box's API then reaches the board back at the IP it reported. Because the API
|
||||||
|
is on the LAN, that return path works — which is the whole point.
|
||||||
|
|
||||||
|
## 3. Run the room
|
||||||
|
|
||||||
|
- Attendees join the workshop WiFi, scan their kit QR → `http://<box-lan-ip>/workshop?kit=…`.
|
||||||
|
- Instructor: `http://<box-lan-ip>/admin` (ADMIN_CODE). Watch boards go live,
|
||||||
|
see the unclaimed pool, release/reassign a kit if a team hits trouble.
|
||||||
|
- Judges: `http://<box-lan-ip>/judge` (JUDGE_CODE).
|
||||||
|
|
||||||
|
Board trouble recovers on its own where it can (see the `onboarding` re-attach
|
||||||
|
work): a rebooted board re-announces and the API refreshes its binding; a team
|
||||||
|
that lost its browser just re-scans the QR + re-enters the code to resume. For
|
||||||
|
anything stuck, release the kit from `/admin` and let the team re-claim.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Data** persists in the `apess-lan-data` volume (`docker compose down` keeps
|
||||||
|
it; `down -v` wipes teams/submissions). Back it up with
|
||||||
|
`docker run --rm -v apess-lan_apess-lan-data:/d -v "$PWD":/o alpine cp /d/apess.db /o/`.
|
||||||
|
- **No internet needed** for the platform itself. Boards still need whatever
|
||||||
|
their *inference* path needs — fully offline with `?agent=local` (on-board
|
||||||
|
Qwen), or a reachable cloud endpoint for `cloud`/`default`.
|
||||||
|
- **vs. the cloud deploy** (`deploy/docker-compose.yml` + traefik): that stays
|
||||||
|
the public site / remote-judging path. Use this LAN stack for the live room so
|
||||||
|
the board features actually work. Same images, different front door.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# APESS workshop — self-contained LAN stack.
|
||||||
|
#
|
||||||
|
# Runs the whole platform (web + API) on ONE box on the workshop WiFi, so the
|
||||||
|
# API can reach each board's LAN IP directly — the thing a cloud-hosted API
|
||||||
|
# cannot do (boards sit behind NAT). Attendees, judges and the instructor all
|
||||||
|
# reach it at http://<box-lan-ip>/ over the same WiFi.
|
||||||
|
#
|
||||||
|
# cp .env.example .env # set ADMIN_CODE / JUDGE_CODE / FLEET_SECRET
|
||||||
|
# docker compose --env-file .env -f docker-compose.yml up -d --build
|
||||||
|
#
|
||||||
|
# Only the web port is published; the API is reachable via the same origin at
|
||||||
|
# /api (nginx reverse-proxy), so nothing needs the box's IP baked in at build.
|
||||||
|
|
||||||
|
name: apess-lan
|
||||||
|
|
||||||
|
services:
|
||||||
|
apess-web:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/Dockerfile.web
|
||||||
|
args:
|
||||||
|
VITE_API_BASE: /api # same-origin proxy → IP-agnostic build
|
||||||
|
image: apess-web:lan
|
||||||
|
container_name: apess-web-lan
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${WEB_PORT:-80}:80' # WEB_PORT=8080 if the box can't bind :80
|
||||||
|
volumes:
|
||||||
|
- ./nginx.lan.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- apess-api
|
||||||
|
networks: [apess-lan]
|
||||||
|
|
||||||
|
apess-api:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: api/Dockerfile
|
||||||
|
image: apess-api:lan
|
||||||
|
container_name: apess-api-lan
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ADMIN_CODE: ${ADMIN_CODE:?set ADMIN_CODE}
|
||||||
|
JUDGE_CODE: ${JUDGE_CODE:?set JUDGE_CODE}
|
||||||
|
# Must match the FLEET_SECRET baked into each board's apess-node.env.
|
||||||
|
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
|
||||||
|
DB_PATH: /data/apess.db
|
||||||
|
# Same-origin via the /api proxy → no CORS needed (API default is permissive).
|
||||||
|
volumes:
|
||||||
|
- apess-lan-data:/data
|
||||||
|
# Not published: boards + browsers reach the API through the web's /api proxy.
|
||||||
|
networks: [apess-lan]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
apess-lan:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
apess-lan-data:
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# LAN single-origin front door: serve the SPA and reverse-proxy the API under
|
||||||
|
# /api on the same origin, so the web build is IP-agnostic (VITE_API_BASE=/api)
|
||||||
|
# and works at whatever address the workshop box happens to get.
|
||||||
|
|
||||||
|
# WebSocket upgrade plumbing (http-context; conf.d files are included there).
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# API + WS + SSE → the api container. Trailing slash strips the /api prefix,
|
||||||
|
# so the app's /api/teams reaches the API as /teams.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://apess-api:3000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# WebSocket (/api/ws) upgrade
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
# long-lived WS + SSE: don't buffer, don't time out mid-stream
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback (client-side routes: /workshop, /admin, /judge, …)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-4
@@ -8,8 +8,21 @@ import type {
|
|||||||
WsEvent,
|
WsEvent,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const API_BASE =
|
// Where the collective API lives. Three shapes:
|
||||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'https://apess-api.redclaw.dev'
|
// unset / empty → the hosted prod API (absolute, cross-origin)
|
||||||
|
// "/api" → same-origin behind a reverse proxy (LAN box; IP-agnostic)
|
||||||
|
// "http://host:3000" → an explicit absolute origin
|
||||||
|
const RAW_API_BASE = (import.meta.env.VITE_API_BASE as string | undefined)?.trim()
|
||||||
|
export const API_BASE = RAW_API_BASE || 'https://apess-api.redclaw.dev'
|
||||||
|
|
||||||
|
/** Build the WS URL. Absolute base → swap http→ws; relative/same-origin base
|
||||||
|
* ('' or '/api') → derive scheme+host from the page so it works at any LAN IP. */
|
||||||
|
function wsUrl(code: string): string {
|
||||||
|
const q = `/ws?code=${encodeURIComponent(code)}`
|
||||||
|
if (/^https?:\/\//i.test(API_BASE)) return `${API_BASE.replace(/^http/, 'ws')}${q}`
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
return `${proto}://${window.location.host}${API_BASE}${q}`
|
||||||
|
}
|
||||||
|
|
||||||
async function asJson<T>(res: Response, label: string): Promise<T> {
|
async function asJson<T>(res: Response, label: string): Promise<T> {
|
||||||
if (!res.ok) throw new Error(`${label} ${res.status}`)
|
if (!res.ok) throw new Error(`${label} ${res.status}`)
|
||||||
@@ -176,8 +189,7 @@ export function openCollective(
|
|||||||
onEvent: (e: WsEvent) => void,
|
onEvent: (e: WsEvent) => void,
|
||||||
opts: OpenCollectiveOptions = {},
|
opts: OpenCollectiveOptions = {},
|
||||||
): () => void {
|
): () => void {
|
||||||
const wsBase = API_BASE.replace(/^http/, 'ws')
|
const url = wsUrl(code)
|
||||||
const url = `${wsBase}/ws?code=${encodeURIComponent(code)}`
|
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let closed = false
|
let closed = false
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user