Align Sinbad2 HTTPS deployment with orcid2sword reverse-proxy pattern.

This adds nginx dual-path routing, forwarded proxy headers, Uvicorn proxy-headers, production security settings, and deployment docs for https://sinbad2.ujaen.es/generadorexamenesllm/.
This commit is contained in:
Mireya Cueto Garrido
2026-06-03 10:12:05 +02:00
parent ca6d370585
commit 7dcc7dc0e1
13 changed files with 230 additions and 31 deletions
+3
View File
@@ -46,6 +46,9 @@ deploy_to_sinbad2:
export BACKEND_PORT=$BACKEND_PORT
export FRONTEND_PORT=$FRONTEND_PORT
export ENVIRONMENT=production
export PUBLIC_BASE_URL="https://sinbad2.ujaen.es/generadorexamenesllm"
export TRUSTED_HOSTS="sinbad2.ujaen.es,localhost,127.0.0.1"
export ALLOWED_ORIGINS="https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:$FRONTEND_PORT"
export VITE_APP_BASE_PATH="/generadorexamenesllm/"
export VITE_API_URL=""
+11 -2
View File
@@ -1,6 +1,15 @@
# --- Aplicación ---
APP_NAME=GenExamenes IA
ENVIRONMENT=local
ENVIRONMENT=production
# URL pública HTTPS (Apache termina TLS; contenedores en HTTP interno)
PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm
# Hosts aceptados por TrustedHostMiddleware (sin esquema ni puerto)
TRUSTED_HOSTS=sinbad2.ujaen.es,localhost,127.0.0.1
# HSTS (segundos; 1 año por defecto)
SECURITY_HSTS_SECONDS=31536000
# Clave legacy (reservada; las rutas /exam usan JWT de usuario).
API_KEY=change-me-in-production-min-16-chars
@@ -8,7 +17,7 @@ API_KEY=change-me-in-production-min-16-chars
# --- Base de datos (Docker: host "db") ---
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# --- CORS (orígenes del frontend, separados por coma) ---
# --- CORS (orígenes HTTPS del frontend; separados por coma) ---
ALLOWED_ORIGINS=https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075
# --- Rate limiting y tamaño de petición ---
+6 -1
View File
@@ -25,4 +25,9 @@ USER app
EXPOSE 8074
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8074"]
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8074", \
"--proxy-headers", \
"--forwarded-allow-ips", "*", \
"--no-server-header"]
+11
View File
@@ -7,6 +7,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "GenExamenes IA"
environment: str = "local"
public_base_url: str = "https://sinbad2.ujaen.es/generadorexamenesllm"
trusted_hosts: str = "sinbad2.ujaen.es,localhost,127.0.0.1"
security_hsts_seconds: int = Field(default=31_536_000, ge=0)
api_prefix: str = ""
api_key: str = Field(min_length=16)
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
@@ -41,10 +44,18 @@ class Settings(BaseSettings):
extra="ignore",
)
@property
def is_production(self) -> bool:
return self.environment.lower() in {"production", "prod"}
@property
def cors_origins(self) -> list[str]:
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
@property
def trusted_hosts_list(self) -> list[str]:
return [host.strip() for host in self.trusted_hosts.split(",") if host.strip()]
@lru_cache
def get_settings() -> Settings:
+30
View File
@@ -0,0 +1,30 @@
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.core.config import Settings
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Cabeceras de seguridad; HSTS en producción o cuando el proxy indica HTTPS."""
def __init__(self, app: object, settings: Settings) -> None:
super().__init__(app)
self._settings = settings
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
is_https = forwarded_proto == "https" or request.url.scheme == "https"
if is_https or self._settings.is_production:
hsts = f"max-age={self._settings.security_hsts_seconds}"
if self._settings.is_production:
hsts += "; includeSubDomains"
response.headers.setdefault("Strict-Transport-Security", hsts)
return response
+6 -1
View File
@@ -1,13 +1,15 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from app.api.routes import auth, exports, generation, health, history, images, materials, questions, templates
from app.core.config import get_settings
from app.core.errors import register_exception_handlers
from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware
from app.core.security_headers import SecurityHeadersMiddleware
from app.db.init_db import init_db
@@ -25,6 +27,9 @@ def create_app() -> FastAPI:
# las peticiones OPTIONS (preflight) respondan antes que rate limit, etc.
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
app.add_middleware(RateLimitMiddleware, settings=settings)
app.add_middleware(SecurityHeadersMiddleware, settings=settings)
if settings.trusted_hosts_list:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts_list)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
+74
View File
@@ -0,0 +1,74 @@
# Despliegue en Sinbad2 (HTTPS vía Apache)
Patrón equivalente a **orcid2sword**: TLS en Apache, contenedores en HTTP en puertos internos.
## URL pública
`https://sinbad2.ujaen.es/generadorexamenesllm/`
No usar `/deckofcars/` ni `/deckofcards/` (son otros proyectos).
## 1. GitLab CI (este repositorio)
El job `deploy_to_sinbad2` en `.gitlab-ci.yml`:
1. Copia el código por SSH a Sinbad2.
2. Ejecuta `docker compose build` y `docker compose up -d`.
3. Expone servicios en **HTTP** (sin certificados en Docker):
- Frontend → `:8075`
- Backend → `:8074`
Variables de despliegue:
| Variable | Valor |
|----------|--------|
| `VITE_APP_BASE_PATH` | `/generadorexamenesllm/` |
| `VITE_API_URL` | *(vacío: misma base HTTPS vía nginx)* |
| `ENVIRONMENT` | `production` |
| `PUBLIC_BASE_URL` | `https://sinbad2.ujaen.es/generadorexamenesllm` |
| `ALLOWED_ORIGINS` | `https://sinbad2.ujaen.es,...` |
## 2. Apache (gestión UJA — no está en este repo)
Fragmento mínimo (`deploy/apache-reverse-proxy.conf`):
```apache
ProxyPass /generadorexamenesllm http://host.docker.internal:8075/
ProxyPassReverse /generadorexamenesllm http://host.docker.internal:8075/
```
| Paso | Qué ocurre |
|------|------------|
| Certificado SSL | Lo proporciona el servidor institucional |
| Entrada pública | `https://sinbad2.ujaen.es/generadorexamenesllm/` |
| Proxy | Apache reenvía a `:8075` (HTTP) y **quita** el prefijo |
| Nginx contenedor | Sirve SPA y hace proxy de `/auth/` y `/exam/` al backend |
Si falta el `ProxyPass`, la ruta la atiende el CMS de Sinbad2 (home del grupo).
## 3. Adaptaciones en la aplicación
### Frontend
- Build con `VITE_APP_BASE_PATH=/generadorexamenesllm/`.
- `nginx.conf` entiende rutas **con prefijo** (acceso directo `:8075`) y **sin prefijo** (tras Apache).
- Proxy interno al backend; cabeceras `X-Forwarded-Proto` / `Host` propagadas desde Apache.
### Backend
- Uvicorn con `--proxy-headers` y `--forwarded-allow-ips *`.
- `TrustedHostMiddleware` + cabeceras HSTS en producción.
- CORS con origen `https://sinbad2.ujaen.es`.
## 4. Comprobaciones
```bash
# Directo al contenedor (HTTP, con prefijo)
curl -I http://sinbad2.ujaen.es:8075/generadorexamenesllm/
# Tras Apache (HTTPS)
curl -I https://sinbad2.ujaen.es/generadorexamenesllm/
# API vía nginx del frontend
curl https://sinbad2.ujaen.es/generadorexamenesllm/health
```
+9 -3
View File
@@ -1,7 +1,13 @@
# Fragmento para el VirtualHost de sinbad2.ujaen.es (HTTPS).
# Sin esto, /generadorexamenesllm/ lo atiende el CMS de Sinbad2 y no la app Docker.
# Apache en sinbad2.ujaen.es (VirtualHost HTTPS) — gestión UJA.
# Sin estas líneas, /generadorexamenesllm/ lo sirve el CMS de Sinbad2, no la app Docker.
#
# URL pública: https://sinbad2.ujaen.es/generadorexamenesllm/
# Contenedor frontend (HTTP): host.docker.internal:8075
# Contenedor backend (HTTP, solo interno): :8074
ProxyPass /generadorexamenesllm http://host.docker.internal:8075/
ProxyPassReverse /generadorexamenesllm http://host.docker.internal:8075/
# URL pública: https://sinbad2.ujaen.es/generadorexamenesllm/
# Opcional: reenviar cabeceras de proxy (Apache 2.4+)
# RequestHeader set X-Forwarded-Proto "https"
# RequestHeader set X-Forwarded-Host "sinbad2.ujaen.es"
+3
View File
@@ -6,6 +6,9 @@ services:
- ./backend/.env
environment:
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
ENVIRONMENT: ${ENVIRONMENT:-production}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://sinbad2.ujaen.es/generadorexamenesllm}
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-sinbad2.ujaen.es,localhost,127.0.0.1}
# Sobrescribe backend/.env con el origen público del frontend en despliegue.
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075}
LLM_BASE_URL:
+1
View File
@@ -17,6 +17,7 @@ RUN npm run build
# --- Serve stage ---
FROM nginx:1.27-alpine AS serve
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY proxy_params.conf /etc/nginx/snippets/proxy_params.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+14 -5
View File
@@ -64,13 +64,22 @@ docker compose up --build
Las variables `VITE_APP_BASE_PATH`, `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
variables de entorno al ejecutar `docker compose`.
## Despliegue HTTPS en subruta (Sinbad2)
## Despliegue HTTPS en Sinbad2 (patrón orcid2sword)
La app **no** va bajo `/deckofcars/` (eso es otro sitio). La URL pública es:
Documentación completa: [`deploy/DESPLIEGUE_SINBAD2.md`](../deploy/DESPLIEGUE_SINBAD2.md)
`https://sinbad2.ujaen.es/generadorexamenesllm/`
Resumen:
En Apache del servidor deben existir estas líneas (el prefijo se quita al llegar al contenedor en el puerto 8075):
| Capa | Responsabilidad |
|------|-----------------|
| Apache (UJA) | Certificado SSL, `ProxyPass` a `:8075` |
| GitLab CI | `docker compose up`, build con base path |
| Nginx contenedor | SPA + proxy `/auth/` y `/exam/` al backend |
| Backend Uvicorn | `--proxy-headers`, HSTS, CORS HTTPS |
URL pública: **`https://sinbad2.ujaen.es/generadorexamenesllm/`**
Apache (fragmento):
```apache
ProxyPass /generadorexamenesllm http://host.docker.internal:8075/
@@ -84,7 +93,7 @@ VITE_APP_BASE_PATH=/generadorexamenesllm/
VITE_API_URL=
```
Si Apache no tiene ese `ProxyPass`, el navegador verá la web principal de Sinbad2 en lugar de esta app.
El navegador habla solo con HTTPS (Apache → nginx → backend); no hay mixed content ni CORS cruzado entre puertos.
## Manejo de errores
+55 -19
View File
@@ -1,46 +1,82 @@
# Nginx del contenedor frontend (HTTP interno, puerto 80 → publicado en 8075).
#
# Flujo HTTPS (igual que orcid2sword en Sinbad2):
# 1. Usuario → https://sinbad2.ujaen.es/generadorexamenesllm/
# 2. Apache termina TLS y hace ProxyPass al puerto 8075 (HTTP).
# 3. Con ProxyPass ... http://host:8075/ Apache QUITA el prefijo /generadorexamenesllm
# y el contenedor recibe /, /assets/, /auth/, etc.
# 4. Acceso directo al puerto 8075 (sin Apache) usa el prefijo /generadorexamenesllm/
# porque el build de Vite lleva VITE_APP_BASE_PATH=/generadorexamenesllm/
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_host $forwarded_host {
default $http_x_forwarded_host;
"" $host;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Backend API dentro de la misma base HTTPS (evita mixed content).
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# --- API: rutas sin prefijo (Apache quita /generadorexamenesllm) ---
location /auth/ {
proxy_pass http://backend:8074/auth/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
include /etc/nginx/snippets/proxy_params.conf;
}
location /exam/ {
proxy_pass http://backend:8074/exam/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
include /etc/nginx/snippets/proxy_params.conf;
}
location = /health {
proxy_pass http://backend:8074/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
include /etc/nginx/snippets/proxy_params.conf;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA: cualquier ruta desconocida sirve index.html (React Router).
location / {
try_files $uri $uri/ /index.html;
}
# Cache de assets con hash.
location /assets/ {
# --- Mismo contenido/API bajo prefijo público (acceso directo :8075 o si Apache no quita prefijo) ---
location ^~ /generadorexamenesllm/auth/ {
proxy_pass http://backend:8074/auth/;
include /etc/nginx/snippets/proxy_params.conf;
}
location ^~ /generadorexamenesllm/exam/ {
proxy_pass http://backend:8074/exam/;
include /etc/nginx/snippets/proxy_params.conf;
}
location = /generadorexamenesllm/health {
proxy_pass http://backend:8074/health;
include /etc/nginx/snippets/proxy_params.conf;
}
location ^~ /generadorexamenesllm/assets/ {
alias /usr/share/nginx/html/assets/;
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
location ^~ /generadorexamenesllm/ {
try_files $uri $uri/ /index.html;
}
}
+7
View File
@@ -0,0 +1,7 @@
proxy_http_version 1.1;
proxy_set_header Host $forwarded_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-Host $forwarded_host;
proxy_redirect off;