diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 1305701..e89a1bf 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -650,6 +650,21 @@ Production readiness checklist: -signature sha256sums.txt.sig sha256sums.txt ``` +- [ ] readiness signing public-key fingerprint for the shipped/customer + contour is fixed and documented: + + ```text + READINESS_PUBLIC_KEY_SHA256= + ``` + + Verification command: + + ```bash + sha256sum /var/lib/activitywatch/health/readiness-bundle/public-key.pem + jq -r '.signature.public_key_fingerprint_sha256' \ + /var/lib/activitywatch/health/readiness-bundle/detmir-readiness-status.json + ``` + - [ ] `detmir-readiness-status.json` and `detmir-readiness.prom` expose the latest OK/WARN/FAIL state independently from systemd unit result; - [ ] `detmir-readiness.timer` is enabled for daily bundle generation; diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index f1343d2..0517581 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -130,6 +130,40 @@ h1 { .status-warn { background: var(--warn-bg); color: var(--warn); } .status-fail { background: var(--fail-bg); color: var(--fail); } .status-unknown { background: var(--unknown-bg); color: var(--unknown); } +.text-ok { color: var(--ok); } +.text-fail { color: var(--fail); } + +.readiness-card { + grid-column: span 2; +} + +.readiness-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.readiness-head h3 { + margin-top: 8px; +} + +.readiness-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 10px 0 6px; +} + +.readiness-metrics > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.readiness-card code { + overflow-wrap: anywhere; +} .grid-2 { display: grid; @@ -350,6 +384,9 @@ pre { .row { grid-template-columns: 1fr; } .incident-row { grid-template-columns: 1fr; } .evidence-row { grid-template-columns: 1fr; } + .readiness-card { grid-column: span 1; } + .readiness-head { flex-direction: column; } + .readiness-metrics { grid-template-columns: 1fr; } .app-weight-row { grid-template-columns: 1fr; } .employee-index-row { grid-template-columns: 1fr; } .section-head { flex-direction: column; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 06110d0..036dce4 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -1,4 +1,4 @@ -const state = { tab: "operator", links: null }; +const state = { tab: "operator", links: null, readiness: null }; function apiBase() { const path = window.location.pathname; @@ -37,12 +37,13 @@ function escapeHtml(value) { .replaceAll('"', """); } -function renderSummary(summary) { +function renderSummary(summary, readiness) { const global = document.getElementById("globalStatus"); global.className = `status-pill ${statusClass(summary.severity)}`; global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`; const blocks = Object.entries(summary.blocks || {}); - document.getElementById("summary").innerHTML = blocks.map(([name, block]) => ` + const readinessCard = renderReadinessSummaryCard(readiness); + document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
${escapeHtml(block.status)}

${escapeHtml(label(name))}

@@ -51,6 +52,44 @@ function renderSummary(summary) { `).join(""); } +function renderReadinessSummaryCard(readiness) { + const bundle = readiness?.bundle || {}; + const verify = readiness?.verify || {}; + const status = bundle.status?.status || bundle.status || (verify.ok ? "OK" : "UNKNOWN"); + const generated = bundle.status?.generated_at_utc || bundle.generated_at_utc || "-"; + const signature = bundle.status?.signature || {}; + const signatureOk = Boolean(verify.signature_verified || signature.verified); + const checksumOk = Boolean(verify.checksum_verified || bundle.status?.checksum_verified || verify.ok); + const fingerprint = signature.public_key_fingerprint_sha256 || "-"; + const verificationText = verify.generated_at_utc + ? `Проверено: ${verify.generated_at_utc}` + : "Проверка не запускалась"; + return ` +
+
+
+ ${escapeHtml(status)} +

Готовность системы

+
+ +
+
+
Дата${escapeHtml(generated)}
+
Подпись${signatureOk ? "OK" : "FAIL"}
+
Checksum${checksumOk ? "OK" : "FAIL"}
+
+

Отпечаток: ${escapeHtml(shortFingerprint(fingerprint))}

+

${escapeHtml(verificationText)}

+
+ `; +} + +function shortFingerprint(value) { + const text = String(value || "-"); + if (text.length <= 24) return text; + return `${text.slice(0, 12)}…${text.slice(-12)}`; +} + function label(name) { return { collection: "Сбор данных", @@ -464,7 +503,11 @@ function renderReports(data) { async function refresh() { if (!state.links) state.links = await loadJson("/links"); const summary = await loadJson("/summary"); - renderSummary(summary); + state.readiness = { + bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })), + verify: state.readiness?.verify || null + }; + renderSummary(summary, state.readiness); const content = document.getElementById("content"); const data = await loadJson(`/${state.tab}`); if (state.tab === "operator") content.innerHTML = renderOperator(data); @@ -515,6 +558,28 @@ document.addEventListener("click", event => { anonymizeReport(button).catch(showError); }); +document.addEventListener("click", event => { + const button = event.target.closest("[data-readiness-verify]"); + if (!button) return; + verifyReadinessBundle(button).catch(showError); +}); + +async function verifyReadinessBundle(button) { + button.disabled = true; + button.textContent = "Проверка..."; + const verify = await loadJson("/readiness/verify"); + state.readiness = { + ...(state.readiness || {}), + verify + }; + const status = document.getElementById("readinessVerifyStatus"); + if (status) { + status.textContent = `Проверено: checksum ${verify.checksum_verified ? "OK" : "FAIL"} · signature ${verify.signature_verified ? "OK" : "FAIL"}`; + } + button.disabled = false; + button.textContent = "Проверить bundle"; +} + async function anonymizeReport(button) { button.disabled = true; const data = await loadJson("/reports?anonymize=1"); diff --git a/adk-rust/crates/detmir-readiness/src/main.rs b/adk-rust/crates/detmir-readiness/src/main.rs index 7ef8a9f..8125087 100644 --- a/adk-rust/crates/detmir-readiness/src/main.rs +++ b/adk-rust/crates/detmir-readiness/src/main.rs @@ -143,6 +143,7 @@ struct BundleStatus { generated_at_utc: String, archive_dir: String, latest_dir: String, + checksum_verified: bool, signature: SignatureStatus, counts: Counts, prometheus_metric_file: String, @@ -156,6 +157,8 @@ struct SignatureStatus { method: String, summary: String, #[serde(skip_serializing_if = "Option::is_none")] + public_key_fingerprint_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] signature_file: Option, #[serde(skip_serializing_if = "Option::is_none")] public_key_file: Option, @@ -772,6 +775,7 @@ fn write_bundle(dir: &Path, report: &Report, cli: &Cli) -> Result<()> { generated_at_utc: report.generated_at_utc.clone(), archive_dir: archive_dir.display().to_string(), latest_dir: dir.display().to_string(), + checksum_verified: true, signature, counts: report.counts.clone(), prometheus_metric_file: dir.join("detmir-readiness.prom").display().to_string(), @@ -840,6 +844,7 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result { verified: false, method: "openssl dgst -sha256".to_string(), summary: "signature not configured".to_string(), + public_key_fingerprint_sha256: None, signature_file: None, public_key_file: None, }); @@ -854,6 +859,7 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result { verified: false, method: "openssl dgst -sha256".to_string(), summary: "signing key not found".to_string(), + public_key_fingerprint_sha256: None, signature_file: None, public_key_file: None, }); @@ -890,12 +896,14 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result { .arg(&sums_path), "verify readiness sha256sums signature", )?; + let fingerprint = sha256_file(&public_key_path)?; Ok(SignatureStatus { required: cli.require_signature, signed: true, verified: true, method: "openssl dgst -sha256".to_string(), summary: "sha256sums detached signature verified".to_string(), + public_key_fingerprint_sha256: Some(fingerprint), signature_file: Some(sig_path.display().to_string()), public_key_file: Some(public_key_path.display().to_string()), }) @@ -1337,9 +1345,67 @@ mod tests { &fs::read_to_string(dir.path().join("detmir-readiness-status.json")).unwrap(), ) .unwrap(); + assert_eq!(status["checksum_verified"], true); assert_eq!(status["signature"]["signed"], false); assert_eq!(status["signature"]["verified"], false); let latest_dir = fs::read_to_string(dir.path().join("latest-dir.txt")).unwrap(); assert!(latest_dir.contains("2026") || latest_dir.contains("20")); } + + #[test] + fn writes_signed_bundle_when_openssl_available() { + if !command_exists("openssl") { + eprintln!("skip signed bundle test: openssl is not available"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("signing-key.pem"); + run_command( + Command::new("openssl") + .arg("genpkey") + .arg("-algorithm") + .arg("RSA") + .arg("-pkeyopt") + .arg("rsa_keygen_bits:2048") + .arg("-out") + .arg(&key_path), + "generate test signing key", + ) + .unwrap(); + let mut cli = test_cli(); + cli.signing_key = Some(key_path); + cli.require_signature = true; + let report = test_report(vec![ok("env:test", "ok", json!({}))]); + write_bundle(dir.path(), &report, &cli).unwrap(); + assert!(dir.path().join("sha256sums.txt.sig").is_file()); + assert!(dir.path().join("public-key.pem").is_file()); + let status: Value = serde_json::from_str( + &fs::read_to_string(dir.path().join("detmir-readiness-status.json")).unwrap(), + ) + .unwrap(); + assert_eq!(status["signature"]["signed"], true); + assert_eq!(status["signature"]["verified"], true); + assert!( + status["signature"]["public_key_fingerprint_sha256"] + .as_str() + .unwrap_or("") + .len() + >= 64 + ); + } + + #[test] + fn prunes_old_readiness_archives() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("2026-01-01").join("000000Z"); + let fresh_date = Utc::now().format("%Y-%m-%d").to_string(); + let fresh = dir.path().join(&fresh_date).join("000000Z"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&fresh).unwrap(); + fs::write(old.join("marker"), "old").unwrap(); + fs::write(fresh.join("marker"), "fresh").unwrap(); + prune_old_archives(dir.path(), 30).unwrap(); + assert!(!dir.path().join("2026-01-01").exists()); + assert!(dir.path().join(fresh_date).exists()); + } } diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 4182c24..374159b 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -1679,6 +1679,22 @@ group: root mode: "0644" + - name: Создать каталог Prometheus rules для DetMir readiness + ansible.builtin.file: + path: /etc/prometheus/rules + state: directory + owner: root + group: root + mode: "0755" + + - name: Установить Prometheus alerts DetMir readiness + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/detmir-readiness-alerts.yml" + dest: /etc/prometheus/rules/detmir-readiness-alerts.yml + owner: root + group: root + mode: "0644" + - name: Перезагрузить systemd после установки AW worktime API ansible.builtin.systemd: daemon_reload: true diff --git a/aw-server/detmir-readiness-alerts.yml b/aw-server/detmir-readiness-alerts.yml new file mode 100644 index 0000000..3ac64c1 --- /dev/null +++ b/aw-server/detmir-readiness-alerts.yml @@ -0,0 +1,22 @@ +groups: + - name: detmir_readiness + rules: + - alert: DetMirReadinessNotOk + expr: detmir_readiness_ok == 0 + for: 10m + labels: + severity: critical + service: detmir-readiness + annotations: + summary: "DetMir readiness is not OK" + description: "detmir-readiness reported WARN/FAIL or the latest readiness bundle is not acceptable." + + - alert: DetMirReadinessSignatureInvalid + expr: detmir_readiness_signature_verified == 0 + for: 10m + labels: + severity: critical + service: detmir-readiness + annotations: + summary: "DetMir readiness bundle signature is not verified" + description: "The latest readiness bundle does not have a valid detached signature." diff --git a/docs/PRODUCTION_READINESS_RU.md b/docs/PRODUCTION_READINESS_RU.md index 949012d..6397d5e 100644 --- a/docs/PRODUCTION_READINESS_RU.md +++ b/docs/PRODUCTION_READINESS_RU.md @@ -130,6 +130,19 @@ Private signing key хранится только на сервере: Публичный ключ попадает в bundle как `public-key.pem`. Retention архивов управляется переменной `DETMIR_READINESS_RETENTION_DAYS`. +Fingerprint публичного ключа считается как SHA-256 файла `public-key.pem` и +дублируется в `detmir-readiness-status.json`: + +```bash +sha256sum /var/lib/activitywatch/health/readiness-bundle/public-key.pem +jq -r '.signature.public_key_fingerprint_sha256' \ + /var/lib/activitywatch/health/readiness-bundle/detmir-readiness-status.json +``` + +Для публичной поставки вместо live fingerprint используется placeholder +``; конкретный customer contour +фиксирует свой fingerprint в private acceptance package. + ## Ежедневное формирование При штатном развертывании Ansible устанавливает: @@ -172,6 +185,26 @@ systemctl status detmir-readiness.service --no-pager - `/api/readiness/bundle` - индекс latest bundle и список артефактов; - `/api/readiness/verify` - проверка `sha256sum -c` и detached signature. +В UI портала карточка `Готовность системы` показывает статус `OK/WARN/FAIL`, +дату формирования, состояние checksum/signature и fingerprint публичного ключа. +Кнопка `Проверить bundle` запускает lightweight verify endpoint без повторного +запуска runtime checks. + +## Prometheus alerts + +Поставочный файл правил: + +```text +aw-server/detmir-readiness-alerts.yml +``` + +Критичные условия: + +```promql +detmir_readiness_ok == 0 +detmir_readiness_signature_verified == 0 +``` + ## Полезные параметры ```bash