fix(ops): harden worktime recovery and collector guard

This commit is contained in:
igor04091968
2026-06-07 11:41:20 +03:00
parent 9c72539401
commit aa641cba2f
21 changed files with 537 additions and 119 deletions
+1
View File
@@ -197,6 +197,7 @@ collectors.
- [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md)
- [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md)
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
- [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md)
- [Экосистема сборщиков](docs/COLLECTOR_ECOSYSTEM_RU.md)
- [Стратегия внедрения](docs/DEPLOYMENT_STRATEGY_RU.md)
@@ -1,5 +1,5 @@
use std::{
collections::{BTreeMap, HashSet},
collections::{BTreeMap, BTreeSet, HashSet},
env,
ffi::OsStr,
fs::{self, File},
@@ -4033,6 +4033,9 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
.unwrap_or_else(|| "powershell_primary".to_string());
let worktime_legacy_fallback_enabled =
json_bool(&config, &["collectors", "worktimeLegacyFallbackEnabled"]).unwrap_or(true);
let file_ops_enabled = json_bool(&config, &["collectors", "fileOpsEnabled"]).unwrap_or(true);
let file_ops_mode = json_string(&config, &["collectors", "fileOpsMode"])
.unwrap_or_else(|| "powershell_primary".to_string());
let worktime_bucket = get_bucket_health(
&api_base,
@@ -4078,9 +4081,24 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
}
let task_defs = guard_task_definitions(&config);
if interactive_stale {
let missing_fileops_sessions =
if file_ops_enabled && file_ops_mode.eq_ignore_ascii_case("rust_primary") {
missing_rust_collector_sessions(
&process_snapshot.processes,
"file-operations-collector",
&["browser-domains-collector", "dlp-endpoint-collector"],
)
} else {
Vec::new()
};
let launch_needed = interactive_stale || !missing_fileops_sessions.is_empty();
if launch_needed {
let active_legacy_collectors = active_legacy_collector_count(&process_snapshot.processes);
if active_legacy_collectors > 0 && process_snapshot.command_line_query_ok {
if interactive_stale
&& missing_fileops_sessions.is_empty()
&& active_legacy_collectors > 0
&& process_snapshot.command_line_query_ok
{
problems.push(
"interactive bucket stale but legacy collectors are already running; skip launch tasks to avoid duplicates"
.to_string(),
@@ -4092,6 +4110,14 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
"activeLegacyCollectors": active_legacy_collectors
}));
} else {
if !missing_fileops_sessions.is_empty() {
actions.push(json!({
"action": "detect-missing-fileops",
"applied": false,
"mode": "diagnostic",
"missingSessions": missing_fileops_sessions.clone()
}));
}
for task in &task_defs {
if !task.task_name.starts_with("ActivityWatch Launch ") {
problems.push(format!("refuse non-allowlisted task {}", task.task_name));
@@ -4160,6 +4186,11 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
"userId": task.user_id
})).collect::<Vec<_>>(),
"interactiveStale": interactive_stale,
"fileOperationsPresence": {
"enabled": file_ops_enabled,
"mode": file_ops_mode,
"missingSessions": missing_fileops_sessions
},
"actions": actions,
"problems": problems,
"quarantine": runtime.quarantine.clone(),
@@ -4273,6 +4304,44 @@ fn active_legacy_collector_count(processes: &[ProcessInfo]) -> usize {
.count()
}
fn missing_rust_collector_sessions(
processes: &[ProcessInfo],
required_subcommand: &str,
peer_subcommands: &[&str],
) -> Vec<u32> {
let required_sessions = rust_collector_sessions(processes, required_subcommand);
let mut expected_sessions = BTreeSet::new();
for subcommand in peer_subcommands {
expected_sessions.extend(rust_collector_sessions(processes, subcommand));
}
expected_sessions
.into_iter()
.filter(|session_id| !required_sessions.contains(session_id))
.collect()
}
fn rust_collector_sessions(processes: &[ProcessInfo], subcommand: &str) -> BTreeSet<u32> {
let subcommand = subcommand.to_ascii_lowercase();
processes
.iter()
.filter_map(|process| {
let name = process.name.as_deref().unwrap_or_default();
if !name.eq_ignore_ascii_case("aw-windows-telemetry.exe") {
return None;
}
let command_line = process
.command_line
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if !command_line.contains(&subcommand) {
return None;
}
process.session_id
})
.collect()
}
fn duplicate_legacy_collectors(processes: &[ProcessInfo]) -> Vec<LegacyCollectorDuplicate> {
let mut groups: BTreeMap<(&'static str, u32), Vec<&ProcessInfo>> = BTreeMap::new();
for process in processes {
@@ -6227,6 +6296,61 @@ SERVICE_NAME: AWatchRusCollectorGuard
assert_eq!(active_legacy_collector_count(&processes), 4);
}
#[test]
fn collector_guard_detects_missing_rust_fileops_by_session() {
let processes = vec![
ProcessInfo {
name: Some("aw-windows-telemetry.exe".to_string()),
pid: Some(100),
session_id: Some(2),
created_unix_seconds: Some(10),
command_line: Some(
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" browser-domains-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
.to_string(),
),
},
ProcessInfo {
name: Some("aw-windows-telemetry.exe".to_string()),
pid: Some(101),
session_id: Some(2),
created_unix_seconds: Some(11),
command_line: Some(
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" dlp-endpoint-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
.to_string(),
),
},
ProcessInfo {
name: Some("aw-windows-telemetry.exe".to_string()),
pid: Some(200),
session_id: Some(3),
created_unix_seconds: Some(20),
command_line: Some(
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" browser-domains-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
.to_string(),
),
},
ProcessInfo {
name: Some("aw-windows-telemetry.exe".to_string()),
pid: Some(201),
session_id: Some(3),
created_unix_seconds: Some(21),
command_line: Some(
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" file-operations-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
.to_string(),
),
},
];
assert_eq!(
missing_rust_collector_sessions(
&processes,
"file-operations-collector",
&["browser-domains-collector", "dlp-endpoint-collector"],
),
vec![2]
);
}
#[test]
fn validate_deployment_parses_wmic_process_csv() {
let csv = br#"Node,CommandLine,Name,ProcessId,SessionId
@@ -62,9 +62,13 @@ const AW_SERVER_FILES: &[&str] = &[
"aw-server/aw-server.env.example",
"aw-server/aw-sw-cleanup.js",
"aw-server/aw-worktime-api.service",
"aw-server/aw-worktime-autoheal.service",
"aw-server/aw-worktime-autoheal.timer",
"aw-server/aw-worktime-prewarm.service",
"aw-server/aw-worktime-prewarm.timer",
"aw-server/aw-worktime-panel.js",
"aw-server/aw-worktime-ui-bridge.service",
"aw-server/aw-worktime-ui-bridge.timer",
"aw-server/install_aw_server.sh",
"aw-server/settings/classes-worktime.json",
"aw-server/settings/views-default.json",
@@ -21,6 +21,15 @@ const REQUIRED_RELATIVE_FILES: &[&str] = &[
"windows/validate-deployment.ps1",
"ansible/deploy_aw_windows.yml",
"aw-server/install_aw_server.sh",
"aw-server/aw-worktime-api.service",
"aw-server/aw-worktime-autoheal.service",
"aw-server/aw-worktime-autoheal.timer",
"aw-server/aw-worktime-prewarm.service",
"aw-server/aw-worktime-prewarm.timer",
"aw-server/aw-worktime-ui-bridge.service",
"aw-server/aw-worktime-ui-bridge.timer",
"aw-server/aw-rus-healthd.service",
"aw-server/aw-rus-healthd.timer",
"scripts/rebuild_install_kit.sh",
"scripts/validate_install_kit.sh",
"scripts/check_install_kit_vs_repo.sh",
@@ -451,7 +460,10 @@ mod tests {
use tempfile::tempdir;
use zip::write::SimpleFileOptions;
use super::{Config, DEFAULT_KIT_DIR, check_archive_composition, read_manifest, validate};
use super::{
Config, DEFAULT_KIT_DIR, REQUIRED_RELATIVE_FILES, check_archive_composition, read_manifest,
validate,
};
#[test]
fn manifest_parser_rejects_bad_line() {
@@ -474,8 +486,14 @@ mod tests {
);
let report = validate(&cfg);
assert!(report.ok, "{report:#?}");
assert_eq!(report.manifest_completeness.tracked_files, 10);
assert_eq!(report.archive_composition.files, 11);
assert_eq!(
report.manifest_completeness.tracked_files,
REQUIRED_RELATIVE_FILES.len() - 1
);
assert_eq!(
report.archive_composition.files,
REQUIRED_RELATIVE_FILES.len()
);
}
#[test]
@@ -520,36 +538,13 @@ mod tests {
fn create_fixture(root: &std::path::Path, matching_archives: bool) {
let kit = root.join(DEFAULT_KIT_DIR);
for rel in [
"windows/deploy-ensemble.ps1",
"windows/aw-windows-telemetry.exe",
"windows/validate-deployment.ps1",
"ansible/deploy_aw_windows.yml",
"aw-server/install_aw_server.sh",
"scripts/rebuild_install_kit.sh",
"scripts/validate_install_kit.sh",
"scripts/check_install_kit_vs_repo.sh",
"scripts/quality-gate.sh",
] {
for rel in fixture_files_without_manifest() {
let path = kit.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, rel).unwrap();
}
fs::write(kit.join("README-INSTALL-KIT.txt"), "readme").unwrap();
let files = [
"README-INSTALL-KIT.txt",
"windows/deploy-ensemble.ps1",
"windows/aw-windows-telemetry.exe",
"windows/validate-deployment.ps1",
"ansible/deploy_aw_windows.yml",
"aw-server/install_aw_server.sh",
"scripts/rebuild_install_kit.sh",
"scripts/validate_install_kit.sh",
"scripts/check_install_kit_vs_repo.sh",
"scripts/quality-gate.sh",
];
let mut manifest = String::new();
for rel in files {
for rel in fixture_files_without_manifest() {
let path = kit.join(rel);
let digest = super::sha256_file(&path).unwrap();
manifest.push_str(&format!("{digest} {DEFAULT_KIT_DIR}/{rel}\n"));
@@ -559,26 +554,23 @@ mod tests {
write_tar(root, matching_archives);
}
fn archive_files(matching: bool) -> Vec<(&'static str, &'static [u8])> {
let mut files = vec![
("README-INSTALL-KIT.txt", b"readme".as_slice()),
("MANIFEST.txt", b"manifest".as_slice()),
("windows/deploy-ensemble.ps1", b"deploy".as_slice()),
("windows/aw-windows-telemetry.exe", b"rust-exe".as_slice()),
("windows/validate-deployment.ps1", b"validate".as_slice()),
("ansible/deploy_aw_windows.yml", b"ansible".as_slice()),
("aw-server/install_aw_server.sh", b"server".as_slice()),
("scripts/rebuild_install_kit.sh", b"rebuild".as_slice()),
(
"scripts/validate_install_kit.sh",
b"validate-kit".as_slice(),
),
("scripts/check_install_kit_vs_repo.sh", b"check".as_slice()),
("scripts/quality-gate.sh", b"quality".as_slice()),
];
fn fixture_files_without_manifest() -> Vec<&'static str> {
REQUIRED_RELATIVE_FILES
.iter()
.copied()
.filter(|rel| *rel != "MANIFEST.txt")
.collect()
}
fn archive_files(matching: bool) -> Vec<(&'static str, Vec<u8>)> {
let mut files: Vec<(&'static str, Vec<u8>)> = REQUIRED_RELATIVE_FILES
.iter()
.copied()
.map(|rel| (rel, rel.as_bytes().to_vec()))
.collect();
if !matching {
files.pop();
files.push(("scripts/other.sh", b"other".as_slice()));
files.push(("scripts/other.sh", b"other".to_vec()));
}
files
}
@@ -591,7 +583,7 @@ mod tests {
for (rel, data) in archive_files(true) {
zip.start_file(format!("{DEFAULT_KIT_DIR}/{rel}"), options)
.unwrap();
zip.write_all(data).unwrap();
zip.write_all(&data).unwrap();
}
zip.finish().unwrap();
}
@@ -607,7 +599,11 @@ mod tests {
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, format!("{DEFAULT_KIT_DIR}/{rel}"), data)
.append_data(
&mut header,
format!("{DEFAULT_KIT_DIR}/{rel}"),
data.as_slice(),
)
.unwrap();
}
builder.finish().unwrap();
+78 -6
View File
@@ -327,7 +327,7 @@ fn load_config() -> Config {
.filter(|item| !item.is_empty())
.collect(),
events_cache_ttl_seconds: env_i64("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", 30).max(0),
worktime_events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).max(1000),
worktime_events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).clamp(100, 50_000),
aw_http_timeout_seconds: env_f64("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", 8.0).max(0.5),
source_http_timeout_seconds: env_f64("AW_WORKTIME_SOURCE_HTTP_TIMEOUT_SECONDS", 1.5)
.max(0.25),
@@ -1771,11 +1771,34 @@ impl App {
host: &str,
interactive_required: bool,
) -> (Vec<Value>, Vec<Value>) {
let critical_max_age = self.config.manager_critical_source_max_age_seconds;
let rust_sessions_bucket = sessions_bucket(host);
let watcher_afk_bucket = format!("aw-watcher-afk_{host}");
let rust_sessions_fresh = self
.latest_bucket_event(&rust_sessions_bucket)
.and_then(|event| {
event
.timestamp
.as_deref()
.and_then(parse_iso_utc)
.map(|dt| (Utc::now() - dt).num_seconds().max(0) <= critical_max_age)
})
.unwrap_or(false);
let watcher_afk_fresh = self
.latest_bucket_event(&watcher_afk_bucket)
.and_then(|event| {
event
.timestamp
.as_deref()
.and_then(parse_iso_utc)
.map(|dt| (Utc::now() - dt).num_seconds().max(0) <= critical_max_age)
})
.unwrap_or(false);
let specs = vec![
(
"worktime_sessions",
"RDP worktime sessions",
sessions_bucket(host),
rust_sessions_bucket,
self.config.manager_critical_source_max_age_seconds,
true,
false,
@@ -1891,6 +1914,18 @@ impl App {
} else {
format!("stale ({}s)", age.unwrap())
};
let legacy_rdp_covered = legacy_rdp_covered_by_rust_sources(
source_id,
status,
rust_sessions_fresh,
watcher_afk_fresh,
);
let effective_required = required && !legacy_rdp_covered;
if legacy_rdp_covered {
status = "inactive";
summary =
"legacy RDP source covered by fresh Rust worktime/watcher sources".to_string();
}
if interactive_only && !interactive_required && status != "ok" {
status = "inactive";
summary = "inactive: no active interactive users".to_string();
@@ -1903,7 +1938,7 @@ impl App {
"bucket_id": bucket,
"timestamp": event.as_ref().and_then(|e| e.timestamp.clone()).unwrap_or_default(),
"age_seconds": age,
"required": required,
"required": effective_required,
"interactive_only": interactive_only,
"interactive_required": interactive_required,
"max_age_seconds": max_age,
@@ -1913,13 +1948,13 @@ impl App {
if !matches!(status, "ok" | "inactive") {
actions.push(action(
"source_freshness_review",
if required { "critical" } else { "medium" },
if effective_required { "critical" } else { "medium" },
"ops",
if required { "today" } else { "3d" },
if effective_required { "today" } else { "3d" },
&format!("Источник '{label}' в состоянии {}: {}.", source_status_label(status), summary),
"Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.",
"",
json!({"source_id": source_id, "bucket_id": bucket, "age_seconds": age, "required": required}),
json!({"source_id": source_id, "bucket_id": bucket, "age_seconds": age, "required": effective_required}),
));
}
sources.push(source);
@@ -1975,6 +2010,18 @@ fn source_status_label(status: &str) -> &str {
}
}
fn legacy_rdp_covered_by_rust_sources(
source_id: &str,
status: &str,
rust_sessions_fresh: bool,
watcher_afk_fresh: bool,
) -> bool {
matches!(source_id, "rdp_window" | "rdp_afk")
&& status != "ok"
&& rust_sessions_fresh
&& watcher_afk_fresh
}
fn source_summary(event: &AwEvent) -> String {
let d = &event.data;
let signal = value_string(d, "signalType");
@@ -3459,6 +3506,31 @@ mod tests {
assert_eq!(rollups[0]["actions_count"], 1);
}
#[test]
fn stale_legacy_rdp_sources_are_covered_by_fresh_rust_sources() {
assert!(legacy_rdp_covered_by_rust_sources(
"rdp_window",
"fail",
true,
true
));
assert!(legacy_rdp_covered_by_rust_sources(
"rdp_afk", "warn", true, true
));
assert!(!legacy_rdp_covered_by_rust_sources(
"rdp_window",
"fail",
true,
false
));
assert!(!legacy_rdp_covered_by_rust_sources(
"worktime_sessions",
"fail",
true,
true
));
}
#[test]
fn sanitize_trend_point_removes_legacy_action_only_rollups() {
let point = sanitize_trend_point(json!({
+6
View File
@@ -725,6 +725,12 @@
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
AW_WORKTIME_HOST={{ aw_effective_worktime_host | default(aw_effective_monitored_windows_hostname | default('HOST-EXAMPLE')) }}
AW_WORKTIME_EVENTS_LIMIT={{ aw_worktime_events_limit | default(250) }}
AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS={{ aw_worktime_aw_http_timeout_seconds | default(6) }}
AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS={{ aw_worktime_events_cache_ttl_seconds | default(300) }}
AW_WORKTIME_REPORT_CACHE_TTL_SECONDS={{ aw_worktime_report_cache_ttl_seconds | default(300) }}
AW_WORKTIME_REPORT_STALE_TTL_SECONDS={{ aw_worktime_report_stale_ttl_seconds | default(3600) }}
AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS={{ aw_worktime_report_disk_stale_ttl_seconds | default(86400) }}
AW_DLP_IOC_DIR={{ aw_dlp_ioc_workdir }}/output
AW_DLP_POLICY_ENGINE_BIND_HOST={{ aw_dlp_policy_engine_bind_host }}
AW_DLP_POLICY_ENGINE_PORT={{ aw_dlp_policy_engine_port }}
+6
View File
@@ -12,6 +12,12 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa
aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}"
aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610"
aw_worktime_timezone: "Europe/Moscow"
aw_worktime_events_limit: 250
aw_worktime_aw_http_timeout_seconds: 6
aw_worktime_events_cache_ttl_seconds: 300
aw_worktime_report_cache_ttl_seconds: 300
aw_worktime_report_stale_ttl_seconds: 3600
aw_worktime_report_disk_stale_ttl_seconds: 86400
aw_worktime_influx_enabled: false
aw_worktime_influx_url: "http://<INFLUXDB_HOST>:8086"
aw_worktime_influx_org: "proxmox"
+6
View File
@@ -12,6 +12,12 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa
aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}"
aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610"
aw_worktime_timezone: "Europe/Moscow"
aw_worktime_events_limit: 250
aw_worktime_aw_http_timeout_seconds: 6
aw_worktime_events_cache_ttl_seconds: 300
aw_worktime_report_cache_ttl_seconds: 300
aw_worktime_report_stale_ttl_seconds: 3600
aw_worktime_report_disk_stale_ttl_seconds: 86400
aw_worktime_influx_enabled: true
aw_worktime_influx_url: "http://192.0.2.10:8086"
aw_worktime_influx_org: "proxmox"
+3 -3
View File
@@ -1,9 +1,9 @@
[Unit]
Description=Run AW-RUS unified health orchestrator every 2 minutes
Description=Run AW-RUS unified health orchestrator every 5 minutes
[Timer]
OnCalendar=*:0/2:25
AccuracySec=15s
OnCalendar=*:0/5:25
AccuracySec=30s
RandomizedDelaySec=15s
Unit=aw-rus-healthd.service
Persistent=false
+7
View File
@@ -16,6 +16,13 @@ AW_SERVER_GROUP=activitywatch
AW_SERVER_PUBLIC_HOST=aw-server
AW_WORKTIME_REPORT_BASE=http://aw-server:5610
AW_WORKTIME_TZ=Europe/Moscow
AW_WORKTIME_HOST=HOST-EXAMPLE
AW_WORKTIME_EVENTS_LIMIT=250
AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS=6
AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS=300
AW_WORKTIME_REPORT_CACHE_TTL_SECONDS=300
AW_WORKTIME_REPORT_STALE_TTL_SECONDS=3600
AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS=86400
AW_SERVER_URL=http://127.0.0.1:5600
AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0
AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS=300
+2 -2
View File
@@ -1,12 +1,12 @@
[Unit]
Description=AW Worktime Autoheal (HOST-EXAMPLE)
Description=AW Worktime Autoheal
After=network-online.target activitywatch-server.service
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/activitywatch/aw-server.env
Environment=AW_URL=http://127.0.0.1:5600
Environment=AW_WORKTIME_HOST=HOST-EXAMPLE
ExecStart=/usr/local/bin/aw-worktime-autoheal-rust
User=root
Group=root
+2 -2
View File
@@ -1,8 +1,8 @@
[Unit]
Description=Run AW Worktime Autoheal every 3 minutes
Description=Run AW Worktime Autoheal every 15 minutes
[Timer]
OnCalendar=*:1/3:35
OnCalendar=*:0/15:35
AccuracySec=30s
Unit=aw-worktime-autoheal.service
Persistent=false
+1 -1
View File
@@ -2,7 +2,7 @@
Description=Run AW Worktime Report Cache Prewarm
[Timer]
OnCalendar=*:2/5:40
OnCalendar=*:0/15:40
AccuracySec=30s
Persistent=false
Unit=aw-worktime-prewarm.service
+1 -1
View File
@@ -7,8 +7,8 @@ StartLimitIntervalSec=120
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
Environment=AW_SERVER_URL=http://127.0.0.1:5600
Environment=AW_WORKTIME_HOST=HOST-EXAMPLE
ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust
Restart=on-failure
RestartSec=10
+3 -3
View File
@@ -1,9 +1,9 @@
[Unit]
Description=Run AW Worktime UI bridge every 30 seconds
Description=Run AW Worktime UI bridge every 5 minutes
[Timer]
OnCalendar=*:0/1:15
AccuracySec=10s
OnCalendar=*:0/5:15
AccuracySec=15s
Unit=aw-worktime-ui-bridge.service
Persistent=false
+40
View File
@@ -27,6 +27,12 @@ CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
WORKTIME_API_RUST_SRC="$BOOTSTRAP_DIR/worktime-api"
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
WORKTIME_ALIASES_SRC="$BOOTSTRAP_DIR/worktime-manager-aliases.example.json"
WORKTIME_AUTOHEAL_RUST_SRC="$BOOTSTRAP_DIR/worktime-autoheal"
WORKTIME_AUTOHEAL_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-autoheal.service"
WORKTIME_AUTOHEAL_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-autoheal.timer"
WORKTIME_PREWARM_RUST_SRC="$BOOTSTRAP_DIR/worktime-prewarm"
WORKTIME_PREWARM_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-prewarm.service"
WORKTIME_PREWARM_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-prewarm.timer"
WORKTIME_UI_BRIDGE_RUST_SRC="$BOOTSTRAP_DIR/worktime-ui-bridge"
WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service"
WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer"
@@ -119,6 +125,40 @@ if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then
fi
fi
if [[ -f "$WORKTIME_AUTOHEAL_RUST_SRC" ]]; then
install -m 0755 "$WORKTIME_AUTOHEAL_RUST_SRC" /usr/local/bin/aw-worktime-autoheal-rust
fi
if [[ -f "$WORKTIME_AUTOHEAL_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-autoheal-rust ]]; then
install -m 0644 "$WORKTIME_AUTOHEAL_SERVICE_SRC" /etc/systemd/system/aw-worktime-autoheal.service
fi
if [[ -f "$WORKTIME_AUTOHEAL_TIMER_SRC" && -f /usr/local/bin/aw-worktime-autoheal-rust ]]; then
install -m 0644 "$WORKTIME_AUTOHEAL_TIMER_SRC" /etc/systemd/system/aw-worktime-autoheal.timer
systemctl daemon-reload
systemctl enable aw-worktime-autoheal.timer
systemctl restart aw-worktime-autoheal.timer
systemctl start aw-worktime-autoheal.service || true
systemctl --no-pager --full status aw-worktime-autoheal.timer || true
fi
if [[ -f "$WORKTIME_PREWARM_RUST_SRC" ]]; then
install -m 0755 "$WORKTIME_PREWARM_RUST_SRC" /usr/local/bin/aw-worktime-prewarm-rust
fi
if [[ -f "$WORKTIME_PREWARM_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-prewarm-rust ]]; then
install -m 0644 "$WORKTIME_PREWARM_SERVICE_SRC" /etc/systemd/system/aw-worktime-prewarm.service
fi
if [[ -f "$WORKTIME_PREWARM_TIMER_SRC" && -f /usr/local/bin/aw-worktime-prewarm-rust ]]; then
install -m 0644 "$WORKTIME_PREWARM_TIMER_SRC" /etc/systemd/system/aw-worktime-prewarm.timer
systemctl daemon-reload
systemctl enable aw-worktime-prewarm.timer
systemctl restart aw-worktime-prewarm.timer
systemctl start aw-worktime-prewarm.service || true
systemctl --no-pager --full status aw-worktime-prewarm.timer || true
fi
if [[ -f "$WORKTIME_UI_BRIDGE_RUST_SRC" ]]; then
install -m 0755 "$WORKTIME_UI_BRIDGE_RUST_SRC" /usr/local/bin/aw-worktime-ui-bridge-rust
fi
+41 -29
View File
@@ -19,36 +19,41 @@
Проверить применимость AWatch-rus для:
- мониторинга активности рабочих мест;
- управленческой аналитики Workforce;
- технического аудита ИТ-контура;
- фиксации DLP-lite/ИБ-событий;
- просмотра evidence и отчетов в портале;
- контроля готовности системы через signed readiness bundle.
- Workforce Analytics: активность, подразделения, нагрузка и управленческий
Markdown-отчет;
- Security Analytics: кандидаты на проверку, объяснимый risk score, события
безопасности и аудит решений;
- Forensics: карточка расследования, timeline, evidence package и экспорт
Markdown-отчета;
- Operations/Admin: качество данных, полнота данных, ClickHouse/fallback-статус
и ошибки сбора;
- проверки ролевых ограничений на сервере, а не только в интерфейсе.
## 3. Состав поставки
| Компонент | Проверка |
|---|---|
| AWatch-rus portal | вход, роли оператора/руководителя/владельца |
| ActivityWatch telemetry | актуальность bucket/event данных |
| Workforce analytics | индекс активности, веса приложений, drill-down |
| DLP-lite incidents | USB/print/clipboard/file/email/browser signals, если включены |
| Evidence workflow | preview/download/view audit |
| Grafana dashboards | наличие данных и отсутствие query errors |
| Readiness bundle | checksum/signature/fingerprint |
| Prometheus alerts | readiness/signature alerts настроены |
| AWatch-rus portal | вход, `/portal`, переключение ролей `executive` / `manager` / `security` / `forensics` / `admin` |
| Executive Dashboard | главный вывод отображается первым, затем риски подразделений и краткий статус |
| Workforce analytics | индекс активности, сравнение подразделений, тренды, перегруз/недогруз, Markdown-отчет |
| Security analytics | кандидаты на проверку, UEBA Score v1, severity, аудит решений |
| Forensics workflow | карточка расследования, timeline, evidence package, Markdown export |
| Operations/Admin | полнота данных, качество данных, ClickHouse/fallback-статус, ошибки сбора |
| pfSense readiness | только `contract_only`: contracts/fixtures/API-заготовка, без production ingestion |
## 4. Критерии приемки
Пилот считается успешным, если:
- портал доступен ответственным пользователям заказчика;
- telemetry freshness находится в согласованных пределах;
- readiness status = `OK` или все `WARN` имеют согласованный план устранения;
- signed readiness bundle проходит проверку;
- главный вывод в Executive View отображается первым;
- роли ограничивают доступ на API-уровне;
- качество и полнота данных имеют понятный статус;
- не менее одного управленческого отчета сформировано и принято заказчиком;
- не менее одного test incident/evidence workflow пройден end-to-end;
- не менее одного security candidate / investigation / evidence workflow
пройден end-to-end;
- UEBA Score v1 остается rule-based и не заявляет ML/LLM;
- pfSense readiness остается `contract_only` и не заявляет production ingestion;
- заказчик подтвердил, что состав данных и уведомлений соответствует правилам
внутреннего контроля и локальным нормативным документам.
@@ -56,24 +61,31 @@
| Проверка | Результат | Комментарий |
|---|---|---|
| Portal login | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Readiness bundle | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Workforce report | `<OK/WARN/FAIL>` | `<COMMENT>` |
| DLP-lite incident | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Evidence preview/download | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Grafana dashboards | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Alerting | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Portal access | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Executive View | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Workforce / Manager View | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Security View | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Forensics View | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Operations/Admin View | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Role gates / API 403 | `<OK/WARN/FAIL>` | `<COMMENT>` |
| `/api/reports` JSON | `<OK/WARN/FAIL>` | `<COMMENT>` |
| `/api/pfsense` contract_only | `<OK/WARN/FAIL>` | `<COMMENT>` |
| Demo data sanitization | `<OK/WARN/FAIL>` | `<COMMENT>` |
## 6. Ограничения пилота
- AWatch-rus не заявляется как сертифицированная СЗИ, SIEM, EDR/XDR или
enterprise DLP.
- pfSense/network quarantine интеграции являются опциональным интеграционным
слоем и не входят в обязательный состав пилота.
- Telegram runtime может использоваться как интеграционный канал уведомлений,
но не является ядром продукта.
- pfSense readiness в Pilot v1 является `contract_only`: contracts, fixtures,
docs и API-заготовка без production ingestion, NAC, SOAR, quarantine и
изменения firewall/VPN/routing.
- Grafana, Prometheus, Telegram и внешние интеграции могут использоваться в
отдельных эксплуатационных контурах, но не являются обязательной частью
приемки Pilot v1 demo pack.
- Результаты Workforce analytics являются управленческими proxy-метриками и
должны трактоваться с учетом ролей, весов приложений и локальных регламентов.
- UEBA Score v1 ранжирует риск для ручной проверки и не принимает
автоматических решений.
## 7. Решение
+23 -17
View File
@@ -1,9 +1,10 @@
# Network perimeter и pfSense
Документ описывает роль pfSense в архитектуре AWatch-rus для
коммерческих внедрений и экспертной оценки. pfSense рассматривается как
опциональный интеграционный слой сетевого периметра, а не как обязательная
часть продукта.
коммерческих внедрений и экспертной оценки. Для Pilot v1 pfSense
рассматривается только как `contract_only/readiness`: контракт данных, fixture
и API-заготовка без заявления production ingestion или управления сетевыми
политиками.
## 1. Позиция продукта
@@ -15,17 +16,18 @@ AWatch-rus поставляет:
- DLP-lite/ИБ evidence workflow;
- readiness checks и portal reporting.
Сетевой шлюз, firewall, NAT, VPN и quarantine enforcement могут быть
интегрированы с AWatch-rus, но не входят в минимальный состав продукта.
Сетевой шлюз, firewall, NAT, VPN и quarantine enforcement являются внешним
периметром. Их можно рассматривать как будущие интеграционные направления, но
они не входят в приемочный контур Pilot v1 и не заявляются как реализованный
runtime.
## 2. Роль pfSense
pfSense может использоваться как:
- источник сетевого контекста;
- внешний policy enforcement point;
- шлюз для ограничений VLAN/alias/rules;
- источник логов для корреляции с endpoint activity.
- будущий источник сетевого контекста;
- будущий внешний policy enforcement point после отдельного change request;
- будущий источник логов для корреляции с endpoint activity.
AWatch-rus не требует pfSense для базовой работы портала, readiness, workforce,
DLP-lite evidence и отчетов.
@@ -36,16 +38,19 @@ DLP-lite evidence и отчетов.
|---|---|
| Endpoint telemetry | обязательный слой AWatch-rus |
| Server-side checks/readiness | обязательный слой AWatch-rus |
| Portal/Grafana/Prometheus | обязательный слой AWatch-rus |
| pfSense logs/context | опциональная интеграция |
| pfSense policy enforcement | опциональная интеграция с отдельным решением |
| Portal/API/report layer | обязательный слой Pilot v1 |
| pfSense contracts/fixtures/API-заготовка | `contract_only/readiness` |
| pfSense logs/context production ingestion | не заявляется для Pilot v1 |
| pfSense policy enforcement | future, только с отдельным решением |
| Автоматический quarantine | не включать без отдельного согласования |
## 4. Безопасный режим внедрения
По умолчанию:
По умолчанию в Pilot v1:
- AWatch-rus только читает сетевой контекст, если интеграция включена;
- AWatch-rus показывает только контрактный readiness-слой pfSense;
- production ingestion, если он появится позже, должен включаться отдельным
решением после проверки источника, свежести данных и sanitization;
- любые изменения firewall/NAT/VPN/quarantine запрещены без отдельного change
request;
- pfSense credentials хранятся вне Git и вне public release assets;
@@ -63,9 +68,10 @@ DLP-lite evidence и отчетов.
Корректная формулировка:
> AWatch-rus поддерживает интеграцию с сетевым периметром заказчика, включая
> pfSense-compatible gateways, как внешний источник контекста и опциональную
> точку применения политик.
> AWatch-rus архитектурно предусматривает интеграцию с сетевым периметром
> заказчика, включая pfSense-compatible gateways. В Pilot v1 этот слой имеет
> статус `contract_only/readiness` и не является production ingestion или
> механизмом изменения сетевых политик.
## 6. Будущий roadmap
@@ -0,0 +1,136 @@
# Итог расследования production-инцидента 2026-06-07
Документ фиксирует результат расследования нестабильности рабочего контура
AWatch-rus перед пилотной демонстрацией. Все значения ниже обезличены: реальные
IP-адреса, hostname, учетные записи, домены и приватные пути не приводятся.
## Краткий вывод
Причина инцидента была не в ClickHouse и не в портале. Узкое место находилось в
цепочке `ActivityWatch Server -> aw-worktime-api-rust -> worktime reports`.
`aw-worktime-api-rust` при построении отчетов запрашивал слишком большой объем
events из bucket `aw-worktime-sessions_<HOST>` через ActivityWatch HTTP API.
На рабочей SQLite-базе это приводило к холодным запросам с таймаутами,
росту memory/CPU у `aw-server-rust`, зависанию прогрева отчетов и периодическим
таймаутам executive API в портале.
## Наблюдаемые симптомы
- `/portal/api/health` мог оставаться зеленым за счет кэша, но
`/portal/api/reports?role=executive` периодически уходил в долгий ответ или
таймаут.
- `aw-worktime-api-rust` писал ошибки построения отчетов на запросах вида
`/buckets/aw-worktime-sessions_<HOST>/events?limit=5000`.
- `aw-server-rust` накапливал высокое потребление памяти и CPU, после чего даже
metadata-запросы к bucket могли отвечать медленно.
- У file-operation telemetry наблюдался рост `sendFailures` в health-событиях:
это указывало на сбои HTTP-доставки событий наблюдения во время перегруза,
а не на потерю или копирование самих файлов.
- `aw-worktime-autoheal` и `aw-worktime-ui-bridge` в исходной оркестрации были
чувствительны к placeholder-host, если runtime env не был применен.
- Operations View показывал legacy `rdp_window`/`rdp_afk` как критические
источники, хотя фактический Rust-путь сбора `worktime_sessions` и
`watcher_afk` был свежим.
## Что было исключено
- ClickHouse: контейнер был доступен, health-check после повторного запуска
проходил успешно, данные читались.
- Nginx/gateway: локальные portal endpoints отвечали.
- Ролевая модель портала: smoke-тесты подтвердили серверные запреты.
- Отсутствие данных 1C: не являлось причиной таймаута worktime reports.
## Корневая причина
1. Верхний лимит `AW_WORKTIME_EVENTS_LIMIT` фактически не позволял задать
малое production-значение: нижняя граница была `1000`, а код дополнительно
ограничивал запрос к ActivityWatch значением до `5000`.
2. Worktime reports фильтровали дневной диапазон уже после получения событий,
поэтому холодный запрос зависел от стоимости чтения большого bucket через AW
HTTP API.
3. Несколько timer/service-компонентов могли одновременно прогревать отчеты или
запускать health/autoheal, усиливая нагрузку.
4. Legacy RDP freshness checks не учитывали, что текущий промышленный путь уже
идет через Rust-сбор `aw-worktime-sessions_<HOST>` и локальный watcher AFK.
## Внесенные исправления
- В `worktime-api` разрешен меньший безопасный лимит events:
`AW_WORKTIME_EVENTS_LIMIT` теперь ограничивается диапазоном `100..50000`.
- Production baseline установлен на:
- `AW_WORKTIME_EVENTS_LIMIT=250`;
- `AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS=6`;
- `AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS=300`;
- `AW_WORKTIME_REPORT_CACHE_TTL_SECONDS=300`;
- `AW_WORKTIME_REPORT_STALE_TTL_SECONDS=3600`.
- Legacy `rdp_window` и `rdp_afk` больше не создают критический источник
Operations View, если свежие `worktime_sessions` и `watcher_afk` уже
подтверждают рабочий Rust-путь.
- `aw-worktime-autoheal.service` и `aw-worktime-ui-bridge.service` теперь берут
`AW_WORKTIME_HOST` из `/etc/activitywatch/aw-server.env`, а не из
hardcoded placeholder.
- Таймеры worktime-цепочки разведены по более щадящему графику:
- `aw-worktime-autoheal.timer`: 15 минут;
- `aw-worktime-prewarm.timer`: 15 минут;
- `aw-worktime-ui-bridge.timer`: 5 минут;
- `aw-rus-healthd.timer`: 5 минут.
- Gateway orchestration `detmir-auto.timer` переведен на 30 минут, а service
source зафиксирован на Rust binary с bounded timeouts.
- File-operation collectors проверены отдельно: активного роста `sendFailures`
после стабилизации сервера не было, локальные очереди были пустыми. Для
снятия ложного аварийного хвоста перезапущены только file-operation
collectors в активных пользовательских сессиях и восстановлены штатными
`ActivityWatch Launch [...]` задачами.
## Проверка после исправления
Подтверждено на рабочем контуре:
- failed systemd units: `0`;
- ClickHouse container health: `healthy`;
- `/portal/api/health`: `ok=true`;
- `/reports/worktime/today`: `200`;
- `/reports/worktime/management`: `200`;
- full prewarm: все URL завершились `200` без `build-error`;
- `node scripts/detmir-pilot-demo-smoke.mjs`: OK;
- `node scripts/detmir-portal-tabs-smoke.mjs`: OK;
- role gates: executive/manager/security/forensics ограничения подтверждены.
- file-operation telemetry после перезапуска: по одному collector-процессу на
активную сессию, `queueDepth=0`, `sendFailures=0`, свежие события приходят в
bucket `aw-file-operations_<HOST>`.
- collector-guard до исправления не контролировал наличие Rust
file-operation collector в активных сессиях: после принудительной остановки
он продолжал писать `status=ok actions=0`. Исправление добавило проверку
`fileOperationsPresence`: если в активной сессии есть Rust DLP/browser
collector, но нет Rust file-operation collector, guard запускает штатные
`ActivityWatch Launch [...]` задачи.
- Исправление collector-guard проверено fault-injection: один file-operation
collector был принудительно остановлен, следующий цикл guard выполнил
recovery (`actions>0`), после чего `missingSessions=[]`, `queueDepth=0`,
`sendFailures=0`.
Локальные проверки кода:
- `cargo fmt --all --check`;
- `cargo test -p worktime-api`;
- `cargo clippy -p worktime-api --all-targets -- -D warnings`;
- `cargo build --release -p worktime-api`;
- `git diff --check`.
## Остаточные наблюдения
- Исторические `sendFailures` в старых health-событиях остаются в bucket как
архивная телеметрия. Текущее состояние после перезапуска collectors чистое:
рост `sendFailures` не подтвержден.
- ActivityWatch SQLite остается чувствительным к широким историческим
`events`-запросам. Для production-демо и пилота надо держать bounded limits,
кэш и предварительный прогрев.
- Реальные runtime значения должны оставаться в private inventory/env, а не в
tracked repository files.
## Итоговое решение
Инцидент закрыт как operational performance regression в worktime reporting
chain. Исправление принято в код, оркестрацию и установочный пакет. Пилотный
контур можно показывать при выполненном преддемо-прогреве и зеленом smoke.
+4 -2
View File
@@ -11,8 +11,10 @@ Environment=DETMIR_AUTO_HEAL=1
Environment=DETMIR_AI_STATE_DIR=/var/lib/detmir-ai
Environment=DETMIR_AI_RUN_DIR=/var/lib/detmir-ai/locks
EnvironmentFile=-/etc/detmir/detmir-check.env
ExecStart=/usr/local/bin/detmir-auto
TimeoutStartSec=300
Environment=no_proxy=localhost,127.0.0.1,198.51.100.18,192.0.2.13,192.0.2.2,192.0.2.0/24,198.51.100.0/24
Environment=NO_PROXY=localhost,127.0.0.1,198.51.100.18,192.0.2.13,192.0.2.2,192.0.2.0/24,198.51.100.0/24
ExecStart=/usr/local/bin/detmir-auto-rust --command-timeout-seconds 120 --report-timeout-seconds 60
TimeoutStartSec=600
Nice=5
IOSchedulingClass=best-effort
IOSchedulingPriority=7
+2 -2
View File
@@ -1,9 +1,9 @@
[Unit]
Description=Run DetMir autonomous health loop every 15 minutes
Description=Run DetMir autonomous health loop every 30 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
OnUnitActiveSec=30min
AccuracySec=1min
Persistent=true
Unit=detmir-auto.service