feat(ops): sign readiness bundle
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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<Value> {
|
||||
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,
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
#[arg(long, env = "DETMIR_READINESS_SIGNING_KEY")]
|
||||
signing_key: Option<PathBuf>,
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
public_key_file: Option<String>,
|
||||
}
|
||||
|
||||
#[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::<String>();
|
||||
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<SignatureStatus> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<uid>`;
|
||||
- `DETMIR_GIT_COMMIT=<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
|
||||
|
||||
Reference in New Issue
Block a user