From 17e8e7b56aa7185b8e1fef0cfd070a0a204bf98f Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Tue, 2 Jun 2026 22:17:25 +0300 Subject: [PATCH] feat(detmir): add portal incident acknowledgement --- adk-rust/RUNBOOK.md | 23 +- adk-rust/crates/detmir-portal/src/main.rs | 454 +++++++++++++++--- .../crates/detmir-portal/src/static/app.css | 37 ++ .../crates/detmir-portal/src/static/app.js | 54 ++- ansible/deploy_detmir_portal.yml | 1 + ansible/templates/proxmox-web-gateway.conf.j2 | 1 + 6 files changed, 508 insertions(+), 62 deletions(-) diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 47ce9ea..b82e7e0 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -1587,13 +1587,28 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg pinned to the same HTML report. Production smoke: AW UI, Worktime, 1C brief, and 1C actions returned `200 text/html`; Grafana links correctly redirect to `/login` when no Grafana session exists. + - incident operator workflow added to portal: incidents now have stable + IDs, operator state is persisted in + `/var/lib/detmir-portal/incidents-state.json`, audit events append to + `/var/lib/detmir-portal/audit.jsonl`, nginx passes `$remote_user` as + `X-Remote-User`, and the UI exposes `В работу` / `Назначить` actions. + This layer only records acknowledgement/assignment metadata; it does not + heal, restart, mutate AW/DLP/1C, or touch pfSense/Telegram. Gates: + `cargo fmt --all -- --check`, `cargo test -p detmir-portal` (`5 passed`), + `cargo clippy -p detmir-portal --all-targets -- -D warnings`, release + build OK. Production smoke: `POST /portal/api/incidents/action` returned + `200 application/json`, actor resolved to `detmir`, state/audit files + were written, smoke state was removed after validation, portal health + stayed `200`, `detmir-status` stayed `OK / ok_for_operator=true`, and + failed units stayed `0`. Local Playwright smoke against a temporary + portal instance verified `Инциденты ИБ`, `В работу`, persisted UI state, + disabled ack button, and zero JS errors. Отложить: -- post-MVP развитие `detmir-portal`: role-aware views, incident comments, - acknowledge/assign, safe check-now action, daily owner report, historical - trends, AI summary with strict source citations, action buttons with - allowlist and audit log. Детальный план: +- post-MVP развитие `detmir-portal`: role-aware views, safe check-now action, + daily owner report, historical trends, AI summary with strict source + citations, action buttons with allowlist and audit log. Детальный план: `docs/DETMIR_PORTAL_GUI_PLAN_RU.md`; - перенос Telegram bot runtime снят с плана: Python остается постоянным runtime, Rust используется только для backend helpers; diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index d3e5344..196862e 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; -use std::io::Read; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::PathBuf; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -9,7 +11,7 @@ use chrono::{SecondsFormat, Utc}; use clap::Parser; use reqwest::blocking::Client; use reqwest::header::{CONNECTION, HeaderValue}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; @@ -61,6 +63,13 @@ struct Cli { #[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")] timeout_seconds: u64, + #[arg( + long, + default_value = "/var/lib/detmir-portal", + env = "DETMIR_PORTAL_STATE_DIR" + )] + state_dir: PathBuf, + #[arg(long)] json_smoke: bool, } @@ -101,12 +110,71 @@ struct SummaryResponse { #[derive(Debug, Serialize)] struct IncidentItem { + id: String, status: String, kind: String, source: String, summary: String, generated_at_utc: String, link: String, + acknowledged: bool, + #[serde(skip_serializing_if = "Option::is_none")] + acknowledged_at_utc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + actor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + assigned_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at_utc: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct IncidentStateFile { + incidents: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct IncidentActionState { + state: String, + actor: String, + updated_at_utc: String, + #[serde(skip_serializing_if = "Option::is_none")] + acknowledged_at_utc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + assigned_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, +} + +#[derive(Debug, Deserialize)] +struct IncidentActionRequest { + id: String, + action: String, + #[serde(default)] + assigned_to: Option, + #[serde(default)] + comment: Option, +} + +#[derive(Debug, Serialize)] +struct IncidentActionResponse { + ok: bool, + id: String, + state: IncidentActionState, +} + +#[derive(Debug, Serialize)] +struct IncidentAuditEntry { + generated_at_utc: String, + actor: String, + id: String, + action: String, + #[serde(skip_serializing_if = "Option::is_none")] + assigned_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, } #[derive(Debug, Serialize)] @@ -145,10 +213,11 @@ fn run() -> Result { let args = Cli::parse(); if args.json_smoke { let snapshot = build_snapshot(&args); + let incident_state = load_incident_state_best_effort(&args); let smoke = json!({ "health": build_health(&snapshot), "summary": build_summary(&snapshot), - "incidents": build_incidents(&snapshot), + "incidents": build_incidents(&snapshot, &incident_state), }); println!("{}", serde_json::to_string_pretty(&smoke)?); return Ok(if build_health(&snapshot).ok { 0 } else { 2 }); @@ -165,10 +234,14 @@ fn run() -> Result { } fn handle_request(request: Request, args: &Cli) -> Result<()> { - if request.method() != &Method::Get { + let method = request.method().clone(); + let path = normalize_path(request.url()); + if method == Method::Post && path == "/api/incidents/action" { + return handle_incident_action(request, args); + } + if method != Method::Get { return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain"); } - let path = normalize_path(request.url()); match path.as_str() { "/" | "/operator" | "/manager" | "/owner" | "/incidents" => respond_text( request, @@ -188,7 +261,8 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { "/api/summary" => respond_json(request, &build_summary(&build_snapshot(args))), "/api/operator" => { let snapshot = build_snapshot(args); - respond_json(request, &build_operator(&snapshot)) + let incident_state = load_incident_state_best_effort(args); + respond_json(request, &build_operator(&snapshot, &incident_state)) } "/api/manager" => { let snapshot = build_snapshot(args); @@ -200,7 +274,8 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { } "/api/incidents" => { let snapshot = build_snapshot(args); - respond_json(request, &build_incidents(&snapshot)) + let incident_state = load_incident_state_best_effort(args); + respond_json(request, &build_incidents(&snapshot, &incident_state)) } "/api/links" => respond_json(request, &links()), _ => respond_text( @@ -465,7 +540,7 @@ fn build_summary(snapshot: &Snapshot) -> SummaryResponse { } } -fn build_operator(snapshot: &Snapshot) -> Value { +fn build_operator(snapshot: &Snapshot, incident_state: &IncidentStateFile) -> Value { json!({ "generated_at_utc": snapshot.generated_at_utc, "summary": build_summary(snapshot), @@ -474,7 +549,7 @@ fn build_operator(snapshot: &Snapshot) -> Value { "failed_units": snapshot.failed_units, "grafana_data": grafana_service(snapshot), "links": links(), - "incidents": build_incidents(snapshot), + "incidents": build_incidents(snapshot, incident_state), }) } @@ -530,7 +605,7 @@ fn build_owner(snapshot: &Snapshot) -> Value { }) } -fn build_incidents(snapshot: &Snapshot) -> Vec { +fn build_incidents(snapshot: &Snapshot, state: &IncidentStateFile) -> Vec { let mut incidents = Vec::new(); for source in [ ("detmir_status", &snapshot.detmir_status), @@ -540,60 +615,62 @@ fn build_incidents(snapshot: &Snapshot) -> Vec { ("one_c", &snapshot.one_c), ] { if !source.1.ok { - incidents.push(IncidentItem { - status: source.1.status.clone(), - kind: "health".to_string(), - source: source.0.to_string(), - summary: source.1.summary.clone(), - generated_at_utc: snapshot.generated_at_utc.clone(), - link: "/portal/operator".to_string(), - }); + incidents.push(incident_item( + &source.1.status, + "health", + source.0, + &source.1.summary, + &snapshot.generated_at_utc, + "/portal/operator", + state, + )); } } if let Some(check) = snapshot.detmir_check.payload.as_ref() { if let Some(services) = check.get("services").and_then(Value::as_array) { for service in services { if service.get("ok").and_then(Value::as_bool) == Some(false) { - incidents.push(IncidentItem { - status: if service.get("required").and_then(Value::as_bool) == Some(true) { - "FAIL" - } else { - "WARN" - } - .to_string(), - kind: "service".to_string(), - source: service - .get("name") - .and_then(Value::as_str) - .unwrap_or("service") - .to_string(), - summary: service - .get("error") - .and_then(Value::as_str) - .unwrap_or("service check failed") - .to_string(), - generated_at_utc: snapshot.generated_at_utc.clone(), - link: "/portal/operator".to_string(), - }); + let status = if service.get("required").and_then(Value::as_bool) == Some(true) { + "FAIL" + } else { + "WARN" + }; + let source = service + .get("name") + .and_then(Value::as_str) + .unwrap_or("service"); + let summary = service + .get("error") + .and_then(Value::as_str) + .unwrap_or("service check failed"); + incidents.push(incident_item( + status, + "service", + source, + summary, + &snapshot.generated_at_utc, + "/portal/operator", + state, + )); } } } if let Some(buckets) = check.get("buckets").and_then(Value::as_array) { for bucket in buckets { if bucket.get("ok").and_then(Value::as_bool) == Some(false) { - incidents.push(IncidentItem { - status: bucket - .get("status") - .and_then(Value::as_str) - .unwrap_or("WARN") - .to_string(), - kind: "collector".to_string(), - source: bucket - .get("bucket") - .and_then(Value::as_str) - .unwrap_or("bucket") - .to_string(), - summary: format!( + let status = bucket + .get("status") + .and_then(Value::as_str) + .unwrap_or("WARN"); + let source = bucket + .get("bucket") + .and_then(Value::as_str) + .unwrap_or("bucket"); + incidents.push(incident_item( + status, + "collector", + source, + &format!( "{} is {}", bucket .get("label") @@ -604,9 +681,10 @@ fn build_incidents(snapshot: &Snapshot) -> Vec { .and_then(Value::as_str) .unwrap_or("not OK") ), - generated_at_utc: snapshot.generated_at_utc.clone(), - link: "/portal/operator".to_string(), - }); + &snapshot.generated_at_utc, + "/portal/operator", + state, + )); } } } @@ -614,6 +692,230 @@ fn build_incidents(snapshot: &Snapshot) -> Vec { incidents } +fn incident_item( + status: &str, + kind: &str, + source: &str, + summary: &str, + generated_at_utc: &str, + link: &str, + state: &IncidentStateFile, +) -> IncidentItem { + let id = incident_id(kind, source, summary); + let saved = state.incidents.get(&id); + IncidentItem { + id, + status: status.to_string(), + kind: kind.to_string(), + source: source.to_string(), + summary: summary.to_string(), + generated_at_utc: generated_at_utc.to_string(), + link: link.to_string(), + acknowledged: saved + .map(|item| item.state == "acknowledged") + .unwrap_or(false), + acknowledged_at_utc: saved.and_then(|item| item.acknowledged_at_utc.clone()), + actor: saved.map(|item| item.actor.clone()), + assigned_to: saved.and_then(|item| item.assigned_to.clone()), + comment: saved.and_then(|item| item.comment.clone()), + updated_at_utc: saved.map(|item| item.updated_at_utc.clone()), + } +} + +fn incident_id(kind: &str, source: &str, summary: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in format!("{kind}\n{source}\n{summary}").as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{kind}-{hash:016x}") +} + +fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> { + let actor = request_actor(&request); + let mut body = String::new(); + request + .as_reader() + .take(32 * 1024) + .read_to_string(&mut body)?; + let response = apply_incident_action(args, &actor, &body); + match response { + Ok(response) => respond_json(request, &response), + Err(err) => respond_text( + request, + StatusCode(400), + &serde_json::to_string_pretty(&json!({ + "ok": false, + "error": err.to_string() + }))?, + "application/json; charset=utf-8", + ), + } +} + +fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result { + let action: IncidentActionRequest = + serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?; + let id = validate_short_token(&action.id, "id", 128)?; + let action_name = validate_action(&action.action)?; + let assigned_to = sanitize_optional_text(action.assigned_to, 80); + let comment = sanitize_optional_text(action.comment, 500); + let now = now(); + + let mut state_file = load_incident_state(args)?; + let previous = state_file.incidents.get(&id).cloned(); + let acknowledged_at_utc = if action_name == "ack" { + previous + .as_ref() + .and_then(|item| item.acknowledged_at_utc.clone()) + .or_else(|| Some(now.clone())) + } else if action_name == "clear" { + None + } else { + previous + .as_ref() + .and_then(|item| item.acknowledged_at_utc.clone()) + }; + let state_name = if action_name == "clear" { + "open" + } else if acknowledged_at_utc.is_some() { + "acknowledged" + } else { + "assigned" + }; + let next = IncidentActionState { + state: state_name.to_string(), + actor: actor.to_string(), + updated_at_utc: now.clone(), + acknowledged_at_utc, + assigned_to: assigned_to + .clone() + .or_else(|| previous.and_then(|item| item.assigned_to)), + comment: comment.clone(), + }; + state_file.incidents.insert(id.clone(), next.clone()); + save_incident_state(args, &state_file)?; + append_incident_audit( + args, + &IncidentAuditEntry { + generated_at_utc: now, + actor: actor.to_string(), + id: id.clone(), + action: action_name.to_string(), + assigned_to, + comment, + }, + )?; + Ok(IncidentActionResponse { + ok: true, + id, + state: next, + }) +} + +fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile { + match load_incident_state(args) { + Ok(state) => state, + Err(err) => { + eprintln!("detmir-portal incident state read failed: {err:#}"); + IncidentStateFile::default() + } + } +} + +fn load_incident_state(args: &Cli) -> Result { + let path = incident_state_path(args); + if !path.exists() { + return Ok(IncidentStateFile::default()); + } + let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&data).with_context(|| format!("parse {}", path.display())) +} + +fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> { + fs::create_dir_all(&args.state_dir) + .with_context(|| format!("create {}", args.state_dir.display()))?; + let path = incident_state_path(args); + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, serde_json::to_vec_pretty(state)?) + .with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?; + Ok(()) +} + +fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> { + fs::create_dir_all(&args.state_dir) + .with_context(|| format!("create {}", args.state_dir.display()))?; + let path = args.state_dir.join("audit.jsonl"); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("open {}", path.display()))?; + serde_json::to_writer(&mut file, entry)?; + file.write_all(b"\n")?; + Ok(()) +} + +fn incident_state_path(args: &Cli) -> PathBuf { + args.state_dir.join("incidents-state.json") +} + +fn request_actor(request: &Request) -> String { + for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] { + if let Some(value) = request + .headers() + .iter() + .find(|header| header.field.equiv(name)) + .map(|header| header.value.as_str().trim()) + { + if !value.is_empty() { + return sanitize_text(value, 80); + } + } + } + "local".to_string() +} + +fn validate_short_token(value: &str, name: &str, max_len: usize) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > max_len { + return Err(anyhow!("{name} length is invalid")); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.')) + { + return Err(anyhow!("{name} contains unsupported characters")); + } + Ok(value.to_string()) +} + +fn validate_action(value: &str) -> Result<&'static str> { + match value.trim() { + "ack" | "acknowledge" => Ok("ack"), + "assign" => Ok("assign"), + "clear" | "reopen" => Ok("clear"), + _ => Err(anyhow!("unsupported incident action")), + } +} + +fn sanitize_optional_text(value: Option, max_len: usize) -> Option { + value + .map(|text| sanitize_text(&text, max_len)) + .filter(|text| !text.is_empty()) +} + +fn sanitize_text(value: &str, max_len: usize) -> String { + value + .chars() + .filter(|ch| !ch.is_control()) + .take(max_len) + .collect::() + .trim() + .to_string() +} + fn links() -> PortalLinks { PortalLinks { portal: "/portal/".to_string(), @@ -949,4 +1251,46 @@ mod tests { let block = collection_block(Some(&value)); assert_eq!(block.status, "OK"); } + + #[test] + fn incident_id_is_stable() { + assert_eq!( + incident_id("service", "grafana-data", "stale"), + incident_id("service", "grafana-data", "stale") + ); + assert_ne!( + incident_id("service", "grafana-data", "stale"), + incident_id("service", "grafana-data", "fresh") + ); + } + + #[test] + fn incident_item_applies_ack_state() { + let id = incident_id("service", "grafana-data", "stale"); + let mut state = IncidentStateFile::default(); + state.incidents.insert( + id.clone(), + IncidentActionState { + state: "acknowledged".to_string(), + actor: "detmir".to_string(), + updated_at_utc: "2026-06-02T18:00:00Z".to_string(), + acknowledged_at_utc: Some("2026-06-02T18:00:00Z".to_string()), + assigned_to: Some("operator".to_string()), + comment: Some("checking".to_string()), + }, + ); + let item = incident_item( + "FAIL", + "service", + "grafana-data", + "stale", + "2026-06-02T18:01:00Z", + "/portal/operator", + &state, + ); + assert_eq!(item.id, id); + assert!(item.acknowledged); + assert_eq!(item.actor.as_deref(), Some("detmir")); + assert_eq!(item.assigned_to.as_deref(), Some("operator")); + } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 4c93de9..e75bab9 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -109,6 +109,12 @@ h1 { color: var(--muted); } +.small { + font-size: 12px; + line-height: 1.35; + margin-top: 4px; +} + .status-pill, .badge { display: inline-flex; @@ -146,6 +152,35 @@ h1 { border-radius: 8px; } +.incident-row { + grid-template-columns: minmax(150px, 1fr) minmax(220px, 2fr) auto auto; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.small-button { + min-height: 32px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--link); + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.small-button:disabled { + cursor: default; + opacity: 0.55; +} + .links { display: flex; flex-wrap: wrap; @@ -179,4 +214,6 @@ pre { .topbar { flex-direction: column; } h1 { font-size: 24px; } .row { grid-template-columns: 1fr; } + .incident-row { grid-template-columns: 1fr; } + .actions { justify-content: flex-start; } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index e4467fb..24be661 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -11,6 +11,16 @@ async function loadJson(path) { return response.json(); } +async function postJson(path, payload) { + const response = await fetch(`${apiBase()}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); + return response.json(); +} + function statusClass(status) { const s = String(status || "UNKNOWN").toLowerCase(); if (s === "ok" || s === "true") return "status-ok"; @@ -153,10 +163,20 @@ function renderOwner(data) { function renderIncidentsList(items) { if (!items || items.length === 0) return `

Активных проблем нет.

`; return `
${items.map(item => ` -
- ${escapeHtml(item.source)} - ${escapeHtml(item.summary)} +
+
+ ${escapeHtml(item.source)} +
${escapeHtml(item.kind)} · ${escapeHtml(item.id)}
+
+
+ ${escapeHtml(item.summary)} + ${item.acknowledged ? `
В работе: ${escapeHtml(item.assigned_to || item.actor || "оператор")} · ${escapeHtml(item.comment || "")}
` : ""} +
${escapeHtml(item.status)} +
+ + +
`).join("")}
`; } @@ -196,5 +216,33 @@ document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); +document.addEventListener("click", event => { + const button = event.target.closest("[data-incident-action]"); + if (!button) return; + incidentAction(button).catch(showError); +}); + +async function incidentAction(button) { + const id = button.dataset.incidentId; + const action = button.dataset.incidentAction; + const payload = { id, action }; + if (action === "ack") { + const comment = window.prompt("Комментарий к взятию в работу", ""); + if (comment === null) return; + payload.comment = comment; + } + if (action === "assign") { + const assignedTo = window.prompt("Кому назначить", ""); + if (assignedTo === null || assignedTo.trim() === "") return; + payload.assigned_to = assignedTo; + const comment = window.prompt("Комментарий", ""); + if (comment === null) return; + payload.comment = comment; + } + button.disabled = true; + await postJson("/incidents/action", payload); + await refresh(); +} + refresh().catch(showError); setInterval(() => refresh().catch(showError), 60000); diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index a240b5b..2a15f8f 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -45,6 +45,7 @@ DETMIR_PORTAL_WORKTIME_URL=http://10.10.10.13:5610 DETMIR_PORTAL_ONE_C_URL=http://10.10.10.2:8710 DETMIR_PORTAL_TIMEOUT_SECONDS=10 + DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal - name: Install detmir-portal systemd service ansible.builtin.copy: diff --git a/ansible/templates/proxmox-web-gateway.conf.j2 b/ansible/templates/proxmox-web-gateway.conf.j2 index 1d3be16..fa3667d 100644 --- a/ansible/templates/proxmox-web-gateway.conf.j2 +++ b/ansible/templates/proxmox-web-gateway.conf.j2 @@ -263,6 +263,7 @@ server { location /portal/ { proxy_set_header Authorization ""; + proxy_set_header X-Remote-User $remote_user; proxy_pass http://127.0.0.1:8720/; proxy_redirect off; }