From 59d5e1f1fc55874ed396ff71f455db4f4a77d333 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 3 Jun 2026 20:56:35 +0300 Subject: [PATCH] feat(ops): sign readiness bundle --- adk-rust/RUNBOOK.md | 5 + adk-rust/crates/detmir-portal/src/main.rs | 135 ++++++++ adk-rust/crates/detmir-readiness/src/main.rs | 289 +++++++++++++++++- ansible/deploy_aw_server.yml | 55 ++++ ansible/deploy_detmir_portal.yml | 3 + ansible/templates/proxmox-web-gateway.conf.j2 | 7 + aw-server/detmir-readiness.service | 2 +- docs/PRODUCTION_READINESS_RU.md | 44 ++- 8 files changed, 532 insertions(+), 8 deletions(-) diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index fbd1834..1305701 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -646,9 +646,14 @@ Production readiness checklist: ```bash cd /var/lib/activitywatch/health/readiness-bundle sha256sum -c sha256sums.txt + openssl dgst -sha256 -verify public-key.pem \ + -signature sha256sums.txt.sig sha256sums.txt ``` +- [ ] `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; +- [ ] DetMir portal readiness endpoints return latest/bundle/verify data; - [ ] `detmir-check --json`, `detmir-status --json` and Grafana check are green; - [ ] rollback path for changed binaries/env files is known. diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 6bbdbae..0d28e2f 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -103,6 +103,13 @@ struct Cli { )] evidence_root: PathBuf, + #[arg( + long, + default_value = "/var/lib/activitywatch/health/readiness-bundle", + env = "DETMIR_PORTAL_READINESS_BUNDLE_DIR" + )] + readiness_bundle_dir: PathBuf, + #[arg(long, default_value_t = 30, env = "DETMIR_PORTAL_EVIDENCE_LIMIT")] evidence_limit: u32, @@ -610,6 +617,9 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { ), "/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"), "/api/health" => respond_json(request, &build_health(&build_snapshot(args))), + "/api/readiness/latest" => respond_json(request, &readiness_latest(args)), + "/api/readiness/bundle" => respond_json(request, &readiness_bundle(args)), + "/api/readiness/verify" => respond_json(request, &readiness_verify(args)), "/api/summary" => respond_json(request, &build_summary(&build_snapshot(args))), "/api/operator" => { let snapshot = build_snapshot(args); @@ -686,6 +696,15 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> { }), ); } + if path == "/api/readiness/latest" { + return respond_json(request, &readiness_latest(args)); + } + if path == "/api/readiness/bundle" { + return respond_json(request, &readiness_bundle(args)); + } + if path == "/api/readiness/verify" { + return respond_json(request, &readiness_verify(args)); + } if path == "/api/dlp/evidence" { return respond_json(request, &build_dlp_evidence_response(args)); } @@ -710,6 +729,121 @@ fn normalize_path(url: &str) -> String { } } +fn readiness_latest(args: &Cli) -> Value { + read_json_file( + &args + .readiness_bundle_dir + .join("detmir-readiness-latest.json"), + ) + .unwrap_or_else(|err| { + json!({ + "ok": false, + "generated_at_utc": now(), + "error": err.to_string(), + }) + }) +} + +fn readiness_bundle(args: &Cli) -> Value { + let dir = &args.readiness_bundle_dir; + let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| { + json!({ + "ok": false, + "error": err.to_string(), + }) + }); + let latest_dir = fs::read_to_string(dir.join("latest-dir.txt")) + .unwrap_or_default() + .trim() + .to_string(); + let artifacts = [ + "detmir-readiness-latest.json", + "detmir-readiness-act.md", + "detmir-readiness-act.html", + "sha256sums.txt", + "sha256sums.txt.sig", + "public-key.pem", + "detmir-readiness-status.json", + "detmir-readiness.prom", + ] + .into_iter() + .filter_map(|name| { + let path = dir.join(name); + path.metadata().ok().map(|meta| { + json!({ + "name": name, + "bytes": meta.len(), + "available": true, + }) + }) + }) + .collect::>(); + json!({ + "ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false), + "generated_at_utc": now(), + "bundle_dir": dir.display().to_string(), + "latest_archive_dir": latest_dir, + "status": status, + "artifacts": artifacts, + }) +} + +fn readiness_verify(args: &Cli) -> Value { + let dir = &args.readiness_bundle_dir; + let checksum = run_in_dir( + dir, + Command::new("sha256sum").arg("-c").arg("sha256sums.txt"), + ); + let sig_path = dir.join("sha256sums.txt.sig"); + let pub_path = dir.join("public-key.pem"); + let signature = if sig_path.is_file() && pub_path.is_file() { + run_in_dir( + dir, + Command::new("openssl") + .arg("dgst") + .arg("-sha256") + .arg("-verify") + .arg("public-key.pem") + .arg("-signature") + .arg("sha256sums.txt.sig") + .arg("sha256sums.txt"), + ) + } else { + Err("signature files are not available".to_string()) + }; + json!({ + "ok": checksum.is_ok() && signature.is_ok(), + "generated_at_utc": now(), + "checksum_verified": checksum.is_ok(), + "signature_verified": signature.is_ok(), + "checksum_error": checksum.err(), + "signature_error": signature.err(), + }) +} + +fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> { + let output = command + .current_dir(dir) + .output() + .map_err(|err| format!("run command in {}: {err}", dir.display()))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .trim() + .to_string()) + } +} + fn query_flag(url: &str, key: &str) -> bool { let Some(query) = url.split_once('?').map(|(_, query)| query) else { return false; @@ -4434,6 +4568,7 @@ mod tests { state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), + readiness_bundle_dir: dir.path().join("readiness-bundle"), evidence_limit: 10, evidence_max_bytes: 1024, json_smoke: false, diff --git a/adk-rust/crates/detmir-readiness/src/main.rs b/adk-rust/crates/detmir-readiness/src/main.rs index c817f96..7ef8a9f 100644 --- a/adk-rust/crates/detmir-readiness/src/main.rs +++ b/adk-rust/crates/detmir-readiness/src/main.rs @@ -19,6 +19,7 @@ const DEFAULT_GRAFANA_ENV_FILE: &str = "/etc/detmir-grafana-check.env"; const DEFAULT_GRAFANA_URL: &str = "http://127.0.0.1:3000"; const DEFAULT_GRAFANA_DATASOURCE_UID: &str = "influxdb_aw"; const DEFAULT_SYSTEMD_SERVICES: &str = "activitywatch-server,aw-worktime-api,aw-worktime-influx-exporter.timer,aw-dlp-influx-exporter.timer"; +const DEFAULT_RETENTION_DAYS: i64 = 30; #[derive(Debug, Parser)] #[command( @@ -83,6 +84,19 @@ struct Cli { #[arg(long)] output_dir: Option, + #[arg(long, env = "DETMIR_READINESS_SIGNING_KEY")] + signing_key: Option, + + #[arg(long, env = "DETMIR_READINESS_REQUIRE_SIGNATURE")] + require_signature: bool, + + #[arg( + long, + env = "DETMIR_READINESS_RETENTION_DAYS", + default_value_t = DEFAULT_RETENTION_DAYS + )] + retention_days: i64, + #[arg(long, env = "DETMIR_GIT_COMMIT", default_value = "unknown")] git_commit: String, } @@ -107,7 +121,7 @@ struct GeneratedBy { version: String, } -#[derive(Debug, Default, Serialize)] +#[derive(Debug, Clone, Default, Serialize)] struct Counts { ok: usize, warn: usize, @@ -122,6 +136,31 @@ struct Check { details: Value, } +#[derive(Debug, Serialize)] +struct BundleStatus { + ok: bool, + status: StatusLevel, + generated_at_utc: String, + archive_dir: String, + latest_dir: String, + signature: SignatureStatus, + counts: Counts, + prometheus_metric_file: String, +} + +#[derive(Debug, Clone, Serialize)] +struct SignatureStatus { + required: bool, + signed: bool, + verified: bool, + method: String, + summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + signature_file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + public_key_file: Option, +} + #[derive(Debug, Clone)] struct InfluxConfig { prefix: &'static str, @@ -683,7 +722,7 @@ fn readiness_exit_code(status: StatusLevel) -> i32 { fn write_outputs(cli: &Cli, report: &Report) -> Result<()> { if let Some(dir) = &cli.output_dir { - write_bundle(dir, report)?; + write_bundle(dir, report, cli)?; } if let Some(path) = &cli.output_json { write_text(path, &serde_json::to_string_pretty(report)?)?; @@ -700,8 +739,12 @@ fn write_outputs(cli: &Cli, report: &Report) -> Result<()> { Ok(()) } -fn write_bundle(dir: &Path, report: &Report) -> Result<()> { +fn write_bundle(dir: &Path, report: &Report, cli: &Cli) -> Result<()> { fs::create_dir_all(dir).with_context(|| format!("create output dir: {}", dir.display()))?; + let archive_dir = archive_dir_for(dir); + fs::create_dir_all(&archive_dir) + .with_context(|| format!("create archive dir: {}", archive_dir.display()))?; + let files = [ ( "detmir-readiness-latest.json", @@ -712,7 +755,7 @@ fn write_bundle(dir: &Path, report: &Report) -> Result<()> { ]; let mut sums = Vec::new(); for (name, content) in files { - let path = dir.join(name); + let path = archive_dir.join(name); write_text(&path, &content)?; sums.push((name.to_string(), sha256_file(&path)?)); } @@ -720,7 +763,203 @@ fn write_bundle(dir: &Path, report: &Report) -> Result<()> { .into_iter() .map(|(name, sum)| format!("{sum} {name}\n")) .collect::(); - write_text(&dir.join("sha256sums.txt"), &sums_text)?; + write_text(&archive_dir.join("sha256sums.txt"), &sums_text)?; + + let signature = sign_bundle(&archive_dir, cli)?; + let status = BundleStatus { + ok: report.ok && (!cli.require_signature || signature.verified), + status: report.status, + generated_at_utc: report.generated_at_utc.clone(), + archive_dir: archive_dir.display().to_string(), + latest_dir: dir.display().to_string(), + signature, + counts: report.counts.clone(), + prometheus_metric_file: dir.join("detmir-readiness.prom").display().to_string(), + }; + write_text( + &archive_dir.join("detmir-readiness-status.json"), + &serde_json::to_string_pretty(&status)?, + )?; + write_text( + &archive_dir.join("detmir-readiness.prom"), + &render_prometheus_metrics(&status), + )?; + copy_latest_bundle(dir, &archive_dir)?; + prune_old_archives(dir, cli.retention_days)?; + Ok(()) +} + +fn archive_dir_for(root: &Path) -> PathBuf { + let now = Utc::now(); + root.join(now.format("%Y-%m-%d").to_string()) + .join(now.format("%H%M%SZ").to_string()) +} + +fn copy_latest_bundle(root: &Path, archive_dir: &Path) -> Result<()> { + for name in [ + "detmir-readiness-latest.json", + "detmir-readiness-act.md", + "detmir-readiness-act.html", + "sha256sums.txt", + "sha256sums.txt.sig", + "public-key.pem", + "detmir-readiness-status.json", + "detmir-readiness.prom", + ] { + let src = archive_dir.join(name); + if src.is_file() { + fs::copy(&src, root.join(name)) + .with_context(|| format!("copy latest bundle file: {}", src.display()))?; + } else { + let latest = root.join(name); + if latest.exists() { + fs::remove_file(&latest) + .with_context(|| format!("remove stale latest file: {}", latest.display()))?; + } + } + } + write_text( + &root.join("latest-dir.txt"), + &format!("{}\n", archive_dir.display()), + ) +} + +fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result { + let sums_path = archive_dir.join("sha256sums.txt"); + let sig_path = archive_dir.join("sha256sums.txt.sig"); + let public_key_path = archive_dir.join("public-key.pem"); + let Some(key_path) = cli.signing_key.as_deref() else { + if cli.require_signature { + anyhow::bail!( + "readiness bundle signature is required but signing key is not configured" + ); + } + return Ok(SignatureStatus { + required: false, + signed: false, + verified: false, + method: "openssl dgst -sha256".to_string(), + summary: "signature not configured".to_string(), + signature_file: None, + public_key_file: None, + }); + }; + if !key_path.is_file() { + if cli.require_signature { + anyhow::bail!("readiness signing key not found: {}", key_path.display()); + } + return Ok(SignatureStatus { + required: false, + signed: false, + verified: false, + method: "openssl dgst -sha256".to_string(), + summary: "signing key not found".to_string(), + signature_file: None, + public_key_file: None, + }); + } + run_command( + Command::new("openssl") + .arg("pkey") + .arg("-in") + .arg(key_path) + .arg("-pubout") + .arg("-out") + .arg(&public_key_path), + "extract readiness public key", + )?; + run_command( + Command::new("openssl") + .arg("dgst") + .arg("-sha256") + .arg("-sign") + .arg(key_path) + .arg("-out") + .arg(&sig_path) + .arg(&sums_path), + "sign readiness sha256sums", + )?; + run_command( + Command::new("openssl") + .arg("dgst") + .arg("-sha256") + .arg("-verify") + .arg(&public_key_path) + .arg("-signature") + .arg(&sig_path) + .arg(&sums_path), + "verify readiness sha256sums signature", + )?; + Ok(SignatureStatus { + required: cli.require_signature, + signed: true, + verified: true, + method: "openssl dgst -sha256".to_string(), + summary: "sha256sums detached signature verified".to_string(), + signature_file: Some(sig_path.display().to_string()), + public_key_file: Some(public_key_path.display().to_string()), + }) +} + +fn run_command(command: &mut Command, context: &str) -> Result<()> { + let output = command.output().with_context(|| format!("run {context}"))?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!( + "{context} failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +fn render_prometheus_metrics(status: &BundleStatus) -> String { + let ready = if status.ok { 1 } else { 0 }; + let signed = if status.signature.verified { 1 } else { 0 }; + let status_value = match status.status { + StatusLevel::Ok => 0, + StatusLevel::Warn => 1, + StatusLevel::Fail | StatusLevel::Unknown => 2, + }; + format!( + "# HELP detmir_readiness_ok DetMir readiness result, 1 means OK.\n\ + # TYPE detmir_readiness_ok gauge\n\ + detmir_readiness_ok {ready}\n\ + # HELP detmir_readiness_status DetMir readiness status: 0 OK, 1 WARN, 2 FAIL.\n\ + # TYPE detmir_readiness_status gauge\n\ + detmir_readiness_status {status_value}\n\ + # HELP detmir_readiness_signature_verified DetMir readiness detached signature verification result.\n\ + # TYPE detmir_readiness_signature_verified gauge\n\ + detmir_readiness_signature_verified {signed}\n\ + detmir_readiness_checks_ok {}\n\ + detmir_readiness_checks_warn {}\n\ + detmir_readiness_checks_fail {}\n", + status.counts.ok, status.counts.warn, status.counts.fail + ) +} + +fn prune_old_archives(root: &Path, retention_days: i64) -> Result<()> { + if retention_days <= 0 { + return Ok(()); + } + let cutoff = Utc::now().date_naive() - chrono::Duration::days(retention_days); + for entry in + fs::read_dir(root).with_context(|| format!("read output dir: {}", root.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + let Ok(date) = chrono::NaiveDate::parse_from_str(&name, "%Y-%m-%d") else { + continue; + }; + if date < cutoff { + fs::remove_dir_all(entry.path()) + .with_context(|| format!("prune readiness archive: {}", entry.path().display()))?; + } + } Ok(()) } @@ -977,6 +1216,33 @@ mod tests { } } + fn test_cli() -> Cli { + Cli { + json: false, + aw_env_file: PathBuf::from("/nonexistent/aw.env"), + grafana_env_file: PathBuf::from("/nonexistent/grafana.env"), + systemd_services: DEFAULT_SYSTEMD_SERVICES.to_string(), + timeout_seconds: 1, + skip_systemd: true, + skip_influx_write: true, + allow_disabled_influx: true, + skip_grafana: true, + grafana_url: None, + grafana_user: None, + grafana_password: None, + grafana_datasource_uid: DEFAULT_GRAFANA_DATASOURCE_UID.to_string(), + output_json: None, + output_markdown: None, + output_html: None, + output_pdf: None, + output_dir: None, + signing_key: None, + require_signature: false, + retention_days: DEFAULT_RETENTION_DAYS, + git_commit: "abc123".to_string(), + } + } + #[test] fn parses_env_file_without_quotes() { let dir = tempfile::tempdir().unwrap(); @@ -1056,13 +1322,24 @@ mod tests { fn writes_bundle_with_sha256sums() { let dir = tempfile::tempdir().unwrap(); let report = test_report(vec![ok("env:test", "ok", json!({}))]); - write_bundle(dir.path(), &report).unwrap(); + let cli = test_cli(); + write_bundle(dir.path(), &report, &cli).unwrap(); assert!(dir.path().join("detmir-readiness-latest.json").is_file()); assert!(dir.path().join("detmir-readiness-act.md").is_file()); assert!(dir.path().join("detmir-readiness-act.html").is_file()); + assert!(dir.path().join("detmir-readiness-status.json").is_file()); + assert!(dir.path().join("detmir-readiness.prom").is_file()); let sums = fs::read_to_string(dir.path().join("sha256sums.txt")).unwrap(); assert!(sums.contains("detmir-readiness-latest.json")); assert!(sums.contains("detmir-readiness-act.md")); assert!(sums.contains("detmir-readiness-act.html")); + 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"], 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")); } } diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 8664fd7..4182c24 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -1608,6 +1608,61 @@ mode: "0755" when: detmir_readiness_rust_binary.stat.exists | default(false) + - name: Создать private каталог DetMir readiness signing + ansible.builtin.file: + path: /etc/detmir-readiness + state: directory + owner: root + group: root + mode: "0700" + + - name: Проверить private key DetMir readiness signing + ansible.builtin.stat: + path: /etc/detmir-readiness/signing-key.pem + register: detmir_readiness_signing_key + + - name: Сгенерировать private key DetMir readiness signing + ansible.builtin.command: + argv: + - openssl + - genpkey + - -algorithm + - RSA + - -pkeyopt + - rsa_keygen_bits:3072 + - -out + - /etc/detmir-readiness/signing-key.pem + when: not (detmir_readiness_signing_key.stat.exists | default(false)) + no_log: true + + - name: Зафиксировать права private key DetMir readiness signing + ansible.builtin.file: + path: /etc/detmir-readiness/signing-key.pem + owner: root + group: root + mode: "0600" + + - name: Создать private env DetMir readiness + ansible.builtin.copy: + dest: /etc/detmir-readiness.env + content: "" + owner: root + group: root + mode: "0600" + force: false + + - name: Настроить private env DetMir readiness signing + ansible.builtin.blockinfile: + path: /etc/detmir-readiness.env + marker: "# {mark} ANSIBLE MANAGED DETMIR READINESS" + block: | + DETMIR_READINESS_SIGNING_KEY=/etc/detmir-readiness/signing-key.pem + DETMIR_READINESS_REQUIRE_SIGNATURE=true + DETMIR_READINESS_RETENTION_DAYS={{ detmir_readiness_retention_days | default(30) }} + owner: root + group: root + mode: "0600" + - name: Установить systemd service DetMir readiness bundle ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/detmir-readiness.service" diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index bdce8ac..4e13663 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -14,6 +14,7 @@ detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}" detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json" detmir_portal_ueba_policy_path: "/etc/detmir-portal-ueba-policy.yaml" + detmir_portal_readiness_bundle_dir: "{{ detmir_portal_readiness_bundle_dir_override | default('/var/lib/activitywatch/health/readiness-bundle', true) }}" tasks: - name: Check local detmir-portal binary @@ -55,6 +56,7 @@ DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/detmir-portal/evidence + DETMIR_PORTAL_READINESS_BUNDLE_DIR={{ detmir_portal_readiness_bundle_dir }} DETMIR_PORTAL_EVIDENCE_LIMIT=30 DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 @@ -235,6 +237,7 @@ DETMIR_PORTAL_STATE_DIR=/var/lib/activitywatch/dlp-evidence DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence + DETMIR_PORTAL_READINESS_BUNDLE_DIR=/var/lib/activitywatch/health/readiness-bundle DETMIR_PORTAL_EVIDENCE_LIMIT=30 DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN={{ detmir_evidence_upload_token_slurp.content | b64decode | trim }} diff --git a/ansible/templates/proxmox-web-gateway.conf.j2 b/ansible/templates/proxmox-web-gateway.conf.j2 index 281a2c2..33ffeb5 100644 --- a/ansible/templates/proxmox-web-gateway.conf.j2 +++ b/ansible/templates/proxmox-web-gateway.conf.j2 @@ -268,6 +268,13 @@ server { proxy_redirect off; } + location ^~ /portal/api/readiness { + proxy_set_header Authorization ""; + proxy_set_header X-Remote-User $remote_user; + proxy_pass http://192.0.2.13:8721/api/readiness; + proxy_redirect off; + } + location /portal/ { proxy_set_header Authorization ""; proxy_set_header X-Remote-User $remote_user; diff --git a/aw-server/detmir-readiness.service b/aw-server/detmir-readiness.service index 7c6e3e1..5853899 100644 --- a/aw-server/detmir-readiness.service +++ b/aw-server/detmir-readiness.service @@ -11,7 +11,7 @@ EnvironmentFile=-/etc/detmir-readiness.env ExecStart=/usr/local/bin/detmir-readiness --output-dir /var/lib/activitywatch/health/readiness-bundle User=root Group=root -SuccessExitStatus=0 2 3 +SuccessExitStatus=0 2 StandardOutput=journal StandardError=journal SyslogIdentifier=detmir-readiness diff --git a/docs/PRODUCTION_READINESS_RU.md b/docs/PRODUCTION_READINESS_RU.md index 142cbe7..949012d 100644 --- a/docs/PRODUCTION_READINESS_RU.md +++ b/docs/PRODUCTION_READINESS_RU.md @@ -86,19 +86,50 @@ detmir-readiness --output-dir /var/lib/activitywatch/health/readiness-bundle - `detmir-readiness-latest.json` - машинный отчет; - `detmir-readiness-act.md` - акт готовности для оператора; - `detmir-readiness-act.html` - HTML-версия акта; -- `sha256sums.txt` - контрольные суммы bundle-файлов. +- `sha256sums.txt` - контрольные суммы bundle-файлов; +- `sha256sums.txt.sig` - detached signature для `sha256sums.txt`; +- `public-key.pem` - публичный ключ проверки подписи; +- `detmir-readiness-status.json` - короткий machine-readable статус bundle; +- `detmir-readiness.prom` - Prometheus textfile metric. + +Архив хранится по датам: + +```text +/var/lib/activitywatch/health/readiness-bundle/ + 2026-06-03/ + 062000Z/ + detmir-readiness-latest.json + detmir-readiness-act.md + detmir-readiness-act.html + sha256sums.txt + sha256sums.txt.sig + public-key.pem +``` + +Файлы в корне `readiness-bundle/` являются latest-копией последнего архива. Проверка целостности: ```bash cd /var/lib/activitywatch/health/readiness-bundle sha256sum -c sha256sums.txt +openssl dgst -sha256 -verify public-key.pem \ + -signature sha256sums.txt.sig sha256sums.txt ``` В JSON и акт добавляются технические поля `generated_by`, `host`, `version`, `git_commit`, а также раздел `Ограничения проверки`. Секреты, токены и пароли в артефакты не включаются. +Private signing key хранится только на сервере: + +```text +/etc/detmir-readiness/signing-key.pem +``` + +Публичный ключ попадает в bundle как `public-key.pem`. Retention архивов +управляется переменной `DETMIR_READINESS_RETENTION_DAYS`. + ## Ежедневное формирование При штатном развертывании Ansible устанавливает: @@ -123,6 +154,9 @@ systemctl status detmir-readiness.service --no-pager Поддерживаемые private env-переключатели: +- `DETMIR_READINESS_SIGNING_KEY=/etc/detmir-readiness/signing-key.pem`; +- `DETMIR_READINESS_REQUIRE_SIGNATURE=true`; +- `DETMIR_READINESS_RETENTION_DAYS=30`; - `DETMIR_READINESS_SKIP_SYSTEMD=true`; - `DETMIR_READINESS_SKIP_INFLUX_WRITE=true`; - `DETMIR_READINESS_ALLOW_DISABLED_INFLUX=true`; @@ -130,6 +164,14 @@ systemctl status detmir-readiness.service --no-pager - `DETMIR_GRAFANA_DATASOURCE_UID=`; - `DETMIR_GIT_COMMIT=`. +## Portal endpoints + +`detmir-portal` публикует read-only endpoints: + +- `/api/readiness/latest` - последний readiness JSON; +- `/api/readiness/bundle` - индекс latest bundle и список артефактов; +- `/api/readiness/verify` - проверка `sha256sum -c` и detached signature. + ## Полезные параметры ```bash