Initial commit: Imperva WAF demo lab
Flight-booking app scaffolded from the source running on the GCP host, plus the four-screen frontend and three OWASP attack endpoints (SQLi, reflected XSS, path traversal) built and locally verified against a live uvicorn/vite dev pair. Includes deploy.sh (local build -> ship over ssh -> swap on host) and docs/ carried over from the handoff.
This commit is contained in:
commit
4d60fdd139
28 files changed
+2609
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
imperva-proxy/certs/*.key
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Context and working guide for the Imperva WAF demo lab. Read this first, then the
|
||||||
|
files under `docs/` for depth. This file is meant to orient any AI agent (or human)
|
||||||
|
picking up the project so they don't re-derive decisions that were already made and
|
||||||
|
deliberately reversed once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A deliberately-vulnerable flight-booking web app sitting behind the **Imperva Cloud
|
||||||
|
WAF**, used to demonstrate that the WAF detects and blocks common OWASP attacks and
|
||||||
|
surfaces them in the Imperva portal. The app itself is not the point — it exists to
|
||||||
|
give the WAF something realistic to protect and to produce clean, distinct blocked
|
||||||
|
events on screen during a live demo.
|
||||||
|
|
||||||
|
The stack runs as **rootless Podman containers** on a single small GCP instance. All
|
||||||
|
public traffic is forced through Imperva; the origin only accepts connections from
|
||||||
|
Imperva's IP ranges.
|
||||||
|
|
||||||
|
## Current state (as of this handoff)
|
||||||
|
|
||||||
|
- Backend (`backend-api`), frontend (`frontend-vue`), and Caddy (`caddy-proxy`) are
|
||||||
|
deployed and working on the GCP host.
|
||||||
|
- **Edge/origin TLS is done and verified.** Imperva terminates the visitor's TLS at
|
||||||
|
the edge and **re-encrypts to the origin over HTTPS** using a **self-signed cert**
|
||||||
|
that Caddy serves on `:443`. Confirmed working end to end (`https://flytoouter.space/`
|
||||||
|
loads with Imperva's trusted edge cert; origin serves self-signed to Imperva only).
|
||||||
|
- A **local-build → ship-over-SSH → swap-on-host** deploy pipeline is defined
|
||||||
|
(`deploy.sh`). See `docs/DEPLOY.md`.
|
||||||
|
- **Repo scaffolded locally** by pulling the exact source that's live on the host
|
||||||
|
(`~/imperva-backend`, `~/imperva-frontend`, `~/imperva-proxy`) via `scp`, so the repo
|
||||||
|
matches what's actually running, not just what was intended.
|
||||||
|
- **The frontend on the GCP host is still a near-empty stub** (just whitespace and a
|
||||||
|
page title) — the real four-screen Vue SPA now exists in this repo's `frontend/` but
|
||||||
|
has not been shipped yet. See "What's next".
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
The four-screen frontend and three attack endpoints described below are **built and
|
||||||
|
locally verified**, but **not yet deployed to the GCP host** — the host still runs the
|
||||||
|
old stub frontend and two-endpoint backend. Next step is running `./deploy.sh` from a
|
||||||
|
real host terminal (see the Podman/sandbox gotcha below), then walking the
|
||||||
|
`docs/DEMO_RUNBOOK.md` pre-demo checklist against `https://flytoouter.space/`.
|
||||||
|
|
||||||
|
Three OWASP attacks, each blocked by an Imperva **default signature** and shown as a
|
||||||
|
*distinct* event type in the portal:
|
||||||
|
|
||||||
|
1. SQL Injection — `/api/v1/users?q=` (Passenger / Admin Lookup screen)
|
||||||
|
2. Reflected XSS — `/api/v1/search?term=` (Flight Search screen)
|
||||||
|
3. Path Traversal — `/api/v1/download?file=` (Boarding Pass Download screen)
|
||||||
|
|
||||||
|
Plus a Login screen (`/api/v1/login`) kept as the credential-stuffing bonus (not in the
|
||||||
|
core demo — needs a custom rate policy, see DECISIONS.md D7).
|
||||||
|
|
||||||
|
See `docs/DEMO_RUNBOOK.md` for payloads and expected portal classifications.
|
||||||
|
|
||||||
|
**Resolved:** confirmed via `podman inspect` and the host's `.bash_history` that
|
||||||
|
`backend-api` and `frontend-vue` have **no bind mounts** — `demo.db` is created inside
|
||||||
|
the image by `init_db()` on container start, not mounted from the host. The real run
|
||||||
|
commands were:
|
||||||
|
|
||||||
|
```
|
||||||
|
podman run -d --name backend-api --restart always -p 8000:8000 backend-api
|
||||||
|
podman run -d --name frontend-vue --restart always -p 8080:8080 frontend-vue
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.sh`'s `swap()` reproduces exactly this — `--name` and `-p` only, nothing else
|
||||||
|
needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key facts an agent needs (these override the original handoff doc)
|
||||||
|
|
||||||
|
- **Host:** GCP `e2-micro`, Ubuntu, static IP `35.203.147.44`, domain `flytoouter.space`.
|
||||||
|
- **Host user:** `matt`. **Rootless Podman socket is under UID `1001`, not 1000** —
|
||||||
|
the original architecture doc says 1000; that was wrong for this host. Prefer the
|
||||||
|
connection alias so the UID is irrelevant.
|
||||||
|
- **Podman connection alias (local → host):** `gcp-lab`, already configured and set as
|
||||||
|
default:
|
||||||
|
`ssh://matt@35.203.147.44:22/run/user/1001/podman/podman.sock`
|
||||||
|
key: `~/.ssh/google_compute_engine`.
|
||||||
|
- **SSH ingress** is locked to the operator's static residential IP.
|
||||||
|
- **HTTP/HTTPS ingress** (`allow-imperva-waf`) is restricted to Imperva's published
|
||||||
|
IPv4 CIDRs only; all `0.0.0.0/0` rules were purged. The vulnerable app is therefore
|
||||||
|
never directly reachable — attacks must traverse the WAF, which is what makes them
|
||||||
|
appear in the portal.
|
||||||
|
- **e2-micro cannot build the Vue multi-stage image** (npm build is memory-hungry even
|
||||||
|
with the swapfile). Always build locally and ship the finished image. Never build on
|
||||||
|
the host.
|
||||||
|
|
||||||
|
## Repo layout (intended)
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── CLAUDE.md # this file
|
||||||
|
├── deploy.sh # local build → ship over ssh → swap on host
|
||||||
|
├── backend/ # FastAPI app + Containerfile
|
||||||
|
├── frontend/ # Vue SPA + multi-stage Containerfile
|
||||||
|
├── imperva-proxy/
|
||||||
|
│ ├── Caddyfile # origin reverse proxy (mirrors host ~/imperva-proxy/)
|
||||||
|
│ └── certs/ # origin.crt / origin.key (self-signed; do NOT commit key)
|
||||||
|
└── docs/
|
||||||
|
├── ARCHITECTURE.md # topology, TLS legs, request flow, network
|
||||||
|
├── DECISIONS.md # every design choice + why + rejected alternatives
|
||||||
|
├── DEPLOY.md # the ship-over-ssh workflow in detail
|
||||||
|
└── DEMO_RUNBOOK.md # attacks, payloads, expected portal events
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: On the **host**, the Caddyfile and certs live at `~/imperva-proxy/`. Keep the
|
||||||
|
> repo copy in sync with what's mounted into `caddy-proxy`.
|
||||||
|
|
||||||
|
## Conventions / gotchas (learned the hard way — see DECISIONS.md)
|
||||||
|
|
||||||
|
- **One catch-all `:80`/`:443` site block in the Caddyfile.** With `auto_https off`, a
|
||||||
|
hostname block (e.g. `flytoouter.space { }`) collapses onto the same port as a
|
||||||
|
`:443`/`:80` block and Caddy fails with `ambiguous site definition`. Host-based
|
||||||
|
routing, if ever needed, goes *inside* the single block as a matcher — never as a
|
||||||
|
second site block.
|
||||||
|
- **Use `handle`, not `handle_path`,** for `/api/*` so the `/api` prefix is preserved
|
||||||
|
for the backend.
|
||||||
|
- **Mount paths in `podman run` must be absolute** (`/home/matt/...`), not `~`. A
|
||||||
|
tilde that doesn't expand creates an empty anonymous mount and Caddy dies with
|
||||||
|
`open /etc/caddy/certs/origin.crt: no such file or directory`.
|
||||||
|
- **Origin cert is self-signed on purpose.** Only Imperva sees it, so no browser
|
||||||
|
warnings. Never put the self-signed cert on the *edge* (visitor-facing) side — that
|
||||||
|
stays Imperva's managed cert.
|
||||||
|
- **`https://35.203.147.44/` resetting is expected** when the origin serves only the
|
||||||
|
intended port and/or your source IP isn't an Imperva CIDR. Test the origin from the
|
||||||
|
host with `curl -vk https://localhost/`, and test the real path via
|
||||||
|
`https://flytoouter.space/`.
|
||||||
|
- **Do not commit `origin.key`.** Add `imperva-proxy/certs/*.key` to `.gitignore`.
|
||||||
|
- **`podman` builds must run on the real host terminal, not inside a sandboxed
|
||||||
|
coding-agent shell.** If you're an AI agent editing this repo from within a sandbox,
|
||||||
|
check whether `podman`/`docker` resolves before assuming you can run `deploy.sh`
|
||||||
|
yourself. On this project's dev machine, the agent sandbox is a *nested* container
|
||||||
|
(different userland/libc than the real host) sitting on the same physical disk as
|
||||||
|
`~/workspace` — so files edited in the sandbox are real and immediately usable, but a
|
||||||
|
`podman` binary installed *inside* the sandbox will collide with the host's existing
|
||||||
|
rootless container storage (`~/.local/share/containers`, shared because it's the same
|
||||||
|
disk) and fail with a `database configuration mismatch` error. Don't try to work
|
||||||
|
around this by resetting or pointing storage elsewhere — it risks corrupting the
|
||||||
|
host's real container state. Edit code from the sandbox; run `./deploy.sh` from a
|
||||||
|
normal terminal on the actual workstation.
|
||||||
|
|
||||||
|
## How to deploy (short version)
|
||||||
|
|
||||||
|
From the repo root, with the `gcp-lab` connection present:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh # build both images, ship over ssh, swap both containers
|
||||||
|
VERSION=v2 ./deploy.sh # same, but tag images v2 for a named rollback point
|
||||||
|
./deploy.sh --restart-caddy # after editing the Caddyfile (new routes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Full detail, including per-step flags and the layer-shipping trade-off, in
|
||||||
|
`docs/DEPLOY.md`.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY main.py .
|
||||||
|
COPY boarding_passes/ ./boarding_passes/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
====================================
|
||||||
|
OUTER SPACE FLIGHTS - BOARDING PASS
|
||||||
|
====================================
|
||||||
|
Passenger: J. DOE
|
||||||
|
Flight: OUT101
|
||||||
|
Route: Earth (JFK) -> Lunar Gateway
|
||||||
|
Departs: 2026-08-14 09:00
|
||||||
|
Seat: 14C
|
||||||
|
Gate: G7
|
||||||
|
Status: CONFIRMED
|
||||||
|
====================================
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
====================================
|
||||||
|
OUTER SPACE FLIGHTS - BOARDING PASS
|
||||||
|
====================================
|
||||||
|
Passenger: A. ALICE
|
||||||
|
Flight: OUT202
|
||||||
|
Route: Earth (LAX) -> Mars Colony Alpha
|
||||||
|
Departs: 2026-09-02 03:30
|
||||||
|
Seat: 02A
|
||||||
|
Gate: G14
|
||||||
|
Status: CONFIRMED
|
||||||
|
====================================
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from fastapi import FastAPI, Query, HTTPException
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI(title="Imperva Demo Leaky API", version="1.0.0")
|
||||||
|
|
||||||
|
DB_FILE = "demo.db"
|
||||||
|
BOARDING_PASS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "boarding_passes")
|
||||||
|
|
||||||
|
FLIGHTS = [
|
||||||
|
{"id": "OUT101", "origin": "Earth (JFK)", "destination": "Lunar Gateway", "depart": "2026-08-14 09:00", "price": 128000},
|
||||||
|
{"id": "OUT202", "origin": "Earth (LAX)", "destination": "Mars Colony Alpha", "depart": "2026-09-02 03:30", "price": 890000},
|
||||||
|
{"id": "OUT303", "origin": "Earth (LHR)", "destination": "Europa Research Station", "depart": "2026-10-21 14:15", "price": 2450000},
|
||||||
|
{"id": "OUT404", "origin": "Lunar Gateway", "destination": "Mars Colony Alpha", "depart": "2026-08-30 22:00", "price": 610000},
|
||||||
|
{"id": "OUT505", "origin": "Earth (NRT)", "destination": "Titan Outpost", "depart": "2026-11-11 06:45", "price": 3980000},
|
||||||
|
]
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT,
|
||||||
|
email TEXT,
|
||||||
|
role TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM users")
|
||||||
|
if cursor.fetchone()[0] == 0:
|
||||||
|
cursor.executemany("""
|
||||||
|
INSERT INTO users (username, email, role) VALUES (?, ?, ?)
|
||||||
|
""", [
|
||||||
|
("admin", "admin@flytoouter.space", "administrator"),
|
||||||
|
("jdoe", "jdoe@flytoouter.space", "engineer"),
|
||||||
|
("alice", "alice@flytoouter.space", "analyst")
|
||||||
|
])
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@app.get("/api/v1/health")
|
||||||
|
def health_check():
|
||||||
|
"""Endpoint for uptime and monitoring checks."""
|
||||||
|
return {"status": "ok", "service": "imperva-demo-backend"}
|
||||||
|
|
||||||
|
@app.post("/api/v1/login")
|
||||||
|
def login(credentials: LoginRequest):
|
||||||
|
"""
|
||||||
|
Intentionally lacks rate limiting or lockout protection.
|
||||||
|
Demo Use: Showcases Imperva Advanced Bot Protection / Credential Stuffing mitigation.
|
||||||
|
"""
|
||||||
|
if credentials.username == "admin" and credentials.password == "Secret123!":
|
||||||
|
return {"status": "authenticated", "token": "demo-jwt-token-xyz789"}
|
||||||
|
return {"status": "failed", "message": "Invalid credentials"}
|
||||||
|
|
||||||
|
@app.get("/api/v1/users")
|
||||||
|
def search_users(q: str = Query("", description="Search username")):
|
||||||
|
"""
|
||||||
|
INTENTIONAL VULNERABILITY: Raw string concatenation for SQL query.
|
||||||
|
Demo Use: Triggers Imperva default SQL Injection (SQLi) detection rules when passing payloads like `' OR '1'='1`.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
query = f"SELECT id, username, email, role FROM users WHERE username LIKE '%{q}%'"
|
||||||
|
try:
|
||||||
|
cursor.execute(query)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [{"id": r[0], "username": r[1], "email": r[2], "role": r[3]} for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/api/v1/search")
|
||||||
|
def search_flights(term: str = Query("", description="Destination search term")):
|
||||||
|
"""
|
||||||
|
INTENTIONAL VULNERABILITY: the search term is echoed back verbatim in
|
||||||
|
`query_echo` and the frontend renders it unescaped (v-html).
|
||||||
|
Demo Use: Triggers Imperva default Cross-Site Scripting (XSS) detection rules
|
||||||
|
when passing payloads like `<script>alert(1)</script>`.
|
||||||
|
"""
|
||||||
|
term_lower = term.lower()
|
||||||
|
results = [
|
||||||
|
f for f in FLIGHTS
|
||||||
|
if term_lower in f["origin"].lower() or term_lower in f["destination"].lower()
|
||||||
|
] if term else FLIGHTS
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query_echo": f"Showing results for: {term}" if term else "Showing all flights",
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/download")
|
||||||
|
def download_boarding_pass(file: str = Query(..., description="Boarding pass filename")):
|
||||||
|
"""
|
||||||
|
INTENTIONAL VULNERABILITY: raw path concatenation, no traversal sanitization.
|
||||||
|
Demo Use: Triggers Imperva default Path Traversal / Illegal Resource Access
|
||||||
|
detection rules when passing payloads like `../../../../etc/passwd`.
|
||||||
|
"""
|
||||||
|
path = os.path.join(BOARDING_PASS_DIR, file)
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Boarding pass not found")
|
||||||
|
except IsADirectoryError:
|
||||||
|
raise HTTPException(status_code=400, detail="Not a file")
|
||||||
|
return PlainTextResponse(content)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/hidden-config")
|
||||||
|
def hidden_config():
|
||||||
|
"""
|
||||||
|
Unauthenticated endpoint exposing internal application state.
|
||||||
|
Demo Use: API Discovery & custom URI blocking rules.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"env": "production-demo",
|
||||||
|
"db_connection": "sqlite:///demo.db",
|
||||||
|
"internal_api_key": "sk_live_demo_998877665544332211",
|
||||||
|
"debug_mode": True
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn==0.30.1
|
||||||
|
pydantic==2.7.4
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local build -> ship over ssh -> swap on host. No container registry (see docs/DECISIONS.md D8).
|
||||||
|
#
|
||||||
|
# Run this from a real terminal on your workstation (NOT inside a sandboxed
|
||||||
|
# coding-agent shell) with the `gcp-lab` podman connection already configured:
|
||||||
|
#
|
||||||
|
# podman system connection add gcp-lab \
|
||||||
|
# --identity ~/.ssh/google_compute_engine \
|
||||||
|
# ssh://matt@35.203.147.44:22/run/user/1001/podman/podman.sock
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh # build both, ship both, swap both containers
|
||||||
|
# VERSION=v2 ./deploy.sh # tag images v2 instead of a timestamp
|
||||||
|
# ./deploy.sh --build --ship --swap # explicit equivalent of the default
|
||||||
|
# ./deploy.sh --restart-caddy # after editing imperva-proxy/Caddyfile
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- config -------------------------------------------------------------
|
||||||
|
HOST="35.203.147.44"
|
||||||
|
HOST_USER="matt"
|
||||||
|
SSH_KEY="$HOME/.ssh/google_compute_engine"
|
||||||
|
CONNECTION="gcp-lab"
|
||||||
|
VERSION="${VERSION:-$(date +%Y%m%d%H%M%S)}"
|
||||||
|
REMOTE_PROXY_DIR="/home/matt/imperva-proxy"
|
||||||
|
|
||||||
|
BACKEND_IMAGE="backend-api"
|
||||||
|
FRONTEND_IMAGE="frontend-vue"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# --- steps ----------------------------------------------------------------
|
||||||
|
|
||||||
|
do_build() {
|
||||||
|
echo "==> Building ${BACKEND_IMAGE}:${VERSION}"
|
||||||
|
podman build -t "${BACKEND_IMAGE}:${VERSION}" -t "${BACKEND_IMAGE}:latest" ./backend
|
||||||
|
|
||||||
|
echo "==> Building ${FRONTEND_IMAGE}:${VERSION}"
|
||||||
|
podman build -t "${FRONTEND_IMAGE}:${VERSION}" -t "${FRONTEND_IMAGE}:latest" ./frontend
|
||||||
|
}
|
||||||
|
|
||||||
|
do_ship() {
|
||||||
|
echo "==> Shipping ${BACKEND_IMAGE}:${VERSION} over ssh"
|
||||||
|
podman save "${BACKEND_IMAGE}:${VERSION}" | ssh -i "$SSH_KEY" "${HOST_USER}@${HOST}" podman load
|
||||||
|
|
||||||
|
echo "==> Shipping ${FRONTEND_IMAGE}:${VERSION} over ssh"
|
||||||
|
podman save "${FRONTEND_IMAGE}:${VERSION}" | ssh -i "$SSH_KEY" "${HOST_USER}@${HOST}" podman load
|
||||||
|
}
|
||||||
|
|
||||||
|
do_swap() {
|
||||||
|
echo "==> Swapping backend-api container"
|
||||||
|
podman --connection "$CONNECTION" rm -f backend-api >/dev/null 2>&1 || true
|
||||||
|
podman --connection "$CONNECTION" run -d \
|
||||||
|
--name backend-api \
|
||||||
|
--restart always \
|
||||||
|
-p 8000:8000 \
|
||||||
|
"${BACKEND_IMAGE}:${VERSION}"
|
||||||
|
|
||||||
|
echo "==> Swapping frontend-vue container"
|
||||||
|
podman --connection "$CONNECTION" rm -f frontend-vue >/dev/null 2>&1 || true
|
||||||
|
podman --connection "$CONNECTION" run -d \
|
||||||
|
--name frontend-vue \
|
||||||
|
--restart always \
|
||||||
|
-p 8080:8080 \
|
||||||
|
"${FRONTEND_IMAGE}:${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_restart_caddy() {
|
||||||
|
echo "==> Copying Caddyfile to host"
|
||||||
|
scp -i "$SSH_KEY" ./imperva-proxy/Caddyfile "${HOST_USER}@${HOST}:${REMOTE_PROXY_DIR}/Caddyfile"
|
||||||
|
|
||||||
|
echo "==> Validating Caddyfile on host"
|
||||||
|
podman --connection "$CONNECTION" exec caddy-proxy caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
echo "==> Restarting caddy-proxy"
|
||||||
|
podman --connection "$CONNECTION" restart caddy-proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
check_connection() {
|
||||||
|
if ! podman system connection list --format '{{.Name}}' 2>/dev/null | grep -qx "$CONNECTION"; then
|
||||||
|
echo "error: podman connection '${CONNECTION}' is not configured." >&2
|
||||||
|
echo " podman system connection add ${CONNECTION} --identity ${SSH_KEY} ssh://${HOST_USER}@${HOST}:22/run/user/1001/podman/podman.sock" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- arg parsing ------------------------------------------------------
|
||||||
|
|
||||||
|
RUN_BUILD=false
|
||||||
|
RUN_SHIP=false
|
||||||
|
RUN_SWAP=false
|
||||||
|
RUN_RESTART_CADDY=false
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
RUN_BUILD=true
|
||||||
|
RUN_SHIP=true
|
||||||
|
RUN_SWAP=true
|
||||||
|
else
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--build) RUN_BUILD=true ;;
|
||||||
|
--ship) RUN_SHIP=true ;;
|
||||||
|
--swap) RUN_SWAP=true ;;
|
||||||
|
--restart-caddy) RUN_RESTART_CADDY=true ;;
|
||||||
|
*)
|
||||||
|
echo "unknown flag: $arg" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RUN_SHIP" = true ] || [ "$RUN_SWAP" = true ] || [ "$RUN_RESTART_CADDY" = true ]; then
|
||||||
|
check_connection
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$RUN_BUILD" = true ] && do_build
|
||||||
|
[ "$RUN_SHIP" = true ] && do_ship
|
||||||
|
[ "$RUN_SWAP" = true ] && do_swap
|
||||||
|
[ "$RUN_RESTART_CADDY" = true ] && do_restart_caddy
|
||||||
|
|
||||||
|
echo "==> Done (version: ${VERSION})"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Single GCP `e2-micro` running three rootless Podman containers under user `matt`.
|
||||||
|
Imperva Cloud WAF sits in front of everything. The origin is network-isolated so that
|
||||||
|
the only things that can reach the app are Imperva's edge nodes.
|
||||||
|
|
||||||
|
```
|
||||||
|
Visitor ──TLS──▶ Imperva Cloud WAF ──TLS (re-encrypt)──▶ Caddy :443 ──HTTP──▶ backend/frontend
|
||||||
|
(terminates, (self-signed cert)
|
||||||
|
inspects,
|
||||||
|
re-encrypts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## The two TLS legs (this is the crux of the design)
|
||||||
|
|
||||||
|
There are two independent TLS connections, and they use different certificates. Mixing
|
||||||
|
them up is the single easiest way to break this setup.
|
||||||
|
|
||||||
|
**Leg 1 — Visitor → Imperva (edge).**
|
||||||
|
Imperva presents a publicly-trusted certificate for `flytoouter.space` to browsers.
|
||||||
|
This is Imperva's *managed* cert (auto-provisioned by Imperva). It must be publicly
|
||||||
|
trusted or every visitor gets a browser warning. We do **not** touch this with our
|
||||||
|
self-signed cert.
|
||||||
|
|
||||||
|
**Leg 2 — Imperva → origin (re-encryption).**
|
||||||
|
Imperva opens a *second* TLS connection back to the GCP origin and inspects/re-encrypts
|
||||||
|
before forwarding. Caddy serves this leg on `:443` with a **self-signed certificate**.
|
||||||
|
Imperva is the only client that ever sees this cert, so self-signed is fine and
|
||||||
|
produces no visitor-facing warnings.
|
||||||
|
|
||||||
|
DNS for `flytoouter.space` must point at **Imperva**, not at `35.203.147.44`. If it
|
||||||
|
resolves straight to the origin, browsers try to negotiate TLS with the self-signed
|
||||||
|
origin cert and fail.
|
||||||
|
|
||||||
|
## Request flow through the origin
|
||||||
|
|
||||||
|
Caddy (`caddy-proxy`) is the only container bound to a public port. It terminates the
|
||||||
|
re-encryption TLS and reverse-proxies over plain HTTP to the app containers via the
|
||||||
|
`host.containers.internal` alias on the default rootless bridge network:
|
||||||
|
|
||||||
|
- `handle /api/*` → `host.containers.internal:8000` (FastAPI backend)
|
||||||
|
- `handle /*` → `host.containers.internal:8080` (Vue frontend)
|
||||||
|
|
||||||
|
`handle` (not `handle_path`) is used so the `/api` prefix is preserved for the backend
|
||||||
|
routes (`/api/v1/users`, etc.).
|
||||||
|
|
||||||
|
## Container stack
|
||||||
|
|
||||||
|
| Container | Image | Port | Role |
|
||||||
|
|----------------|------------------------------------|-------------|--------------------------------------------------|
|
||||||
|
| `caddy-proxy` | `docker.io/library/caddy:2-alpine` | `443:443` | TLS termination (re-encryption leg) + reverse proxy |
|
||||||
|
| `backend-api` | custom Python 3.11-slim (FastAPI) | `8000:8000` | REST API + local SQLite `demo.db` |
|
||||||
|
| `frontend-vue` | custom multi-stage (Node build → Nginx) | `8080:8080` | Vue SPA (flight booking UI) |
|
||||||
|
|
||||||
|
Caddy currently serves only `:443`. There is intentionally no `:80` site block (see
|
||||||
|
DECISIONS.md — a stray `:80` block under `auto_https off` caused the `ambiguous site
|
||||||
|
definition` failure). If Imperva needs an HTTP health check, an `:80` block can be
|
||||||
|
added back as a separate, non-conflicting listener.
|
||||||
|
|
||||||
|
## Network / edge security
|
||||||
|
|
||||||
|
- **Origin target tag:** `imperva-origin`.
|
||||||
|
- **`allow-imperva-waf`:** HTTP/HTTPS ingress restricted to Imperva's published IPv4
|
||||||
|
CIDR blocks (e.g. `199.83.128.0/21`, `198.143.32.0/19`, …). Confirm `tcp:443` is in
|
||||||
|
the allowed ports since the origin path is now 443-only.
|
||||||
|
- **`allow-home-ssh`:** SSH (22) locked to the operator's static residential IP.
|
||||||
|
- All default open (`0.0.0.0/0`) rules purged.
|
||||||
|
|
||||||
|
Because the origin only answers Imperva CIDRs, the deliberately-vulnerable endpoints
|
||||||
|
are unreachable directly. Every attack must pass through the WAF — which is exactly why
|
||||||
|
they register as blocked events in the portal.
|
||||||
|
|
||||||
|
## Host hardening (from original build)
|
||||||
|
|
||||||
|
- `/swapfile` mounted via `/etc/fstab` to mitigate OOM kills on the burstable e2-micro.
|
||||||
|
- `loginctl enable-linger matt` so rootless containers persist across reboots.
|
||||||
|
- `sysctl net.ipv4.ip_unprivileged_port_start=80` to let rootless Caddy bind low ports.
|
||||||
|
|
||||||
|
## Cert details (origin, Leg 2)
|
||||||
|
|
||||||
|
- Self-signed, RSA 2048, 3650-day validity.
|
||||||
|
- **SAN covers both** `DNS:flytoouter.space` and `IP:35.203.147.44`, so whichever way
|
||||||
|
Imperva addresses the backend, the cert matches.
|
||||||
|
- Files live on the host at `~/imperva-proxy/certs/{origin.crt,origin.key}` and are
|
||||||
|
bind-mounted read-only into Caddy at `/etc/caddy/certs/`.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Design Decisions & Rationale
|
||||||
|
|
||||||
|
An ADR-style log of the choices made in this project, *why* they were made, and what was
|
||||||
|
rejected. Several of these were reversed mid-stream — the reversals are kept here on
|
||||||
|
purpose so nobody re-walks a dead end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D1 — WAF terminates client TLS; origin does not do public HTTPS
|
||||||
|
|
||||||
|
**Decision:** Imperva handles all public-facing TLS. The origin never runs ACME /
|
||||||
|
Let's Encrypt and never serves a publicly-trusted cert.
|
||||||
|
|
||||||
|
**Why:** The whole point of the lab is that Imperva is the edge. Having the origin also
|
||||||
|
try to obtain public certs is redundant, and on this locked-down host ACME would fail
|
||||||
|
anyway (egress is restricted and ingress is Imperva-only). Keeping cert management at
|
||||||
|
the edge is simpler and matches the intended topology.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D2 — First attempt: plain-HTTP origin (REVERSED)
|
||||||
|
|
||||||
|
**Decision (later reversed):** Have Caddy serve only plain HTTP on `:80` and let
|
||||||
|
Imperva connect to the origin over HTTP; `auto_https off`.
|
||||||
|
|
||||||
|
**Why it was chosen:** The user's initial goal was "forward https/http traffic from the
|
||||||
|
WAF without Caddy doing SSL." The cleanest literal reading is: Imperva terminates TLS,
|
||||||
|
origin speaks plain HTTP, Caddy needs no cert at all.
|
||||||
|
|
||||||
|
**Why it was reversed:** The user then wanted a certificate they could upload to /
|
||||||
|
associate with Imperva (origin re-encryption). Plain HTTP has no cert to produce, so we
|
||||||
|
moved to a self-signed HTTPS origin (D5).
|
||||||
|
|
||||||
|
**Artifacts from this phase worth remembering:**
|
||||||
|
- `https://35.203.147.44/` returning `PR_CONNECT_RESET_ERROR` was *expected* here —
|
||||||
|
nothing was listening on 443. That reset is not a bug; it's the correct result of
|
||||||
|
hitting a port with no listener. Test HTTP origins with `curl -v http://localhost/`
|
||||||
|
from the host, and the real path via the hostname.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D3 — Single catch-all site block in the Caddyfile
|
||||||
|
|
||||||
|
**Decision:** Exactly one site block (`:443`, or `:80` in the HTTP phase). No separate
|
||||||
|
hostname block.
|
||||||
|
|
||||||
|
**Why:** With `auto_https off`, a hostname block like `flytoouter.space { }` no longer
|
||||||
|
routes to 443 automatically — it binds the same plain port as an explicit `:80`/`:443`
|
||||||
|
block. Two blocks fighting over one port produces:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: adapting config using caddyfile: ambiguous site definition: :80
|
||||||
|
```
|
||||||
|
|
||||||
|
This actually happened (a leftover domain block from the pre-WAF config remained in the
|
||||||
|
file). The container crash-looped, spamming the error hundreds of times — that volume is
|
||||||
|
just the restart policy retrying, not hundreds of distinct problems.
|
||||||
|
|
||||||
|
**Rejected:** keeping host-specific blocks. If host-based routing is ever wanted (e.g.
|
||||||
|
rejecting requests whose Host isn't `flytoouter.space` as origin hardening), it goes
|
||||||
|
*inside* the single block as a `@host` matcher, never as a second site block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D4 — `handle` not `handle_path` for `/api/*`
|
||||||
|
|
||||||
|
**Decision:** Route the API with `handle /api/* { reverse_proxy … }`.
|
||||||
|
|
||||||
|
**Why:** `handle_path` strips the matched prefix; `handle` preserves it. The backend's
|
||||||
|
routes are defined under `/api/v1/...`, so the prefix must survive the proxy hop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D5 — Self-signed cert on the origin, generated with OpenSSL (not Caddy internal CA)
|
||||||
|
|
||||||
|
**Decision:** Generate the origin cert with `openssl` (self-signed, RSA 2048, 3650
|
||||||
|
days, SAN = DNS + IP) and hand the files to Caddy via `tls <crt> <key>`.
|
||||||
|
|
||||||
|
**Why OpenSSL over Caddy's `tls internal`:** `tls internal` mints a cert via Caddy's
|
||||||
|
internal CA and buries it in `/data/caddy/pki/...`, which is awkward to export. The lab
|
||||||
|
needs the cert *as a file on disk* — to upload to / associate with Imperva and to have
|
||||||
|
explicit control over the SAN. OpenSSL gives that in one command.
|
||||||
|
|
||||||
|
**Why self-signed is acceptable here:** Only Imperva connects to the origin. Browsers
|
||||||
|
only ever see Imperva's edge cert (D1). So the self-signed cert causes zero
|
||||||
|
visitor-facing warnings. The critical rule: **the self-signed cert belongs only on the
|
||||||
|
re-encryption leg, never on the edge/visitor leg.**
|
||||||
|
|
||||||
|
**SAN includes both DNS and IP** so Imperva matches regardless of whether it addresses
|
||||||
|
the origin by hostname or by `35.203.147.44`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D6 — Debugging the "cert not found" start failure
|
||||||
|
|
||||||
|
**Symptom:** `caddy validate` reported
|
||||||
|
`open /etc/caddy/certs/origin.crt: no such file or directory`, and curl to `:443` reset.
|
||||||
|
|
||||||
|
**Root cause & rule:** The config was fine; the cert file wasn't visible *inside the
|
||||||
|
container*. Two common causes on rootless Podman:
|
||||||
|
1. **Tilde didn't expand** in `-v ~/imperva-proxy/certs:...`, creating an empty
|
||||||
|
anonymous mount. Fix: use absolute paths (`/home/matt/...`).
|
||||||
|
2. Stale `:Z` SELinux relabel state from an earlier container. Fix: recreate the
|
||||||
|
container.
|
||||||
|
|
||||||
|
The TLS reset was a *symptom* of Caddy refusing to start the HTTPS server, not an
|
||||||
|
independent TLS problem. Always read the `validate` / `podman logs` output first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D7 — Three OWASP attacks for the demo: SQLi, Reflected XSS, Path Traversal
|
||||||
|
|
||||||
|
**Decision:** Demo exactly these three.
|
||||||
|
|
||||||
|
**Why these three:** Imperva blocks all three with **default, out-of-the-box
|
||||||
|
signatures** (no custom rules needed), and each surfaces as a **different, clearly
|
||||||
|
labeled event** in the portal (SQL Injection / Cross-Site Scripting / Illegal Resource
|
||||||
|
Access). Three distinct classifications reads as comprehensive coverage rather than
|
||||||
|
three flavors of one thing.
|
||||||
|
|
||||||
|
**Why credential stuffing / rate-limiting was left OUT of the demo set:** It needs a
|
||||||
|
**custom rate policy** configured in Imperva, not a signature match. It doesn't produce
|
||||||
|
the clean one-payload-one-block moment the other three do. Kept as an optional "bonus"
|
||||||
|
for later if we want to showcase custom rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D8 — Deploy via `podman save | ssh podman load`, not a container registry
|
||||||
|
|
||||||
|
**Decision:** Build locally, pipe the image over the existing SSH connection into the
|
||||||
|
host's Podman, then swap the container via the `gcp-lab` connection alias.
|
||||||
|
|
||||||
|
**Why:** Zero new infrastructure. All the pieces already exist (SSH key, locked
|
||||||
|
ingress, remote Podman socket, connection alias). A registry (GCP Artifact Registry or
|
||||||
|
GHCR) would add credential management on both ends and, for Artifact Registry, IAM
|
||||||
|
wiring (service account + `artifactregistry.reader`) that's easy to get subtly wrong.
|
||||||
|
|
||||||
|
**Trade-off accepted:** `podman save` ships **every layer every time**, so each deploy
|
||||||
|
moves the full image even for a one-line change (~20–40s on this lab). The registry's
|
||||||
|
layer-caching win only pays off at high deploy frequency, which a demo lab isn't.
|
||||||
|
|
||||||
|
**Upgrade path:** If this ever becomes team-touched or high-frequency, migrating to
|
||||||
|
Artifact Registry is clean — only the *ship* step changes (`push`/`pull` instead of
|
||||||
|
`save | ssh load`); the rest of `deploy.sh` is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D9 — Build locally, never on the host
|
||||||
|
|
||||||
|
**Decision:** All image builds happen on the local workstation.
|
||||||
|
|
||||||
|
**Why:** The `e2-micro` (shared-core, burstable) doesn't have the memory to run the Vue
|
||||||
|
multi-stage build (npm install + build) reliably, even with the swapfile. Building
|
||||||
|
locally and shipping the finished image sidesteps OOM kills entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D10 — Frontend screens map 1:1 to attacks
|
||||||
|
|
||||||
|
**Decision:** Give the flight-booking SPA four screens, each a natural home for one
|
||||||
|
attack, so the demo feels like poking a real product rather than hitting raw endpoints:
|
||||||
|
|
||||||
|
- Passenger/admin lookup → SQLi (`/api/v1/users?q=`)
|
||||||
|
- Flight search results → Reflected XSS (`/api/v1/search?term=`)
|
||||||
|
- Boarding-pass download → Path Traversal (`/api/v1/download?file=`)
|
||||||
|
- Login → existing endpoint, kept as the credential-stuffing bonus
|
||||||
|
|
||||||
|
**Why:** A believable UI makes the "exploitable without the WAF, blocked with it"
|
||||||
|
narrative land better in a live demo than curling endpoints.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Demo Runbook
|
||||||
|
|
||||||
|
> **Status: BUILT, not yet deployed to the GCP host.** All four screens and three
|
||||||
|
> attack endpoints exist in `backend/` and `frontend/` and were functionally verified
|
||||||
|
> locally (backend via `uvicorn` + `curl`, frontend via `vite dev` + a browser, talking
|
||||||
|
> to each other through the same `/api/*` path Caddy proxies in prod — including
|
||||||
|
> confirming the XSS payload actually executes and the traversal payload actually reads
|
||||||
|
> a file outside `boarding_passes/`). Not yet shipped with `./deploy.sh` — that must run
|
||||||
|
> from a real host terminal, not an AI coding-agent sandbox (see CLAUDE.md).
|
||||||
|
|
||||||
|
Goal of the demo: type an attack into a believable flight-booking UI, watch Imperva
|
||||||
|
block it, then show the corresponding event in the Imperva portal. Three attacks, three
|
||||||
|
*distinct* portal classifications.
|
||||||
|
|
||||||
|
## Attack matrix
|
||||||
|
|
||||||
|
| # | Attack | Endpoint | Sample payload | Expected Imperva portal classification |
|
||||||
|
|---|-----------------|-----------------------------|------------------------------------|------------------------------------------------|
|
||||||
|
| 1 | SQL Injection | `/api/v1/users?q=` | `' OR '1'='1` | SQL Injection |
|
||||||
|
| 2 | Reflected XSS | `/api/v1/search?term=` | `<script>alert(1)</script>` | Cross-Site Scripting (XSS) |
|
||||||
|
| 3 | Path Traversal | `/api/v1/download?file=` | `../../../../etc/passwd` | Illegal Resource Access / Directory Traversal |
|
||||||
|
| — | (bonus) Cred stuffing | `/api/v1/login` | repeated login attempts | Requires a **custom rate policy**, not a signature — not in core demo |
|
||||||
|
|
||||||
|
Attacks 1–3 are blocked by Imperva's **default signatures** — no custom rules needed.
|
||||||
|
The bonus is intentionally excluded from the core demo because it needs a rate policy
|
||||||
|
configured in Imperva and doesn't produce a clean single-payload block (see
|
||||||
|
DECISIONS.md D7).
|
||||||
|
|
||||||
|
## Frontend screen → attack mapping
|
||||||
|
|
||||||
|
Each attack has a natural home in the UI so the demo looks like poking a real product:
|
||||||
|
|
||||||
|
- **Passenger / admin lookup** → SQLi search box (`users?q=`)
|
||||||
|
- **Flight search results** → XSS search field (`search?term=`)
|
||||||
|
- **Boarding-pass download** → path-traversal file param (`download?file=`)
|
||||||
|
- **Login** → existing endpoint, kept as the credential-stuffing bonus
|
||||||
|
|
||||||
|
## Demo flow (target)
|
||||||
|
|
||||||
|
1. Show the app working normally (a legitimate search, a legitimate lookup).
|
||||||
|
2. For each of the three attacks: enter the payload in its screen → show the request is
|
||||||
|
blocked by Imperva (block page / rejected response).
|
||||||
|
3. Switch to the Imperva portal security dashboard → show the three distinct event
|
||||||
|
types logged, one per attack.
|
||||||
|
4. Reinforce the isolation point: the origin only accepts Imperva CIDRs, so these
|
||||||
|
attacks *cannot* reach the app except through the WAF — which is why every one of
|
||||||
|
them appears in the portal.
|
||||||
|
|
||||||
|
## Pre-demo checklist
|
||||||
|
|
||||||
|
- `https://flytoouter.space/` loads with Imperva's trusted edge cert.
|
||||||
|
- All three endpoints reachable *through* Imperva (legit requests return 200).
|
||||||
|
- Origin firewall confirmed Imperva-CIDR-only (`allow-imperva-waf`, `tcp:443`).
|
||||||
|
- DNS for `flytoouter.space` points at Imperva, not the raw origin IP.
|
||||||
|
- Portal logged-in and on the security events view, filtered to the demo site.
|
||||||
|
|
||||||
|
## Notes on portal specifics
|
||||||
|
|
||||||
|
Exact event labels and menu paths vary by Imperva account/version. Confirm the live
|
||||||
|
labels in your portal before presenting and update the classification column above to
|
||||||
|
match what your account actually shows.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Deploy Workflow
|
||||||
|
|
||||||
|
Local code → finished image shipped over SSH → container swapped on the host. No
|
||||||
|
container registry (see DECISIONS.md D8 for why).
|
||||||
|
|
||||||
|
## The two channels to the host
|
||||||
|
|
||||||
|
Both go to the same GCP box; they're used for different steps:
|
||||||
|
|
||||||
|
- **`podman save … | ssh … podman load`** — ships the image *bytes*. This has to use
|
||||||
|
raw `ssh` because it's a pipe into the remote Podman CLI.
|
||||||
|
- **`podman --connection gcp-lab …`** — swaps the running container over the existing
|
||||||
|
rootless socket, hands-free (no manual SSH). The alias is already configured and set
|
||||||
|
as default:
|
||||||
|
`ssh://matt@35.203.147.44:22/run/user/1001/podman/podman.sock`.
|
||||||
|
|
||||||
|
> The socket is under **UID 1001** on this host (the original architecture doc's 1000 is
|
||||||
|
> wrong). Using the alias means you never have to think about the UID.
|
||||||
|
|
||||||
|
## Why build locally
|
||||||
|
|
||||||
|
The `e2-micro` can't build the Vue multi-stage image reliably (memory). Always build on
|
||||||
|
the local workstation and ship the finished image. Never build on the host.
|
||||||
|
|
||||||
|
## `deploy.sh`
|
||||||
|
|
||||||
|
Lives at the repo root and is run from there (so the relative `./backend` and
|
||||||
|
`./frontend` build contexts resolve). Config block at the top holds host/user/key/alias.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh # full cycle: build both, ship both, swap both containers
|
||||||
|
VERSION=v2 ./deploy.sh # tag images v2 instead of a timestamp → named rollback point
|
||||||
|
./deploy.sh --build --ship --swap # explicit equivalent of the default
|
||||||
|
./deploy.sh --restart-caddy # after editing the Caddyfile to add/modify routes
|
||||||
|
```
|
||||||
|
|
||||||
|
With no arguments it runs `build → ship → swap`. `VERSION` defaults to a timestamp; set
|
||||||
|
it explicitly to create a named point you can redeploy later.
|
||||||
|
|
||||||
|
## Steps the script performs
|
||||||
|
|
||||||
|
1. **build** — `podman build` the backend and frontend images locally.
|
||||||
|
2. **ship** — `podman save <img> | ssh -i <key> matt@35.203.147.44 'podman load'` for
|
||||||
|
each image.
|
||||||
|
3. **swap** — over `--connection gcp-lab`: `rm -f` then `run -d` each container with its
|
||||||
|
name and port mapping.
|
||||||
|
4. **restart-caddy** (on demand) — `podman --connection gcp-lab restart caddy-proxy`,
|
||||||
|
needed only when the Caddyfile changed (e.g. new XSS/traversal routes).
|
||||||
|
|
||||||
|
## IMPORTANT: keep `swap()` in sync with real run flags
|
||||||
|
|
||||||
|
The current `swap()` recreates containers with only `--name` and `-p`. It does **not**
|
||||||
|
yet reproduce any bind mounts (e.g. the SQLite `demo.db`), config-file mounts, or
|
||||||
|
network/DNS-alias flags the real containers depend on. **Before relying on the script,
|
||||||
|
confirm the actual `podman run` flags** the current `backend-api` and `frontend-vue`
|
||||||
|
were created with and fold them into `swap()`, or a deploy will silently drop them.
|
||||||
|
|
||||||
|
To inspect what the running containers actually use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman --connection gcp-lab inspect backend-api | jq '.[0].Mounts, .[0].NetworkSettings'
|
||||||
|
podman --connection gcp-lab inspect frontend-vue | jq '.[0].Mounts, .[0].NetworkSettings'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caddy config / cert deploys
|
||||||
|
|
||||||
|
The Caddyfile and certs live on the host at `~/imperva-proxy/` and are bind-mounted
|
||||||
|
read-only into `caddy-proxy`. To change routing:
|
||||||
|
|
||||||
|
1. Edit `imperva-proxy/Caddyfile` in the repo, copy it to the host `~/imperva-proxy/`.
|
||||||
|
2. Validate before restarting so you're not debugging from a crash loop:
|
||||||
|
```bash
|
||||||
|
podman --connection gcp-lab exec caddy-proxy caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
3. `./deploy.sh --restart-caddy`.
|
||||||
|
|
||||||
|
If the container is already crash-looping and `exec` won't attach, validate against the
|
||||||
|
mount with a throwaway container instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --rm -v /home/matt/imperva-proxy/Caddyfile:/etc/caddy/Caddyfile:ro,Z \
|
||||||
|
docker.io/library/caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifying a deploy
|
||||||
|
|
||||||
|
- Origin health from the host (self-signed, so `-k`):
|
||||||
|
```bash
|
||||||
|
podman --connection gcp-lab exec caddy-proxy sh -c 'wget -qO- --no-check-certificate https://localhost/ | head'
|
||||||
|
```
|
||||||
|
(or `curl -vk https://localhost/` directly on the host)
|
||||||
|
- Real path end to end: load `https://flytoouter.space/` in a browser — should show
|
||||||
|
Imperva's trusted edge cert while the origin serves self-signed to Imperva only.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Stage 1: Build the Vue App
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve static files with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Outer Space Flights</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name localhost;
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1242
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "imperva-demo-frontend",
|
||||||
|
"scripts": { "dev": "vite", "build": "vite build" },
|
||||||
|
"dependencies": { "vue": "^3.4.0" },
|
||||||
|
"devDependencies": { "vite": "^5.2.0", "@vitejs/plugin-vue": "^5.0.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, shallowRef } from 'vue'
|
||||||
|
import FlightSearch from './views/FlightSearch.vue'
|
||||||
|
import PassengerLookup from './views/PassengerLookup.vue'
|
||||||
|
import BoardingPassDownload from './views/BoardingPassDownload.vue'
|
||||||
|
import LoginView from './views/LoginView.vue'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'flights', label: 'Flight Search', component: FlightSearch },
|
||||||
|
{ key: 'passengers', label: 'Passenger Lookup', component: PassengerLookup },
|
||||||
|
{ key: 'boarding', label: 'Boarding Pass', component: BoardingPassDownload },
|
||||||
|
{ key: 'login', label: 'Login', component: LoginView },
|
||||||
|
]
|
||||||
|
|
||||||
|
const active = ref('flights')
|
||||||
|
const activeComponent = shallowRef(tabs[0].component)
|
||||||
|
|
||||||
|
function select(tab) {
|
||||||
|
active.value = tab.key
|
||||||
|
activeComponent.value = tab.component
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">Outer Space Flights</div>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:class="{ active: active === tab.key }"
|
||||||
|
@click="select(tab)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<component :is="activeComponent" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const BASE = '/api/v1'
|
||||||
|
|
||||||
|
async function request(path) {
|
||||||
|
const res = await fetch(`${BASE}${path}`)
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
return { ok: res.ok, status: res.status, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchFlights(term) {
|
||||||
|
return request(`/search?term=${encodeURIComponent(term)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchUsers(q) {
|
||||||
|
return request(`/users?q=${encodeURIComponent(q)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBoardingPass(file) {
|
||||||
|
const res = await fetch(`${BASE}/download?file=${encodeURIComponent(file)}`)
|
||||||
|
const text = await res.text()
|
||||||
|
return { ok: res.ok, status: res.status, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username, password) {
|
||||||
|
const res = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
return { ok: res.ok, status: res.status, body }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--panel: #131a2e;
|
||||||
|
--border: #232c47;
|
||||||
|
--text: #e6e9f2;
|
||||||
|
--muted: #8a92b2;
|
||||||
|
--accent: #5b8cff;
|
||||||
|
--accent-2: #7c5bff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(160deg, var(--bg), #050810);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app { min-height: 100vh; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tabs button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tabs button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
flex: 1;
|
||||||
|
background: #0d1326;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
.echo {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
|
pre.result {
|
||||||
|
background: #0d1326;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error { color: #ff6b6b; }
|
||||||
|
.status.ok { color: #63e6be; }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { downloadBoardingPass } from '../api.js'
|
||||||
|
|
||||||
|
const file = ref('BP-1001.txt')
|
||||||
|
const content = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function runDownload() {
|
||||||
|
error.value = ''
|
||||||
|
content.value = ''
|
||||||
|
const { ok, status, text } = await downloadBoardingPass(file.value)
|
||||||
|
if (!ok) {
|
||||||
|
error.value = `Request failed (${status})`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.value = text
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Boarding Pass Download</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="file"
|
||||||
|
placeholder="Boarding pass filename (e.g. BP-1001.txt)"
|
||||||
|
@keyup.enter="runDownload"
|
||||||
|
/>
|
||||||
|
<button class="primary" @click="runDownload">Download</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status error" v-if="error">{{ error }}</p>
|
||||||
|
<pre class="result" v-if="content">{{ content }}</pre>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { searchFlights } from '../api.js'
|
||||||
|
|
||||||
|
const term = ref('')
|
||||||
|
const queryEcho = ref('')
|
||||||
|
const results = ref([])
|
||||||
|
const searched = ref(false)
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const { body } = await searchFlights(term.value)
|
||||||
|
queryEcho.value = body?.query_echo ?? ''
|
||||||
|
results.value = body?.results ?? []
|
||||||
|
searched.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Flight Search</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="term"
|
||||||
|
placeholder="Search destinations (e.g. Mars)"
|
||||||
|
@keyup.enter="runSearch"
|
||||||
|
/>
|
||||||
|
<button class="primary" @click="runSearch">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intentionally unescaped: the API echoes the raw search term back
|
||||||
|
verbatim and this renders it as HTML, not text. -->
|
||||||
|
<p class="echo" v-if="searched" v-html="queryEcho"></p>
|
||||||
|
|
||||||
|
<table v-if="results.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Flight</th>
|
||||||
|
<th>Origin</th>
|
||||||
|
<th>Destination</th>
|
||||||
|
<th>Departs</th>
|
||||||
|
<th>Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in results" :key="f.id">
|
||||||
|
<td>{{ f.id }}</td>
|
||||||
|
<td>{{ f.origin }}</td>
|
||||||
|
<td>{{ f.destination }}</td>
|
||||||
|
<td>{{ f.depart }}</td>
|
||||||
|
<td>${{ f.price.toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { login } from '../api.js'
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
async function runLogin() {
|
||||||
|
const { body } = await login(username.value, password.value)
|
||||||
|
success.value = body?.status === 'authenticated'
|
||||||
|
message.value = success.value
|
||||||
|
? `Authenticated. Token: ${body.token}`
|
||||||
|
: (body?.message ?? 'Login failed')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="text" v-model="username" placeholder="Username" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="password" v-model="password" placeholder="Password" @keyup.enter="runLogin" />
|
||||||
|
<button class="primary" @click="runLogin">Log in</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status" :class="success ? 'ok' : 'error'" v-if="message">{{ message }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { searchUsers } from '../api.js'
|
||||||
|
|
||||||
|
const q = ref('')
|
||||||
|
const results = ref([])
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
error.value = ''
|
||||||
|
const { ok, body } = await searchUsers(q.value)
|
||||||
|
if (!ok) {
|
||||||
|
error.value = body?.detail ?? 'Search failed'
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results.value = body ?? []
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Passenger / Admin Lookup</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="q"
|
||||||
|
placeholder="Search by username"
|
||||||
|
@keyup.enter="runSearch"
|
||||||
|
/>
|
||||||
|
<button class="primary" @click="runSearch">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status error" v-if="error">{{ error }}</p>
|
||||||
|
|
||||||
|
<table v-if="results.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in results" :key="u.id">
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.email }}</td>
|
||||||
|
<td>{{ u.role }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// Mirrors the Caddy `/api/*` -> backend route used in prod, so the
|
||||||
|
// frontend talks to the API the same way (same-origin) in dev and prod.
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
tls /etc/caddy/certs/origin.crt /etc/caddy/certs/origin.key
|
||||||
|
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy host.containers.internal:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy host.containers.internal:8080
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDOjCCAiKgAwIBAgIUXT8uwyaJMEENfQOgs6SsXZ4W+C0wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwGzEZMBcGA1UEAwwQZmx5dG9vdXRlci5zcGFjZTAeFw0yNjA3MjgyMDAyNTRa
|
||||||
|
Fw0zNjA3MjUyMDAyNTRaMBsxGTAXBgNVBAMMEGZseXRvb3V0ZXIuc3BhY2UwggEi
|
||||||
|
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3gl823XO7+Qx09H0rHoocm/YE
|
||||||
|
NeUYXFtWLevhptFYOpoyU+EafsogrHMJO9dy0Tnt/sA6dv+Td7oF4BxrJykbr6Fh
|
||||||
|
+xGonXGck/vTh7RE4eNClmQBZ7jJrMN0BhCtw+gvmUVgzNAz2ItqoCs37fX2EC6+
|
||||||
|
AcApAbWIAnNmNoKa7Sc6CL+uGm6I3eW769J72rDxhm27ZUtFZZTxF8jhc64kR6TM
|
||||||
|
TDSAMLt2axlUHsYZ4GdLjsJ47rb461Cn9smUX4SFD74qSNmYDPzZbBgvmi+/UkC7
|
||||||
|
lFsT+6FwYP/wc3eRVjVhb6j1mjG/UiVwYY59eUoKbN1Ebl/XpvJHs4skxqwLAgMB
|
||||||
|
AAGjdjB0MB0GA1UdDgQWBBTEJ04zRuHst5AwwW32lpBe43YONzAfBgNVHSMEGDAW
|
||||||
|
gBTEJ04zRuHst5AwwW32lpBe43YONzAPBgNVHRMBAf8EBTADAQH/MCEGA1UdEQQa
|
||||||
|
MBiCEGZseXRvb3V0ZXIuc3BhY2WHBCPLkywwDQYJKoZIhvcNAQELBQADggEBAGAH
|
||||||
|
EkzsBrSa30386HeOLChmbgIeCIDQLtfwPSgMtTunWTHG5fsDWIMoOOYEWT/dZdwj
|
||||||
|
8pw7FbvkKYDbnU8QufP3v+qyfvgsvAlQ9Is5+E9eTm0mmBi/zQBBZCe+vzF1EfMp
|
||||||
|
xHcvsuVwoDgaQ39Tk6rzERjpr2oIhf6UL4IFc7xP8/vmyUaIzFffxuFXMuheqGnv
|
||||||
|
uDqV+HxsJbaQnbqfRGxHmEoXNz4YhuUvT1qFgh0aeB/PDgr1T7gGvP/nrfJ0gA6D
|
||||||
|
Tvk7xnjWCeo+aKvHO6ZNqCrLqJbi5MgitdEQ5GYX4PGRH4xay9fpGlwP47oqS5Gn
|
||||||
|
XxFeG/YP/3uKDgjBOSE=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
Reference in new issue
Block a user