diff --git a/bin/ra-console/src/audit.rs b/bin/ra-console/src/audit.rs index 128ca05..da93010 100644 --- a/bin/ra-console/src/audit.rs +++ b/bin/ra-console/src/audit.rs @@ -17,6 +17,11 @@ pub const EVENT_LOGIN_SUCCEEDED: &str = "ra.login_succeeded"; pub const EVENT_LOGIN_REFUSED: &str = "ra.login_refused"; pub const EVENT_SESSION_OPENED: &str = "ra.session_opened"; pub const EVENT_SESSION_CLOSED: &str = "ra.session_closed"; +/// Une action signée préparée par `ca-server` à la demande d'un opérateur +/// (docs/WEBUI.md §4, étapes 1 à 3) : l'identifiant de l'action et +/// l'empreinte du corps figé, pour rapprocher ce journal de celui de +/// `ca-server`, qui fait foi. +pub const EVENT_ACTION_CHALLENGE: &str = "ra.action_challenge"; /// Même forme que `oe_ca_core::Recorder` / `oe_raflow::Recorder`, dupliquée /// plutôt que partagée (ce sont des traits d'un seul étage, la duplication diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 4d468a5..8e3a1ae 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -14,6 +14,7 @@ use axum::{Json, Router}; use serde::Deserialize; use sqlx::PgPool; +use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; use crate::requests; @@ -27,6 +28,7 @@ pub struct AppState { pub link: CaLink, pub login: LoginService, pub sessions: Sessions, + pub journal: Arc, } pub fn router(state: Arc) -> Router { @@ -45,6 +47,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/me", get(handle_me)) .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) + .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(state) } @@ -438,6 +441,75 @@ async fn handle_requests( } } +/// Les actions que la console relaie à ce stade (docs/WEBUI.md §15, étape 3) : +/// décider d'une demande d'enrôlement. La révocation (étape 4) et la gestion +/// du registre suivront ; d'ici là, la console refuse de les préparer, même si +/// `ca-server` saurait les exécuter. +fn relayed_at_this_stage(action: &oe_actions::Action) -> bool { + matches!( + action, + oe_actions::Action::ApproveRequest { .. } | oe_actions::Action::RejectRequest { .. } + ) +} + +/// `POST /api/v1/webauthn/challenge` (docs/WEBUI.md §4 étapes 1 à 3, §5) : +/// l'opérateur connecté demande à `ca-server` de figer une action et d'émettre +/// le challenge qu'il signera. Le corps rendu est celui que `ca-server` +/// exécutera, à afficher tel quel. +/// +/// Ce que la console décide : que la session est valide, et **pour qui** le +/// challenge est émis — l'opérateur de la session, jamais une valeur du +/// navigateur. Ce qu'elle ne décide pas : le rôle suffisant, l'état de la +/// demande, le corps final. `ca-server` en juge. +async fn handle_action_challenge( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !is_json(&headers) { + return unsupported_media_type(); + } + let who = match authenticate(&state, &headers).await { + Ok(a) => a, + Err(resp) => return resp, + }; + // Relue dans l'énumération fermée d'`oe_actions`, puis resérialisée : un + // champ en trop (un `operator_hint` glissé par le navigateur, par exemple) + // ne franchit jamais la console. + let action: oe_actions::Action = match serde_json::from_slice(&body) { + Ok(a) => a, + Err(_) => return error(StatusCode::BAD_REQUEST, "bad_request", "action invalide"), + }; + if !relayed_at_this_stage(&action) { + return error( + StatusCode::FORBIDDEN, + "action_not_available", + "cette action n'est pas encore proposée par la console", + ); + } + let result = state + .link + .post( + "/internal/v1/challenge", + &serde_json::json!({ "body": action, "operator_hint": who.operator_id }), + ) + .await; + if let Ok(r) = &result { + state.journal.append( + audit::EVENT_ACTION_CHALLENGE, + serde_json::json!({ + "operator": who.operator, + "action": r.body.get("body").and_then(|b| b.get("action")), + "action_id": r.body.get("action_id"), + "body_hash": r.body.get("body_hash"), + "status": r.status, + "error": r.body.get("error"), + }), + ); + } + relayed(result) +} + /// `POST /api/v1/logout` : révoque la session sans attendre son expiration. /// Idempotent, sans cookie ou avec un cookie déjà invalide compris : dans /// tous les cas, plus aucune session valide n'existe ensuite. diff --git a/bin/ra-console/src/main.rs b/bin/ra-console/src/main.rs index ac8cf67..ec6f462 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -112,7 +112,7 @@ async fn run_serve() { cfg.webauthn.login_decoy_secret.into_bytes(), journal.clone(), ); - let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal); + let sessions = Sessions::new(oe_actions::Registry::new(pool.clone()), journal.clone()); // Purge périodique des sessions et challenges expirés (§15 étape 1c-2b) : // aucune opération manuelle, arrêtée par le même signal que le serveur. @@ -123,6 +123,7 @@ async fn run_serve() { link, login, sessions, + journal, })); let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen)) .await diff --git a/bin/ra-console/src/session.rs b/bin/ra-console/src/session.rs index 87239d1..7d76ad6 100644 --- a/bin/ra-console/src/session.rs +++ b/bin/ra-console/src/session.rs @@ -38,6 +38,9 @@ pub enum SessionError { /// Une session authentifiée, relue en base à l'instant de l'appel. pub struct Authenticated { + /// Identifiant de l'opérateur dans le registre : ce qu'une route relaie à + /// `ca-server` (`operator_hint`), jamais une valeur venue du navigateur. + pub operator_id: Uuid, pub operator: String, pub role: Role, } @@ -118,6 +121,7 @@ impl Sessions { .execute(self.registry.pool()) .await; Ok(Authenticated { + operator_id: operator.id, operator: operator.name, role: operator.role, }) diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs new file mode 100644 index 0000000..433d3a9 --- /dev/null +++ b/bin/ra-console/tests/action_challenge.rs @@ -0,0 +1,390 @@ +//! Préparation d'une action signée (docs/WEBUI.md §4 étapes 1 à 3, §15 étape +//! 3a) de bout en bout : un navigateur factice connecté → `ra-console` → le +//! lien mTLS → le **vrai** service d'actions de `ca-server`, sur un vrai +//! PostgreSQL. Ce que le test prouve : le challenge est émis pour l'opérateur +//! de la session et pour personne d'autre, la console ne prépare que les +//! actions de l'étape 3, et c'est `ca-server` qui juge du rôle. +//! +//! DSN dans `OE_CASTORE_TEST_DSN` ; test ignoré si elle n'est pas définie. + +mod common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::{pki, tempdir::Dir, Pki}; +use http_body_util::BodyExt; +use oe_actions::{NewCredential, Registry, Role, Service}; +use oe_castore::{Postgres, RequestState, Store}; +use oe_raflow::{Decider, DeciderOptions, Recorder}; +use oe_webauthn::{trusted_models, TrustedModel, Url, Uuid, Verifier}; +use ra_console::ca_link::CaLink; +use ra_console::http::{router, AppState}; +use ra_console::login::LoginService; +use sqlx::postgres::PgPoolOptions; +use tower::ServiceExt; +use webauthn_authenticator_rs::softtoken::{SoftToken, AAGUID}; +use webauthn_authenticator_rs::WebauthnAuthenticator; + +const HOST: &str = "console.example.com"; +const LOGIN_BEGIN: &str = "/api/v1/webauthn/login/begin"; +const LOGIN_FINISH: &str = "/api/v1/webauthn/login/finish"; +const CHALLENGE: &str = "/api/v1/webauthn/challenge"; + +struct NullJournal; +#[async_trait::async_trait] +impl Recorder for NullJournal { + async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} + +fn origin() -> Url { + Url::parse(&format!("https://{HOST}")).unwrap() +} + +struct Env { + console: axum::Router, + registry: Registry, + store: Arc, + verifier: Verifier, + authn: WebauthnAuthenticator, + _dir: Dir, + _pki: Pki, +} + +impl Env { + async fn new() -> Option { + let base = std::env::var("OE_CASTORE_TEST_DSN").ok()?; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let name = format!("chal_{nanos}"); + let admin = PgPoolOptions::new().connect(&base).await.unwrap(); + sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin) + .await + .unwrap(); + let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0); + let store: Arc = Arc::new(Postgres::open(&dsn).await.unwrap()); + let registry = Registry::connect(&dsn).await.unwrap(); + + // Un seul modèle de clé de confiance, le même pour ca-server (qui + // vérifiera les assertions d'action) et pour la console (connexion). + let (token, root) = SoftToken::new(true).unwrap(); + let root_pem = root.to_pem().unwrap(); + let verifier = || { + Verifier::new( + HOST, + &origin(), + "test", + trusted_models(&[TrustedModel { + root_pem: &root_pem, + aaguid: AAGUID, + description: "SoftToken (test)", + }]) + .unwrap(), + ) + .unwrap() + }; + + let service = Arc::new(Service::new( + registry.clone(), + verifier(), + store.clone(), + Decider::new(DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + )); + let pki = pki().await; + let port = pki + .serve_router(ca_server::internal::router(service, 64 * 1024)) + .await; + let dir = Dir::new(); + let client = pki + .cert(&oe_ca_core::profile::internal_client(), "ra-console") + .await; + let link = CaLink::new(&pki.files(&dir, &client, port)).unwrap(); + + let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let console = router(Arc::new(AppState { + pool: pool.clone(), + link, + login: LoginService::new( + registry.clone(), + verifier(), + b"secret-de-test-au-moins-16-octets".to_vec(), + Arc::new(ra_console::audit::NullRecorder), + ), + sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), + })); + + Some(Env { + console, + registry, + store, + verifier: verifier(), + authn: WebauthnAuthenticator::new(token), + _dir: dir, + _pki: pki, + }) + } + + /// Un opérateur actif avec une clé enregistrée, posé directement dans le + /// registre : l'enrôlement a ses propres tests (register_relay.rs). + async fn operator_with_key(&mut self, name: &str, role: Role) -> Uuid { + let now = time::OffsetDateTime::now_utc(); + let id = self + .registry + .add_operator(name, role, "test", now) + .await + .unwrap(); + let (options, state) = self.verifier.start_registration(id, name, None).unwrap(); + let reg = self.authn.do_registration(origin(), options).unwrap(); + let key = self.verifier.finish_registration(®, &state).unwrap(); + self.registry + .add_credential( + NewCredential { + operator_id: id, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "test", + initiated_by: "test", + confirmed_by: Some("test"), + }, + now, + ) + .await + .unwrap(); + id + } + + async fn post( + &self, + path: &str, + body: serde_json::Value, + cookie: Option<&str>, + content_type: &str, + ) -> (StatusCode, axum::http::HeaderMap, serde_json::Value) { + let mut req = Request::post(path).header("content-type", content_type); + if let Some(c) = cookie { + req = req.header("cookie", c); + } + let res = self + .console + .clone() + .oneshot(req.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = res.status(); + let headers = res.headers().clone(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + ( + status, + headers, + serde_json::from_slice(&bytes).unwrap_or_default(), + ) + } + + async fn challenge( + &self, + cookie: Option<&str>, + body: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let (status, _, body) = self.post(CHALLENGE, body, cookie, "application/json").await; + (status, body) + } + + async fn log_in(&mut self, name: &str) -> String { + let (_, _, begun) = self + .post( + LOGIN_BEGIN, + serde_json::json!({ "name": name }), + None, + "application/json", + ) + .await; + let options: oe_webauthn::RequestChallengeResponse = + serde_json::from_value(serde_json::json!({ "publicKey": begun["webauthn"] })).unwrap(); + let assertion = self.authn.do_authentication(origin(), options).unwrap(); + let (status, headers, body) = self + .post( + LOGIN_FINISH, + serde_json::json!({ "challenge_id": begun["challenge_id"], "credential": assertion }), + None, + "application/json", + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let set_cookie = headers.get("set-cookie").unwrap().to_str().unwrap(); + set_cookie.split(';').next().unwrap().to_string() + } + + async fn pending_request(&self, transaction_id: &str) { + self.store + .create_request(oe_castore::Request { + transaction_id: transaction_id.to_string(), + csr_fingerprint: format!("empreinte-{transaction_id}"), + csr_der: vec![0x30, 0x00], + profile: "tsa_signer".to_string(), + subject_cn: "tsu.example.test".to_string(), + state: RequestState::Pending, + created_at: time::OffsetDateTime::now_utc(), + decided_at: None, + operator: String::new(), + comment: String::new(), + issued_at: None, + certificate_serial: None, + }) + .await + .unwrap(); + } + + async fn actions_frozen(&self) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM actions") + .fetch_one(self.registry.pool()) + .await + .unwrap() + } + + /// Identifiants (base64url) des clés d'un opérateur, tels que l'option + /// `allowCredentials` d'un challenge les désigne. + async fn credential_ids(&self, operator: Uuid) -> Vec { + sqlx::query_scalar("SELECT credential_id FROM webauthn_credentials WHERE operator_id = $1") + .bind(operator) + .fetch_all(self.registry.pool()) + .await + .unwrap() + } +} + +macro_rules! env { + () => { + match Env::new().await { + Some(e) => e, + None => { + eprintln!("OE_CASTORE_TEST_DSN non définie : test PostgreSQL ignoré"); + return; + } + } + }; +} + +fn approve(tx: &str) -> serde_json::Value { + serde_json::json!({ "action": "approve_request", "transaction_id": tx, "comment": "identité vérifiée" }) +} + +fn allowed(challenge: &serde_json::Value) -> Vec { + challenge["webauthn"]["allowCredentials"] + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap().to_string()) + .collect() +} + +#[tokio::test] +async fn a_logged_in_operator_gets_a_challenge_for_their_own_keys() { + let mut env = env!(); + let alice = env.operator_with_key("alice", Role::RaOperateur).await; + let bob = env.operator_with_key("bob", Role::RaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + + // Le navigateur glisse l'identifiant de bob : la console ne le relaie pas, + // le challenge vise les clés de la session (alice), pas celles de bob. + let mut body = approve("tx-1"); + body["operator_hint"] = serde_json::json!(bob); + let (status, issued) = env.challenge(Some(&cookie), body).await; + assert_eq!(status, StatusCode::OK, "{issued}"); + assert_eq!(issued["body"]["action"], "approve_request"); + assert_eq!(issued["body"]["transaction_id"], "tx-1"); + assert_eq!(issued["body_hash"].as_str().unwrap().len(), 64); + assert!(issued["challenge_id"].is_string() && issued["action_id"].is_string()); + let keys = allowed(&issued); + assert_eq!(keys, env.credential_ids(alice).await, "{issued}"); + assert!(env + .credential_ids(bob) + .await + .iter() + .all(|k| !keys.contains(k))); + assert_eq!(env.actions_frozen().await, 1); +} + +#[tokio::test] +async fn the_console_prepares_nothing_without_a_session_or_outside_step_3() { + let mut env = env!(); + env.operator_with_key("alice", Role::CaOperateur).await; + let cookie = env.log_in("alice").await; + env.pending_request("tx-1").await; + + // Sans session, ou avec une session inventée. + for c in [None, Some("session=n-importe-quoi")] { + let (status, err) = env.challenge(c, approve("tx-1")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{err}"); + } + + // Une action que ca-server saurait exécuter, mais que la console ne propose + // pas encore (révocation : étape 4 ; registre : plus tard). + for action in [ + serde_json::json!({ "action": "revoke_certificate", "serial": "0a", "reason": 1, "comment": "x" }), + serde_json::json!({ "action": "set_role", "operator": "alice", "role": "admin" }), + ] { + let (status, err) = env.challenge(Some(&cookie), action).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(err["error"], "action_not_available"); + } + + // Une action inconnue, ou un corps qui n'est pas une action. + for body in [ + serde_json::json!({ "action": "delete_everything" }), + serde_json::json!({ "transaction_id": "tx-1" }), + ] { + let (status, err) = env.challenge(Some(&cookie), body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{err}"); + } + + // Un corps qui ne se déclare pas JSON. + let (status, _, _) = env + .post(CHALLENGE, approve("tx-1"), Some(&cookie), "text/plain") + .await; + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + + assert_eq!( + env.actions_frozen().await, + 0, + "rien n'a été figé côté ca-server" + ); +} + +/// Le rôle affiché par la console n'est pas une barrière (§3) : c'est +/// `ca-server` qui refuse une approbation à un administrateur, et la console +/// relaie son refus. +#[tokio::test] +async fn ca_server_judges_the_role_not_the_console() { + let mut env = env!(); + env.operator_with_key("root", Role::Admin).await; + let cookie = env.log_in("root").await; + env.pending_request("tx-1").await; + + let (status, err) = env.challenge(Some(&cookie), approve("tx-1")).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{err}"); + assert_eq!(env.actions_frozen().await, 0); + + // Et une demande qui n'existe pas n'est pas figée non plus. + let mut env2 = env!(); + env2.operator_with_key("alice", Role::RaOperateur).await; + let cookie = env2.log_in("alice").await; + let (status, err) = env2.challenge(Some(&cookie), approve("tx-inconnue")).await; + assert!(status.is_client_error(), "{status} {err}"); + assert_eq!(env2.actions_frozen().await, 0); +} diff --git a/bin/ra-console/tests/healthz.rs b/bin/ra-console/tests/healthz.rs index dc12902..01442a3 100644 --- a/bin/ra-console/tests/healthz.rs +++ b/bin/ra-console/tests/healthz.rs @@ -49,6 +49,7 @@ async fn healthz_needs_both_the_database_and_the_link() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool.clone()), + journal: Arc::new(ra_console::audit::NullRecorder), })); let (status, body) = get(&app).await; assert_eq!(status, axum::http::StatusCode::OK, "{body}"); @@ -67,6 +68,7 @@ async fn healthz_needs_both_the_database_and_the_link() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), })); let (status, body) = get(&app).await; assert_eq!( diff --git a/bin/ra-console/tests/login.rs b/bin/ra-console/tests/login.rs index 2f8e8be..8408966 100644 --- a/bin/ra-console/tests/login.rs +++ b/bin/ra-console/tests/login.rs @@ -114,6 +114,7 @@ impl Env { pool, link, login, + journal: Arc::new(ra_console::audit::NullRecorder), })); Some(Env { console, diff --git a/bin/ra-console/tests/register_relay.rs b/bin/ra-console/tests/register_relay.rs index c988f19..4ca43a7 100644 --- a/bin/ra-console/tests/register_relay.rs +++ b/bin/ra-console/tests/register_relay.rs @@ -112,6 +112,7 @@ impl Env { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), })); Some(Env { @@ -353,6 +354,7 @@ async fn an_unreachable_ca_server_gives_a_generic_bad_gateway() { link, login: common::login_service(pool.clone()), sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), })); let res = console diff --git a/bin/ra-console/tests/requests.rs b/bin/ra-console/tests/requests.rs index ccd1fbd..8a0b051 100644 --- a/bin/ra-console/tests/requests.rs +++ b/bin/ra-console/tests/requests.rs @@ -90,6 +90,7 @@ impl Env { pool: pool.clone(), link, login, + journal: Arc::new(ra_console::audit::NullRecorder), })); Some(Env { console, diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 4ca5ea1..b09f2dc 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -53,6 +53,29 @@ gardé par la console. - Pas encore de limitation de débit (l'endpoint est anonyme ; le jeton fait 256 bits et vit 24 h au plus) : voir `TODO.md`. +## Préparation d'une action signée (relais du challenge) + +`POST /api/v1/webauthn/challenge`, avec une session ouverte : le corps est l'action +demandée, dans la forme d'`oe_actions` (`{"action": "approve_request", +"transaction_id": "…", "comment": "…"}`, ou `reject_request`). La console relaie à +`ca-server` (`/internal/v1/challenge`), qui **fige** l'action et rend le corps qu'il +exécutera, son empreinte (`body_hash`) et les options WebAuthn à passer à la clé +(docs/WEBUI.md §4, étapes 1 à 3). + +- Le challenge est émis pour **l'opérateur de la session** : l'identifiant relayé + (`operator_hint`) vient de la session, jamais du navigateur. L'action est relue dans + l'énumération fermée d'`oe_actions` puis resérialisée : un champ en trop ne franchit + pas la console. +- Seules l'approbation et le rejet d'une demande sont préparés à ce stade (§15, étape + 3) ; toute autre action est refusée (`403 action_not_available`) sans solliciter + `ca-server`. +- Le rôle et l'état de la demande sont jugés par `ca-server` (un administrateur ne peut + pas approuver) ; la console relaie son refus. +- Chaque préparation est inscrite au journal de la console (`ra.action_challenge` : + opérateur, action, `action_id`, `body_hash`, statut), rapprochable du journal de + `ca-server`, qui fait foi. +- L'exécution (relais de l'assertion signée) est l'étape 3b. + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -87,8 +110,9 @@ gardé par la console. ## Ce qui n'existe pas encore -La connexion des opérateurs (login, sessions), la -lecture, les actions signées relayées, la révocation, le workflow d'incident et le -frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. L'image, le chart Helm et le +L'exécution des actions signées (étape 3b : relais de l'assertion), la révocation, le +workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La +connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas +encore décrites ici. L'image, le chart Helm et le `docker-compose.yml` de la console non plus. Le certificat client (3 mois) se renouvelle à la main pour l'instant.