From 7dcc7dc0e1f6c5392847fecd06ce19e2e20019ae Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Wed, 3 Jun 2026 10:12:05 +0200 Subject: [PATCH] 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/. --- .gitlab-ci.yml | 3 ++ backend/.env.example | 13 ++++- backend/Dockerfile | 7 ++- backend/app/core/config.py | 11 +++++ backend/app/core/security_headers.py | 30 +++++++++++ backend/app/main.py | 7 ++- deploy/DESPLIEGUE_SINBAD2.md | 74 ++++++++++++++++++++++++++++ deploy/apache-reverse-proxy.conf | 12 +++-- docker-compose.yml | 3 ++ frontend/Dockerfile | 1 + frontend/README.md | 19 +++++-- frontend/nginx.conf | 74 +++++++++++++++++++++------- frontend/proxy_params.conf | 7 +++ 13 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 backend/app/core/security_headers.py create mode 100644 deploy/DESPLIEGUE_SINBAD2.md create mode 100644 frontend/proxy_params.conf diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 14e5147..4b2140f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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="" diff --git a/backend/.env.example b/backend/.env.example index 00357a5..ac4821d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 --- diff --git a/backend/Dockerfile b/backend/Dockerfile index 73ce560..21e75c0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1e869d5..1eeb633 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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: diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py new file mode 100644 index 0000000..1702da1 --- /dev/null +++ b/backend/app/core/security_headers.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index c1e55bc..ab3cd3c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/deploy/DESPLIEGUE_SINBAD2.md b/deploy/DESPLIEGUE_SINBAD2.md new file mode 100644 index 0000000..d7a0bb1 --- /dev/null +++ b/deploy/DESPLIEGUE_SINBAD2.md @@ -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 +``` diff --git a/deploy/apache-reverse-proxy.conf b/deploy/apache-reverse-proxy.conf index 3ee97a6..57beca5 100644 --- a/deploy/apache-reverse-proxy.conf +++ b/deploy/apache-reverse-proxy.conf @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index e1e6854..f2c2fdd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 62b6cc6..c325770 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/README.md b/frontend/README.md index 5dd43bc..8ee1bc9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 346187a..a4cc3ba 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; + } } diff --git a/frontend/proxy_params.conf b/frontend/proxy_params.conf new file mode 100644 index 0000000..e71bc67 --- /dev/null +++ b/frontend/proxy_params.conf @@ -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;