Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a29faaa16 | ||
|
|
bc75528616 | ||
|
|
3cd9a6b272 | ||
|
|
26f834485c | ||
|
|
777cd0ac64 | ||
|
|
c528ed239b | ||
|
|
a88e51c92a | ||
|
|
f3ea2785b3 | ||
|
|
0c487fa7be | ||
|
|
b3efb98208 | ||
|
|
2d7b4bd74d | ||
|
|
f9c2a4610c | ||
|
|
b5fa7b0010 | ||
|
|
7f710c9084 |
@@ -17,6 +17,11 @@ APP_LOG_LEVEL=INFO
|
|||||||
APP_SECRET_KEY=change_me
|
APP_SECRET_KEY=change_me
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
FRONTEND_PORT=3000
|
||||||
|
|
||||||
|
# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur.
|
||||||
|
MAILPIT_SMTP_PORT=1025
|
||||||
|
MAILPIT_UI_PORT=8025
|
||||||
|
|
||||||
# API Mock EnerVision
|
# API Mock EnerVision
|
||||||
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
||||||
@@ -38,3 +43,8 @@ AIRFLOW_ADMIN_USERNAME=admin
|
|||||||
# comptes `app_user` d'EnerVision.
|
# comptes `app_user` d'EnerVision.
|
||||||
AIRFLOW_ADMIN_PASSWORD=change_me
|
AIRFLOW_ADMIN_PASSWORD=change_me
|
||||||
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
||||||
|
|
||||||
|
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
|
||||||
|
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
||||||
|
PUBLIC_HOST=enervision.local
|
||||||
|
ACME_EMAIL=
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ updates:
|
|||||||
directory: "/apps/frontend"
|
directory: "/apps/frontend"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
|
||||||
|
- package-ecosystem: "docker-compose"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ ml/mlflow.db
|
|||||||
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||||
etl/airflow/tests/.airflow_home/
|
etl/airflow/tests/.airflow_home/
|
||||||
|
|
||||||
|
# TLS : certificats du reverse proxy, générés par script ou par certbot
|
||||||
|
infra/proxy/tls/*.pem
|
||||||
|
|
||||||
# IDE et OS
|
# IDE et OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ BACKEND := apps/backend
|
|||||||
FRONTEND := apps/frontend
|
FRONTEND := apps/frontend
|
||||||
ML := ml
|
ML := ml
|
||||||
AIRFLOW := etl/airflow
|
AIRFLOW := etl/airflow
|
||||||
|
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
|
||||||
|
|
||||||
|
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
|
||||||
|
# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`.
|
||||||
|
PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1)
|
||||||
|
PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local)
|
||||||
|
export PUBLIC_HOST
|
||||||
|
ifdef ACME_EMAIL
|
||||||
|
export ACME_EMAIL
|
||||||
|
endif
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||||
@@ -9,7 +19,8 @@ AIRFLOW := etl/airflow
|
|||||||
lint format typecheck test test-cov test-integration check \
|
lint format typecheck test test-cov test-integration check \
|
||||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
||||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations \
|
ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations \
|
||||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs
|
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
||||||
|
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -107,6 +118,35 @@ airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâche
|
|||||||
docker-build: ## Construit l'image du backend
|
docker-build: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
|
||||||
|
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
|
||||||
|
|
||||||
|
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
|
||||||
|
@test -f infra/proxy/tls/fullchain.pem \
|
||||||
|
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
|
||||||
|
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|
||||||
|
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
|
||||||
|
$(COMPOSE_PROD) up -d --build
|
||||||
|
|
||||||
|
stack-down: ## Arrête la stack complète en conservant les données
|
||||||
|
$(COMPOSE_PROD) stop
|
||||||
|
|
||||||
|
stack-logs: ## Suit les journaux du reverse proxy
|
||||||
|
$(COMPOSE_PROD) logs -f proxy
|
||||||
|
|
||||||
|
tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis
|
||||||
|
@test "$(PUBLIC_HOST)" != enervision.local \
|
||||||
|
|| { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; }
|
||||||
|
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
-d $(PUBLIC_HOST) \
|
||||||
|
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
|
||||||
|
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
|
||||||
|
$(COMPOSE_PROD) exec proxy nginx -s reload
|
||||||
|
|
||||||
|
tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
|
||||||
|
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
|
||||||
|
$(COMPOSE_PROD) exec proxy nginx -s reload
|
||||||
|
|
||||||
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
|
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||||
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
||||||
@@ -51,9 +52,11 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── plugins/ Operateurs et hooks maison
|
||||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||||
│ └── tests/ Tests d'integrite des DAGs
|
│ └── tests/ Tests d'integrite des DAGs
|
||||||
├── infra/terraform/
|
├── infra/
|
||||||
│ ├── modules/ Modules reutilisables
|
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
||||||
│ └── environments/ Racines Terraform, une par environnement
|
│ └── terraform/
|
||||||
|
│ ├── modules/ Modules reutilisables
|
||||||
|
│ └── environments/ Racines Terraform, une par environnement
|
||||||
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
||||||
├── monitoring/
|
├── monitoring/
|
||||||
│ ├── prometheus/ Collecte et regles d'alerte
|
│ ├── prometheus/ Collecte et regles d'alerte
|
||||||
@@ -98,6 +101,21 @@ Verifier que la base repond et que l'extension est chargee :
|
|||||||
curl -s localhost:8000/api/v1/health/ready
|
curl -s localhost:8000/api/v1/health/ready
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stack complète derrière le reverse proxy
|
||||||
|
|
||||||
|
Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine.
|
||||||
|
L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
|
||||||
|
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié
|
||||||
|
```
|
||||||
|
|
||||||
|
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun
|
||||||
|
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans
|
||||||
|
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
||||||
|
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"optimization": {
|
||||||
|
"styles": {
|
||||||
|
"inlineCritical": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
|
|||||||
@@ -23,4 +23,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'monitoring/sensors',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
data: { role: 'admin' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { SensorsService } from './sensors.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
describe('SensorsService', () => {
|
||||||
|
let service: SensorsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(SensorsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.getStatus().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
|
||||||
|
req.flush({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Test',
|
||||||
|
overall: 'ok',
|
||||||
|
sensors: {
|
||||||
|
consumption: { status: 'ok', since: null },
|
||||||
|
electrical: { status: 'ok', since: null },
|
||||||
|
temperature: { status: 'ok', since: null },
|
||||||
|
humidity: { status: 'ok', since: null },
|
||||||
|
network: { status: 'ok', since: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((result as { sites: unknown[] }).sites.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class SensorsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard__actions">
|
<div class="dashboard__actions">
|
||||||
|
@if (auth.principal()?.role === 'admin') {
|
||||||
|
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
||||||
|
}
|
||||||
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||||
<ev-button
|
<ev-button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ h2 {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card--link {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card__label {
|
.card__label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -170,8 +170,11 @@ describe('Dashboard', () => {
|
|||||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
const authMock = {
|
||||||
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -198,9 +201,10 @@ describe('Dashboard', () => {
|
|||||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = {
|
const authMock = {
|
||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
};
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
export class Dashboard implements OnInit {
|
export class Dashboard implements OnInit {
|
||||||
private statsService = inject(StatsService);
|
private statsService = inject(StatsService);
|
||||||
private alertsService = inject(AlertsService);
|
private alertsService = inject(AlertsService);
|
||||||
|
public auth = inject(AuthService);
|
||||||
private predictionsService = inject(PredictionsService);
|
private predictionsService = inject(PredictionsService);
|
||||||
private auth = inject(AuthService);
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<div class="sensor-status">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="sensor-status__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="sensor-status__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Supervision des capteurs</h1>
|
||||||
|
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data(); as d) {
|
||||||
|
<div class="sites-grid">
|
||||||
|
@for (site of d.sites; track site.site_id) {
|
||||||
|
<ev-card class="site-card">
|
||||||
|
<div class="site-card__header">
|
||||||
|
<span class="site-card__name">{{ site.site_name }}</span>
|
||||||
|
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sensor-list">
|
||||||
|
@for (entry of sensorEntries; track entry[0]) {
|
||||||
|
@let diagnostic = sensorOf(site.sensors, entry[0]);
|
||||||
|
<li class="sensor-item">
|
||||||
|
<span class="sensor-dot" [class]="'sensor-dot--' + diagnostic.status"></span>
|
||||||
|
<span class="sensor-item__label">{{ entry[1] }}</span>
|
||||||
|
@if (diagnostic.status === 'failing') {
|
||||||
|
<span class="sensor-item__since">
|
||||||
|
@if (diagnostic.since; as since) {
|
||||||
|
dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }}
|
||||||
|
} @else {
|
||||||
|
aucune lecture reçue
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</ev-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sites-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
&--failing {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item__label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item__since {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { SensorStatusView } from './sensor-status';
|
||||||
|
import { SensorsService } from '../../../core/services/sensors.service';
|
||||||
|
import { SiteSensors } from '../../../shared/models/sensor-status.model';
|
||||||
|
import {provideRouter} from '@angular/router';
|
||||||
|
|
||||||
|
const OK_SENSORS: SiteSensors = {
|
||||||
|
consumption: { status: 'ok', since: null },
|
||||||
|
electrical: { status: 'ok', since: null },
|
||||||
|
temperature: { status: 'ok', since: null },
|
||||||
|
humidity: { status: 'ok', since: null },
|
||||||
|
network: { status: 'ok', since: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SensorStatusView', () => {
|
||||||
|
let sensorsMock: { getStatus: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sensorsMock = { getStatus: vi.fn() };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SensorStatusView],
|
||||||
|
providers: [
|
||||||
|
{ provide: SensorsService, useValue: sensorsMock },
|
||||||
|
provideRouter([]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charge et affiche les données au démarrage', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [
|
||||||
|
{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.data()?.sites.length).toBe(1);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Bureau Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("affiche un message d'erreur si l'appel échoue", () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom')));
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).toBe(
|
||||||
|
'État des capteurs indisponible, réessayez plus tard.'
|
||||||
|
);
|
||||||
|
expect(fixture.componentInstance.data()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe le bon ton de badge à chaque statut global', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.badgeToneForOverall('ok')).toBe('success');
|
||||||
|
expect(component.badgeToneForOverall('degraded')).toBe('warning');
|
||||||
|
expect(component.badgeToneForOverall('critical')).toBe('critical');
|
||||||
|
expect(component.badgeToneForOverall('inconnu')).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne le bon diagnostic via sensorOf', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => {
|
||||||
|
const sensors: SiteSensors = {
|
||||||
|
...OK_SENSORS,
|
||||||
|
temperature: { status: 'failing', since: '2026-09-18T08:00:00' },
|
||||||
|
};
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('dernière lecture le');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => {
|
||||||
|
const sensors: SiteSensors = {
|
||||||
|
consumption: { status: 'failing', since: null },
|
||||||
|
electrical: { status: 'failing', since: null },
|
||||||
|
temperature: { status: 'failing', since: null },
|
||||||
|
humidity: { status: 'failing', since: null },
|
||||||
|
network: { status: 'failing', since: null },
|
||||||
|
};
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, Observable } from 'rxjs';
|
||||||
|
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
|
||||||
|
import {Card} from '../../../shared/components/ui/card/card';
|
||||||
|
import {Alert} from '../../../shared/components/ui/alert/alert';
|
||||||
|
import {Brand} from '../../../shared/components/ui/brand/brand';
|
||||||
|
import {SensorsService} from '../../../core/services/sensors.service';
|
||||||
|
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
|
||||||
|
|
||||||
|
const SENSOR_LABELS: Record<string, string> = {
|
||||||
|
consumption: 'Consommation',
|
||||||
|
electrical: 'Électrique',
|
||||||
|
temperature: 'Température',
|
||||||
|
humidity: 'Humidité',
|
||||||
|
network: 'Réseau',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
|
||||||
|
ok: 'success',
|
||||||
|
degraded: 'warning',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sensor-status',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
|
||||||
|
templateUrl: './sensor-status.html',
|
||||||
|
styleUrl: './sensor-status.scss',
|
||||||
|
})
|
||||||
|
export class SensorStatusView implements OnInit {
|
||||||
|
private sensorsService = inject(SensorsService);
|
||||||
|
|
||||||
|
data = signal<SensorStatusResponse | null>(null);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly sensorEntries = Object.entries(SENSOR_LABELS);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sensorsService
|
||||||
|
.getStatus()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable()))
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.error.set(null);
|
||||||
|
this.data.set(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
|
||||||
|
return sensors[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeToneForOverall(overall: string): BadgeTone {
|
||||||
|
return TON_PAR_OVERALL[overall] ?? 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type SensorStatus = 'ok' | 'failing';
|
||||||
|
export type OverallStatus = 'ok' | 'degraded' | 'critical';
|
||||||
|
|
||||||
|
export interface SensorDiagnostic {
|
||||||
|
status: SensorStatus;
|
||||||
|
since: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSensors {
|
||||||
|
consumption: SensorDiagnostic;
|
||||||
|
electrical: SensorDiagnostic;
|
||||||
|
temperature: SensorDiagnostic;
|
||||||
|
humidity: SensorDiagnostic;
|
||||||
|
network: SensorDiagnostic;
|
||||||
|
[key: string]: SensorDiagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSensorStatus {
|
||||||
|
site_id: string;
|
||||||
|
site_name: string;
|
||||||
|
sensors: SiteSensors;
|
||||||
|
overall: OverallStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorStatusResponse {
|
||||||
|
timestamp: string;
|
||||||
|
sites: SiteSensorStatus[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste
|
||||||
|
# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et
|
||||||
|
# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide.
|
||||||
|
# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul
|
||||||
|
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
||||||
|
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
||||||
|
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
||||||
|
|
||||||
|
name: enervision
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports: !reset null
|
||||||
|
command:
|
||||||
|
- uvicorn
|
||||||
|
- app.main:create_app
|
||||||
|
- --factory
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "8000"
|
||||||
|
- --proxy-headers
|
||||||
|
- --forwarded-allow-ips=*
|
||||||
|
environment:
|
||||||
|
APP_ENV: prod
|
||||||
|
APP_DEBUG: "false"
|
||||||
|
APP_TRUST_PROXY_HEADERS: "true"
|
||||||
|
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
|
||||||
|
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports: !reset null
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
image: nginx:1.28-alpine
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./infra/proxy/tls:/etc/nginx/tls:ro
|
||||||
|
- acme_webroot:/var/www/certbot
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot:v5.8.0
|
||||||
|
profiles: ["acme"]
|
||||||
|
volumes:
|
||||||
|
- letsencrypt:/etc/letsencrypt
|
||||||
|
- acme_webroot:/var/www/certbot
|
||||||
|
- ./infra/proxy/tls:/tls
|
||||||
|
- ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
acme_webroot:
|
||||||
|
letsencrypt:
|
||||||
+1
-1
@@ -98,7 +98,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./apps/frontend
|
build: ./apps/frontend
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
|
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
|
||||||
|
|||||||
@@ -11,3 +11,6 @@
|
|||||||
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
||||||
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
|
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
|
||||||
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||||
|
| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global |
|
||||||
|
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
|
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-21
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress
|
||||||
|
remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans
|
||||||
|
« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte.
|
||||||
|
`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » :
|
||||||
|
servir le SPA et l'API sous la même origine, et servir en HTTPS.
|
||||||
|
|
||||||
|
Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers
|
||||||
|
`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En
|
||||||
|
développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le
|
||||||
|
fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend
|
||||||
|
le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et
|
||||||
|
l'authentification ne tient pas au rechargement de page.
|
||||||
|
|
||||||
|
La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible
|
||||||
|
documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le
|
||||||
|
`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas
|
||||||
|
aboutir, faute de DNS public et de port 80 entrant.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à
|
||||||
|
publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la
|
||||||
|
base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par
|
||||||
|
l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement.
|
||||||
|
|
||||||
|
**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers
|
||||||
|
l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et
|
||||||
|
redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister.
|
||||||
|
|
||||||
|
**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`.
|
||||||
|
Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de
|
||||||
|
certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura
|
||||||
|
pas à changer le jour de la bascule.
|
||||||
|
|
||||||
|
**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par
|
||||||
|
`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut
|
||||||
|
pas savoir si elle est jointe en HTTPS, le terminateur, si.
|
||||||
|
|
||||||
|
## Pourquoi Compose et pas l'ingress k3s
|
||||||
|
|
||||||
|
Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le
|
||||||
|
provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais
|
||||||
|
été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux
|
||||||
|
topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un
|
||||||
|
stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les
|
||||||
|
deux topologies », et il ne tient pas dans le jalon.
|
||||||
|
|
||||||
|
Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un
|
||||||
|
cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix
|
||||||
|
d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes.
|
||||||
|
|
||||||
|
## Ce que le proxy n'expose pas, et pourquoi c'est structurel
|
||||||
|
|
||||||
|
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
|
||||||
|
pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans
|
||||||
|
`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est
|
||||||
|
nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer »
|
||||||
|
Swagger, il publiera les métriques avec.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent
|
||||||
|
ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine
|
||||||
|
CORS, et le cookie devient `__Secure-ev_refresh`.
|
||||||
|
- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec
|
||||||
|
`$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que
|
||||||
|
lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP
|
||||||
|
du proxy, c'est-à-dire globalement.
|
||||||
|
- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire
|
||||||
|
`request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de
|
||||||
|
`request.client` du backend, en dernier recours quand l'en-tête est absent.
|
||||||
|
- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20
|
||||||
|
requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret,
|
||||||
|
`login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en
|
||||||
|
sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule
|
||||||
|
adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration.
|
||||||
|
- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires
|
||||||
|
d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela :
|
||||||
|
`<link rel="stylesheet" media="print" onload="this.media='all'">`. La feuille serait restée en
|
||||||
|
`media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans
|
||||||
|
`angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de
|
||||||
|
composants injectés à l'exécution.
|
||||||
|
- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient
|
||||||
|
donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être
|
||||||
|
amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique
|
||||||
|
couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`.
|
||||||
|
- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle
|
||||||
|
sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe
|
||||||
|
donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent
|
||||||
|
couvre l'hôte demandé, ce qui est la condition réelle à tenir.
|
||||||
|
- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend
|
||||||
|
sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502.
|
||||||
|
- La ligne API8 transport de `owasp-traceabilite.md` se referme.
|
||||||
|
- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas
|
||||||
|
été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur
|
||||||
|
avertit, et c'est la situation réelle du projet, pas un raccourci.
|
||||||
|
- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`,
|
||||||
|
sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un
|
||||||
|
registre et des manifestes qui n'existent pas, à quatre jours du rendu.
|
||||||
|
- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS :
|
||||||
|
moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front
|
||||||
|
redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire.
|
||||||
|
- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le
|
||||||
|
défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx.
|
||||||
|
- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine
|
||||||
|
possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la
|
||||||
|
configuration nginx le jour où ces deux éléments existent.
|
||||||
|
- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi
|
||||||
|
la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`.
|
||||||
@@ -46,6 +46,7 @@ flowchart TB
|
|||||||
navigateur["Navigateur"]
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
subgraph machine["Machine on-premise"]
|
subgraph machine["Machine on-premise"]
|
||||||
|
proxy["Reverse proxy Nginx<br/>:80 et :443"]
|
||||||
front["Frontend Angular 22<br/>apps/frontend"]
|
front["Frontend Angular 22<br/>apps/frontend"]
|
||||||
api["API FastAPI<br/>apps/backend"]
|
api["API FastAPI<br/>apps/backend"]
|
||||||
db[("PostgreSQL 17<br/>TimescaleDB")]
|
db[("PostgreSQL 17<br/>TimescaleDB")]
|
||||||
@@ -54,7 +55,9 @@ flowchart TB
|
|||||||
grafana["Grafana"]
|
grafana["Grafana"]
|
||||||
end
|
end
|
||||||
|
|
||||||
navigateur --> front
|
navigateur --> proxy
|
||||||
|
proxy --> front
|
||||||
|
proxy --> api
|
||||||
front -.-> api
|
front -.-> api
|
||||||
api --> db
|
api --> db
|
||||||
airflow --> db
|
airflow --> db
|
||||||
@@ -82,7 +85,7 @@ collecteur ne vient le lire.
|
|||||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
||||||
@@ -143,6 +146,11 @@ consolidée.
|
|||||||
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
||||||
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
||||||
|
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
|
||||||
|
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
|
||||||
|
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
|
||||||
|
distincte de celle de l'application. Voir
|
||||||
|
[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
|
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
|
||||||
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
||||||
`.example`.
|
`.example`.
|
||||||
@@ -156,13 +164,10 @@ consolidée.
|
|||||||
arrêteraient une application compromise. Même raison de report.
|
arrêteraient une application compromise. Même raison de report.
|
||||||
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
||||||
agir sur le site B. C'est la limite connue du modèle.
|
agir sur le site B. C'est la limite connue du modèle.
|
||||||
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
|
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
||||||
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
|
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
||||||
l'infrastructure.
|
est livré et documenté mais pas exercé.
|
||||||
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
||||||
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
|
|
||||||
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
|
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
|
||||||
|
|
||||||
## Décisions structurantes
|
## Décisions structurantes
|
||||||
|
|
||||||
@@ -176,3 +181,4 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||||
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
||||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
|
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Infrastructure
|
# Infrastructure
|
||||||
|
|
||||||
Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
||||||
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux.
|
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
|
||||||
|
|
||||||
| Topologie | Sert à | Statut |
|
| Topologie | Sert à | Statut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||||
| k3s single-node | Déployer sur le serveur on-premise | `En cours` |
|
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||||
|
| k3s single-node | Cible à terme | `En cours` |
|
||||||
|
|
||||||
## Poste de développement
|
## Poste de développement
|
||||||
|
|
||||||
@@ -98,7 +99,44 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu
|
|||||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||||
construction de l'image.
|
construction de l'image.
|
||||||
|
|
||||||
## Cible de déploiement
|
## Machine cible, exécution Docker
|
||||||
|
|
||||||
|
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
||||||
|
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de
|
||||||
|
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
|
subgraph machine["Machine on-premise"]
|
||||||
|
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
|
||||||
|
front["service frontend<br/>nginx statique :3000"]
|
||||||
|
api["service backend<br/>uvicorn :8000"]
|
||||||
|
db[("service db<br/>:5432")]
|
||||||
|
mail["service mailpit"]
|
||||||
|
end
|
||||||
|
|
||||||
|
navigateur -->|"HTTPS"| proxy
|
||||||
|
proxy -->|"/"| front
|
||||||
|
proxy -->|"/api/"| api
|
||||||
|
api --> db
|
||||||
|
api --> mail
|
||||||
|
```
|
||||||
|
|
||||||
|
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
|
||||||
|
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
|
||||||
|
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
|
||||||
|
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
|
||||||
|
|
||||||
|
Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas :
|
||||||
|
|
||||||
|
- Servir le SPA et l'API sous la même origine est ce qui rend le cookie `__Secure-ev_refresh`
|
||||||
|
utilisable. Sans cela, `apiUrl: '/api/v1'` ne mène nulle part une fois en conteneur.
|
||||||
|
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
||||||
|
compte sur l'IP du proxy et devient globale.
|
||||||
|
|
||||||
|
## Cible à terme, k3s
|
||||||
|
|
||||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||||
été appliqué.
|
été appliqué.
|
||||||
@@ -154,6 +192,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
|
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
|
||||||
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
|
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
|
||||||
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
||||||
|
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
||||||
|
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
||||||
|
|
||||||
## Ports et noms
|
## Ports et noms
|
||||||
|
|
||||||
@@ -162,14 +202,16 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
|
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
|
||||||
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
|
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
|
||||||
| API | `8000` | Identique en conteneur et hors conteneur |
|
| API | `8000` | Identique en conteneur et hors conteneur |
|
||||||
| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend |
|
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
||||||
|
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
||||||
|
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
|
||||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
||||||
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
||||||
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
||||||
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||||
|
|
||||||
## Le trou entre les deux topologies
|
## Le trou vers k3s
|
||||||
|
|
||||||
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
|
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
|
||||||
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
|
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
|
||||||
@@ -177,7 +219,11 @@ question à trancher, avant toute ressource Kubernetes.
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
- **Quel ingress** remplace Traefik, et qui termine le TLS.
|
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
|
||||||
|
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
|
||||||
|
topologie Compose, pas pour Kubernetes.
|
||||||
|
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
|
||||||
|
reste auto-signé.
|
||||||
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
||||||
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
||||||
cluster ou à côté.
|
cluster ou à côté.
|
||||||
|
|||||||
@@ -390,9 +390,12 @@ Le reste, par ordre de surface :
|
|||||||
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
||||||
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
||||||
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
||||||
terminateur TLS, que l'application ne connaît pas.
|
terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose
|
||||||
|
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
|
||||||
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
||||||
- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative.
|
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
|
||||||
|
`APP_TRUST_PROXY_HEADERS` doit alors valoir vrai, sinon le compteur par IP devient global.
|
||||||
|
- Pas de journalisation des accès applicative.
|
||||||
|
|
||||||
## Observabilité
|
## Observabilité
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost
|
|||||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||||
`apiUrl` relatif, `/api/v1`.
|
`apiUrl` relatif, `/api/v1`.
|
||||||
|
|
||||||
En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif
|
En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL
|
||||||
(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur
|
absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build
|
||||||
`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...`
|
de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy**
|
||||||
sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le
|
qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir
|
||||||
backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md).
|
[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
## Exécution
|
## Exécution
|
||||||
|
|
||||||
@@ -118,13 +118,14 @@ le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.1
|
|||||||
tester ni construire le frontend.
|
tester ni construire le frontend.
|
||||||
|
|
||||||
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
||||||
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
|
englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis
|
||||||
développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200
|
`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut
|
||||||
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
d'`APP_CORS_ORIGINS`, côté backend.
|
||||||
|
|
||||||
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
|
Le service `frontend` du `docker-compose.yml` sert le build statique par le nginx de
|
||||||
`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage
|
`apps/frontend/Dockerfile`, multi-étage, qui **écoute sur 3000**. En déploiement il n'est plus
|
||||||
avec un service statique, il reste à écrire.
|
publié du tout : le reverse proxy est seul à sortir sur le réseau, et l'atteint par le réseau
|
||||||
|
Compose.
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
@@ -133,6 +134,13 @@ avec un service statique, il reste à écrire.
|
|||||||
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
||||||
rafraîchissement sur 401. Détail complet dans
|
rafraîchissement sur 401. Détail complet dans
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||||
|
- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les
|
||||||
|
gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un
|
||||||
|
(`<link media="print" onload="this.media='all'">`), ce qui aurait laissé l'application sans
|
||||||
|
style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration
|
||||||
|
de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier
|
||||||
|
affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à
|
||||||
|
l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -129,18 +129,18 @@ n'est pas envoyé et le rafraîchissement échoue toujours.
|
|||||||
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
|
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
|
||||||
**même origine** et le cookie marche sans rien configurer.
|
**même origine** et le cookie marche sans rien configurer.
|
||||||
|
|
||||||
En production, `src/environments/environment.ts` contient encore le gabarit
|
En déploiement, les deux conditions sont désormais remplies par le reverse proxy
|
||||||
`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun
|
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)) : `environment.ts` porte un
|
||||||
cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.**
|
`apiUrl` relatif, `/api/v1`, et le proxy sert le SPA sur `/` et l'API sur `/api/` **sous la même
|
||||||
|
origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-ev_refresh`
|
||||||
|
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
|
||||||
|
l'authentification ne survit pas à un rechargement de page.
|
||||||
|
|
||||||
Deux corrections, à faire avant la démonstration :
|
Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout
|
||||||
|
vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie.
|
||||||
1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un
|
|
||||||
`location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ;
|
|
||||||
2. servir en HTTPS.
|
|
||||||
|
|
||||||
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
||||||
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque.
|
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
|
||||||
|
|
||||||
## Origines autorisées
|
## Origines autorisées
|
||||||
|
|
||||||
|
|||||||
@@ -43,17 +43,21 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
|||||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||||
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
||||||
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
||||||
|
| Terminaison TLS au frontal, redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit au frontal | `infra/proxy/conf.d/enervision.conf`, ADR 0007 | API8 Security Misconfiguration, A05 |
|
||||||
|
|
||||||
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
||||||
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
|
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
|
||||||
|
|
||||||
|
Note sur API8 : le transport est couvert, le certificat ne l'est qu'à moitié. Tant qu'aucun nom de
|
||||||
|
domaine public ne résout vers la machine, le défi HTTP-01 de Let's Encrypt ne peut pas aboutir et
|
||||||
|
le certificat servi reste auto-signé. Le chemin ACME est livré et documenté, pas exercé.
|
||||||
|
|
||||||
## Non couvert, et pourquoi
|
## Non couvert, et pourquoi
|
||||||
|
|
||||||
| Item | État | Raison |
|
| Item | État | Raison |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||||
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
||||||
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
|
|
||||||
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||||
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
||||||
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Reverse proxy
|
||||||
|
|
||||||
|
Terminaison TLS et routage de la stack déployée. Seul composant publié sur le réseau : il
|
||||||
|
écoute en 80 et 443, et rien d'autre ne sort du réseau Compose.
|
||||||
|
|
||||||
|
- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit.
|
||||||
|
- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité,
|
||||||
|
routage.
|
||||||
|
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
|
||||||
|
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
|
||||||
|
|
||||||
|
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
|
||||||
|
configuration est montée en volume par `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||||
|
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
||||||
|
|
||||||
|
## Routage
|
||||||
|
|
||||||
|
| Chemin | Destination | Remarque |
|
||||||
|
|---|---|---|
|
||||||
|
| `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS |
|
||||||
|
| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute |
|
||||||
|
| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde |
|
||||||
|
| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues |
|
||||||
|
|
||||||
|
La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh`
|
||||||
|
partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une
|
||||||
|
seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal.
|
||||||
|
|
||||||
|
L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les
|
||||||
|
ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le
|
||||||
|
proxy demanderait une authentification propre, qui n'est pas la leur.
|
||||||
|
|
||||||
|
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
|
||||||
|
pas sous `/api`. Ils tombent donc dans `location /`, donc sur le SPA : ils ne sont pas joignables
|
||||||
|
depuis l'extérieur, sans qu'aucune règle de blocage ait à être écrite. Y toucher, c'est les
|
||||||
|
exposer.
|
||||||
|
|
||||||
|
## Certificat : deux modes, un seul emplacement
|
||||||
|
|
||||||
|
nginx lit toujours `tls/fullchain.pem` et `tls/privkey.pem`. Seule leur fabrication change, la
|
||||||
|
configuration n'a jamais à bouger.
|
||||||
|
|
||||||
|
### Démonstration, certificat auto-signé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tls-selfsigned PUBLIC_HOST=enervision.local
|
||||||
|
make stack-up
|
||||||
|
```
|
||||||
|
|
||||||
|
Le navigateur avertira d'un émetteur inconnu : c'est attendu, et c'est le seul mode exploitable
|
||||||
|
tant que la machine cible n'a pas de nom de domaine public.
|
||||||
|
|
||||||
|
### Let's Encrypt
|
||||||
|
|
||||||
|
Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable
|
||||||
|
depuis Internet. La cible documentée aujourd'hui (`ssh_host = "10.0.0.10"`, serveur de l'école)
|
||||||
|
ne remplit ni l'une ni l'autre condition : le chemin ci-dessous est livré et documenté, il n'a
|
||||||
|
pas été exercé.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stack-up # nginx doit tourner pour servir le défi
|
||||||
|
make tls-acme PUBLIC_HOST=enervision.fr ACME_EMAIL=ops@enervision.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Renouvellement, à passer en tâche planifiée sur la machine :
|
||||||
|
|
||||||
|
```cron
|
||||||
|
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un
|
||||||
|
greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour.
|
||||||
|
|
||||||
|
## Vérifier la configuration sans démarrer la stack
|
||||||
|
|
||||||
|
`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
||||||
|
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
|
||||||
|
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
|
||||||
|
nginx:1.28-alpine nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Contrainte : certbot écrit dans /etc/letsencrypt/live/<domaine>/, nginx lit /etc/nginx/tls/.
|
||||||
|
# Ce hook recopie le résultat à l'emplacement unique que la configuration nginx connaît, ce
|
||||||
|
# qui rend le mode auto-signé et le mode ACME interchangeables sans toucher à un vhost.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cp -L "$RENEWED_LINEAGE/fullchain.pem" /tls/fullchain.pem
|
||||||
|
cp -L "$RENEWED_LINEAGE/privkey.pem" /tls/privkey.pem
|
||||||
|
chmod 644 /tls/fullchain.pem
|
||||||
|
chmod 600 /tls/privkey.pem
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Piège : `X-Forwarded-For` se construit avec `$proxy_add_x_forwarded_for`, qui ajoute l'IP
|
||||||
|
# réelle en fin de chaîne. `get_client_ip()` (apps/backend/app/api/deps.py) ne lit que le
|
||||||
|
# dernier élément : toute autre forme rend la limitation de débit par IP globale, donc le
|
||||||
|
# déni de service auto-infligé que ce code cherche précisément à éviter.
|
||||||
|
# Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de
|
||||||
|
# nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la
|
||||||
|
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
|
||||||
|
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
|
||||||
|
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# L'application refuse délibérément de poser ces deux en-têtes, verrouillé par
|
||||||
|
# tests/api/test_hardening.py. Ils appartiennent au terminateur TLS, c'est-à-dire ici.
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $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 $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de
|
||||||
|
# l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page.
|
||||||
|
location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ {
|
||||||
|
limit_req zone=auth burst=20 nodelay;
|
||||||
|
set $cible_api http://backend:8000;
|
||||||
|
proxy_pass $cible_api$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=40 nodelay;
|
||||||
|
set $cible_api http://backend:8000;
|
||||||
|
proxy_pass $cible_api$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $cible_web http://frontend:3000;
|
||||||
|
proxy_pass $cible_web$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contrainte : les directives `limit_req_zone` ne sont valides que dans le bloc `http`.
|
||||||
|
# Les `location` de conf.d/enervision.conf s'y réfèrent par nom, `api` et `auth`.
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
log_format enervision '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent $request_time '
|
||||||
|
'"$http_referer" "$http_user_agent"';
|
||||||
|
access_log /var/log/nginx/access.log enervision;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types application/javascript application/json application/xml
|
||||||
|
image/svg+xml text/css text/plain;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contrainte : nginx lit toujours infra/proxy/tls/{fullchain,privkey}.pem, quel que soit le
|
||||||
|
# mode d'obtention. Ce script remplit ces deux fichiers pour la démonstration, certbot les
|
||||||
|
# remplit par acme-deploy-hook.sh. La configuration nginx ne connaît pas la différence.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DESTINATION="$RACINE/infra/proxy/tls"
|
||||||
|
HOTE="${PUBLIC_HOST:-enervision.local}"
|
||||||
|
ADRESSE="${PUBLIC_IP:-}"
|
||||||
|
JOURS="${TLS_DAYS:-365}"
|
||||||
|
ECRASER=0
|
||||||
|
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--force) ECRASER=1 ;;
|
||||||
|
*)
|
||||||
|
echo "Usage : PUBLIC_HOST=exemple.local [PUBLIC_IP=10.0.0.10] $0 [--force]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -f "$DESTINATION/fullchain.pem" && $ECRASER -eq 0 ]]; then
|
||||||
|
echo "Un certificat existe déjà dans $DESTINATION." >&2
|
||||||
|
echo "Relancer avec --force pour l'écraser." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DESTINATION"
|
||||||
|
|
||||||
|
NOMS="DNS:$HOTE,DNS:localhost"
|
||||||
|
if [[ -n "$ADRESSE" ]]; then
|
||||||
|
NOMS="$NOMS,IP:$ADRESSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days "$JOURS" \
|
||||||
|
-subj "/CN=$HOTE" \
|
||||||
|
-addext "subjectAltName=$NOMS" \
|
||||||
|
-keyout "$DESTINATION/privkey.pem" \
|
||||||
|
-out "$DESTINATION/fullchain.pem" 2>/dev/null
|
||||||
|
|
||||||
|
chmod 600 "$DESTINATION/privkey.pem"
|
||||||
|
chmod 644 "$DESTINATION/fullchain.pem"
|
||||||
|
|
||||||
|
echo "Certificat auto-signé écrit dans $DESTINATION."
|
||||||
|
echo " Noms couverts : $NOMS"
|
||||||
|
echo " Validité : $JOURS jours"
|
||||||
|
echo "Le navigateur avertira d'un émetteur inconnu, c'est attendu hors Let's Encrypt."
|
||||||
Reference in New Issue
Block a user