Compare commits

...
Author SHA1 Message Date
igor04091968 cfeaf772ea fix(aw-db): add guarded sqlite vacuum maintenance 2026-06-20 11:04:10 +03:00
igor04091968 f800c676ce docs(pilot): freeze readiness and demo safety guardrails 2026-06-20 09:57:34 +03:00
IgorRachkovandGitHub 73578e8388 Update PILOT_DEMO_SCENARIO_RU.md 2026-06-19 09:47:43 +03:00
igor04091968 9b4b847723 feat(detmir): add support scripts docs and daily/weekly/monthly runners 2026-06-18 22:26:28 +03:00
igor04091968 c9ad5ecca7 chore(scripts): add full diagnostics package for detmir 2026-06-18 22:11:38 +03:00
igor04091968 ced3b0fb20 docs: clarify DetMir support responsibility and SLA 2026-06-18 17:33:00 +03:00
igor04091968 5f8a25e056 docs: update support scope with web server 2026-06-17 23:33:27 +03:00
igor04091968 66dc0a09b1 docs: add network and virtualization support tasks 2026-06-17 23:21:36 +03:00
igor04091968 4c96879915 docs: remove 1C from DetMir support scope 2026-06-17 23:16:42 +03:00
igor04091968 371a1e2728 docs: narrow DetMir support scope 2026-06-17 23:06:14 +03:00
igor04091968 b7209771f6 docs: add DetMir support task list 2026-06-17 23:01:54 +03:00
IgorRachkovandGitHub 525f45e0f7 Merge pull request #37 from igor04091968/refactor/portal-telemetry-ingest
refactor(portal): move telemetry ingest into module
2026-06-15 15:31:34 +03:00
igor04091968 66b0b6cffb refactor(portal): move telemetry ingest into module 2026-06-15 14:24:10 +03:00
IgorRachkovandGitHub 57ef5abf7e Merge pull request #36 from igor04091968/refactor/portal-http-response
refactor(portal): move HTTP response helpers into module
2026-06-15 12:04:21 +03:00
IgorRachkovandGitHub ae1909a28a Update README.md 2026-06-15 09:58:03 +03:00
IgorRachkovandGitHub 064fbd05fb Update README.md 2026-06-15 09:57:24 +03:00
38 changed files with 3555 additions and 212 deletions
+15 -2
View File
@@ -5,7 +5,10 @@ AWatch-rus - программный комплекс операционного
корпоративной ИТ-инфраструктуры на базе ActivityWatch, Rust-сервисов
автоматизации, Grafana/Prometheus-витрин и модулей расследования инцидентов.
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ,хотя DLP,evidence и Hayabusa используются в проекте.
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ,
не заявляет ML/LLM UEBA и не подменяет штатные средства защиты, хотя
DLP-сигналы, evidence и Hayabusa используются как аналитические и
расследовательские слои.
## Назначение
@@ -24,6 +27,11 @@ AWatch-rus - программный комплекс операционного
Основной серверный runtime AWatch-rus переведен на Rust: status/check/auto-heal,
SLO, worktime, DLP server-side helpers, evidence и install-kit tooling.
Это Rust-primary направление, а не заявление о полном удалении PowerShell.
Оставшиеся PowerShell runtime/fallback/installer/repair scripts сохраняются
как документированный слой отката, установки и поддержки до отдельной задачи
удаления с burn-in периодом, canary test, rollback plan и acceptance gate.
Python, присутствующий в коде репозитория, остается для вспомогательных направлений: Telegram bot
runtime(для оперативного оповещения), OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти части не являются ядром Rust-first runtime.
@@ -55,7 +63,8 @@ Implemented:
Planned:
- Provider detail expansion under `/portal/architecture`.
- PowerShell Provider.
- PowerShell Provider как planned/agentless direction, не как возврат новых
runtime-функций на PowerShell.
- SSH Provider.
- Syslog Provider.
- 1C Provider как формализация текущего file-based 1C analytics направления.
@@ -89,6 +98,7 @@ Security Analytics + Forensics для ролей `executive`, `manager`, `securi
Pilot validation:
- [чеклист проверки пилота](docs/PILOT_VALIDATION_CHECKLIST_RU.md);
- [pilot freeze readiness](docs/PILOT_FREEZE_READINESS_RU.md);
- [gap analysis пилота](docs/PILOT_GAP_ANALYSIS_RU.md);
- [вопросы для discovery с заказчиком](docs/CUSTOMER_DISCOVERY_QUESTIONS_RU.md);
- [критерии успеха пилота](docs/PILOT_SUCCESS_CRITERIA_RU.md);
@@ -98,6 +108,8 @@ Pilot validation:
- pfSense показывается только как `contract_only/readiness`, без заявления
production ingestion или SIEM;
- pfSense в текущем пилоте допускается только как contract/readiness/optional
integration layer;
- UEBA Score v1 является прозрачной rule-based моделью, без ML/LLM;
- demo fixtures не содержат реальных IP-адресов, hostname, логинов, ФИО,
подразделений заказчика или событий безопасности;
@@ -296,3 +308,4 @@ collectors.
- [Windows Collector Suite](docs/wiki/Windows-Collector-Suite.md)
- [Worktime API and UI Bridge](docs/wiki/Worktime-API-and-UI-Bridge.md)
- [Russian WebUI Patch and Localization](docs/wiki/Russian-WebUI-Patch-and-Localization.md)
- Актуальные ссылки по этой тематике: https://www.securitylab.ru/analytics/573771.php (Как собрать ролевую модель доступа при хаосе в инфраструктуре)
+35 -1
View File
@@ -22,7 +22,9 @@ scripts with durable standalone Rust modules.
- `detmir-dlp` - SSH wrapper replacement for remote DLP health JSON collection.
- `dlp-health-check` - AW server DLP health check replacement.
- `aw-db-maintenance` - guarded weekly SQLite maintenance for old allowlisted
process-level session events, with backup-before-delete.
process-level session events, with backup-before-delete; nightly SQLite
compaction is handled by the same binary in `--vacuum` mode and scheduled
separately from the trim job.
- `aw-ensure-reliability` - safe dry-run/apply planner for AW service
reliability repair actions that were previously immediate Bash mutations.
- `aw-linux-install` - safe dry-run/apply planner for Linux ActivityWatch
@@ -58,6 +60,38 @@ scripts with durable standalone Rust modules.
- `detmir-status` - read-only DetMir state normalizer with text, JSON, and ADK
`Content` output. Also builds `detmir-adk-status` as a compatibility binary.
## SQLite Maintenance Safety
`aw-db-maintenance` has two separate modes:
- default trim mode removes only old allowlisted `process_start` /
`process_stop` rows from the configured session bucket and is dry-run unless
`--apply` is passed;
- `--vacuum` compacts the SQLite DB with `VACUUM INTO`, checks
`PRAGMA integrity_check`, preserves owner/mode, and replaces the DB only after
backup and integrity success.
Both apply modes use `AW_DB_MAINTENANCE_LOCK_PATH` /
`--lock-path` to block concurrent trim/VACUUM runs. VACUUM also checks the
configured `activitywatch-server.service` through systemd, refuses unknown or
failed unit states, stops the service before compaction, and starts it again
through a guard on success or error.
Do not run VACUUM during business hours, active incident response, evidence
collection, active backup/restore, or when the ActivityWatch service/unit state
is unclear. Rollback is replacing the SQLite DB from
`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db` while
`activitywatch-server.service` is stopped, then starting the service and
checking `aw-db-health`/`detmir-status`.
The Ansible deploy installs the VACUUM unit files but does not enable the
nightly timer unless `aw_db_vacuum_timer_enabled=true` is set explicitly.
Disable it with:
```bash
systemctl disable --now aw-db-vacuum.timer
```
## Migration Runbook
Use `RUNBOOK.md` as the operational plan for replacing Python and shell modules
+39
View File
@@ -1118,6 +1118,45 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
(`sqlite.db=359.6MiB`, WAL `4.0MiB`, session rows `174`, recent process
events `0`, latest `eventType=logon`), DetMir status OK with
`dlp_counts={ok:22,warn:0,fail:0}`, `ok_for_operator=true`.
35.2. `[done]` Закрепить nightly VACUUM для AW SQLite:
- `aw-db-maintenance` получил отдельный `--vacuum` режим; weekly trim
остается отдельной задачей и не смешивается с compaction;
- добавлены `aw-server/aw-db-vacuum.service` и
`aw-server/aw-db-vacuum.timer`;
- timer schedule: `OnCalendar=*-*-* 02:10:00`,
`RandomizedDelaySec=10m`, `Persistent=true`; Ansible не включает timer
без явного opt-in `aw_db_vacuum_timer_enabled=true`;
- если opt-in не задан, `deploy_aw_server.yml` оставляет unit-файлы на
сервере, но держит `aw-db-vacuum.timer` в `disabled/stopped`;
- nightly job перед VACUUM останавливает `activitywatch-server.service`,
делает rollback backup SQLite DB, выполняет `VACUUM INTO`, проверяет
`PRAGMA integrity_check`, сохраняет владельца и режим файла и
поднимает server обратно;
- apply-режимы weekly trim и nightly VACUUM используют общий lock
`/run/aw-db-maintenance.lock`, поэтому одновременный запуск завершается
fail-closed без записи в SQLite;
- VACUUM нельзя запускать в рабочее время, во время активного
расследования/снятия доказательств, backup/restore, неизвестного
состояния `activitywatch-server.service` или если уже есть maintenance
lock;
- dry-run проверки:
`aw-db-maintenance --vacuum --json` и обычный
`aw-db-maintenance --json`; они не создают backup и не пишут в DB;
- включить timer явно:
`ansible-playbook -i inventory.ini deploy_aw_server.yml -e aw_db_vacuum_timer_enabled=true`;
- отключить timer:
`systemctl disable --now aw-db-vacuum.timer`;
- rollback: остановить `activitywatch-server.service`, заменить
`/var/lib/activitywatch/aw-server-rust/sqlite.db` из последнего
`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db`,
вернуть владельца/режим, запустить service, проверить `aw-db-health` и
`detmir-status`;
- local gates: `cargo fmt --all -- --check`, `cargo test --workspace`,
`cargo clippy --workspace --all-targets -- -D warnings`,
`cargo build --workspace --release`, systemd unit verify,
`ansible-playbook -i inventory.ini deploy_aw_server.yml --syntax-check`,
`scripts/check_detmir_rust_release_artifacts.sh`,
`scripts/quality-gate.sh`;
36. `[done]` Устранить blocker полного AW server deploy на Influx token:
- проблема: `deploy_aw_server.yml` падал на assert
`aw_worktime_influx_enabled=true`, потому что локальные env
+543 -8
View File
@@ -1,5 +1,7 @@
use std::fs;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result, bail};
@@ -12,6 +14,8 @@ use serde_json::Value;
const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db";
const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db";
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
const DEFAULT_SERVICE_UNIT: &str = "activitywatch-server.service";
const DEFAULT_LOCK_PATH: &str = "/run/aw-db-maintenance.lock";
const ALLOWED_EVENT_TYPES: &[&str] = &["process_start", "process_stop"];
#[derive(Debug, Parser)]
@@ -38,6 +42,19 @@ struct Cli {
#[arg(long)]
apply: bool,
#[arg(long)]
vacuum: bool,
#[arg(
long,
default_value = DEFAULT_SERVICE_UNIT,
env = "AW_DB_MAINTENANCE_SERVICE_UNIT"
)]
service_unit: String,
#[arg(long, default_value = DEFAULT_LOCK_PATH, env = "AW_DB_MAINTENANCE_LOCK_PATH")]
lock_path: PathBuf,
#[arg(long)]
json: bool,
}
@@ -56,9 +73,52 @@ struct Report {
planned_delete_rows: usize,
deleted_rows: usize,
backup_created: bool,
lock_path: String,
skipped_reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct VacuumReport {
apply: bool,
generated_at_utc: String,
db_path: String,
service_unit: String,
service_was_active: bool,
service_restarted: bool,
backup_path: Option<String>,
backup_created: bool,
lock_path: String,
db_size_before_bytes: Option<u64>,
vacuumed_path: Option<String>,
vacuumed_size_bytes: Option<u64>,
integrity_check: Option<String>,
replaced_db: bool,
skipped_reason: Option<String>,
}
struct VacuumResult {
backup_path: PathBuf,
vacuumed_path: PathBuf,
db_size_before_bytes: u64,
vacuumed_size_bytes: u64,
integrity_check: String,
}
struct ServiceGuard {
unit: String,
was_active: bool,
restored: bool,
}
struct TempFileGuard {
path: PathBuf,
keep: bool,
}
struct LockFileGuard {
path: PathBuf,
}
fn main() {
let code = match run() {
Ok(code) => code,
@@ -72,11 +132,20 @@ fn main() {
fn run() -> Result<i32> {
let cli = Cli::parse();
let report = build_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
if cli.vacuum {
let report = build_vacuum_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_vacuum_text(&report);
}
} else {
print_text(&report);
let report = build_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_text(&report);
}
}
Ok(0)
}
@@ -116,10 +185,15 @@ fn build_report(cli: &Cli) -> Result<Report> {
let mut backup_file = None;
let mut backup_created = false;
let mut deleted = 0;
let _lock_guard = if cli.apply && planned > 0 {
Some(LockFileGuard::acquire(&cli.lock_path)?)
} else {
None
};
if cli.apply && planned > 0 {
fs::create_dir_all(&cli.backup_dir)
.with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?;
let backup = backup_path(&cli.backup_dir);
let backup = backup_path(&cli.backup_dir, "aw-sqlite-before-db-maintenance");
copy_sqlite_via_backup(&cli.db_path, &backup)?;
backup_file = Some(backup);
backup_created = true;
@@ -139,6 +213,60 @@ fn build_report(cli: &Cli) -> Result<Report> {
))
}
fn build_vacuum_report(cli: &Cli) -> Result<VacuumReport> {
if !cli.db_path.exists() {
return Ok(vacuum_report(
cli,
false,
None,
false,
false,
false,
None,
None,
None,
None,
Some("database not found".to_string()),
));
}
if !cli.apply {
return Ok(vacuum_report(
cli,
false,
Some(file_size(&cli.db_path)?),
false,
false,
false,
None,
None,
None,
None,
Some("dry-run".to_string()),
));
}
let _lock_guard = LockFileGuard::acquire(&cli.lock_path)?;
let mut service_guard = ServiceGuard::stop_if_active(&cli.service_unit)?;
let service_was_active = service_guard.was_active;
let result = vacuum_sqlite_db(&cli.db_path, &cli.backup_dir)?;
let service_restarted = service_guard.restore()?;
Ok(vacuum_report(
cli,
true,
Some(result.db_size_before_bytes),
true,
service_was_active,
service_restarted,
Some(result.backup_path),
Some(result.vacuumed_path),
Some(result.vacuumed_size_bytes),
Some(result.integrity_check),
None,
))
}
#[allow(clippy::too_many_arguments)]
fn base_report(
cli: &Cli,
@@ -164,6 +292,40 @@ fn base_report(
planned_delete_rows,
deleted_rows,
backup_created,
lock_path: cli.lock_path.display().to_string(),
skipped_reason,
}
}
#[allow(clippy::too_many_arguments)]
fn vacuum_report(
cli: &Cli,
apply: bool,
db_size_before_bytes: Option<u64>,
backup_created: bool,
service_was_active: bool,
service_restarted: bool,
backup_path: Option<PathBuf>,
vacuumed_path: Option<PathBuf>,
vacuumed_size_bytes: Option<u64>,
integrity_check: Option<String>,
skipped_reason: Option<String>,
) -> VacuumReport {
VacuumReport {
apply,
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
db_path: cli.db_path.display().to_string(),
service_unit: cli.service_unit.clone(),
service_was_active,
service_restarted,
backup_path: backup_path.map(|path| path.display().to_string()),
backup_created,
lock_path: cli.lock_path.display().to_string(),
db_size_before_bytes,
vacuumed_path: vacuumed_path.map(|path| path.display().to_string()),
vacuumed_size_bytes,
integrity_check,
replaced_db: apply && skipped_reason.is_none(),
skipped_reason,
}
}
@@ -237,13 +399,256 @@ fn delete_events(conn: &Connection, ids: &[i64], chunk_size: usize) -> Result<us
Ok(deleted)
}
fn backup_path(backup_dir: &Path) -> PathBuf {
fn backup_path(backup_dir: &Path, prefix: &str) -> PathBuf {
backup_dir.join(format!(
"aw-sqlite-before-db-maintenance-{}.db",
"{}-{}.db",
prefix,
Utc::now().format("%Y%m%dT%H%M%SZ")
))
}
fn vacuum_sqlite_db(db_path: &Path, backup_dir: &Path) -> Result<VacuumResult> {
fs::create_dir_all(backup_dir)
.with_context(|| format!("create backup dir {}", backup_dir.display()))?;
let db_size_before_bytes = file_size(db_path)?;
let backup_path = backup_path(backup_dir, "aw-sqlite-before-db-vacuum");
copy_sqlite_via_backup(db_path, &backup_path)?;
let vacuumed_path = vacuumed_path(db_path)?;
let mut vacuum_cleanup = TempFileGuard::new(vacuumed_path.clone());
vacuum_into(db_path, &vacuumed_path)?;
preserve_sqlite_metadata(db_path, &vacuumed_path)?;
let vacuumed_size_bytes = file_size(&vacuumed_path)?;
let integrity_check = integrity_check(&vacuumed_path)?;
remove_sqlite_sidecars(db_path)?;
fs::rename(&vacuumed_path, db_path).with_context(|| {
format!(
"replace {} with {}",
db_path.display(),
vacuumed_path.display()
)
})?;
vacuum_cleanup.disarm();
Ok(VacuumResult {
backup_path,
vacuumed_path,
db_size_before_bytes,
vacuumed_size_bytes,
integrity_check,
})
}
fn vacuum_into(src: &Path, dst: &Path) -> Result<()> {
let conn = open_connection(src, true)?;
let sql = format!("VACUUM INTO {}", sqlite_string_literal(dst));
conn.execute_batch(&sql)
.with_context(|| format!("VACUUM INTO {}", dst.display()))
}
fn integrity_check(path: &Path) -> Result<String> {
let conn = open_connection(path, false)?;
let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if result != "ok" {
bail!("integrity_check failed for {}: {result}", path.display());
}
Ok(result)
}
fn remove_sqlite_sidecars(db_path: &Path) -> Result<()> {
for suffix in ["-wal", "-shm", "-journal"] {
let sidecar = sqlite_sidecar_path(db_path, suffix)?;
match fs::remove_file(&sidecar) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err).with_context(|| format!("remove {}", sidecar.display())),
}
}
Ok(())
}
fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> Result<PathBuf> {
let file_name = db_path
.file_name()
.and_then(|value| value.to_str())
.context("database path must have a file name")?;
Ok(db_path.with_file_name(format!("{file_name}{suffix}")))
}
fn vacuumed_path(db_path: &Path) -> Result<PathBuf> {
let file_name = db_path
.file_name()
.and_then(|value| value.to_str())
.context("database path must have a file name")?;
Ok(db_path.with_file_name(format!(
"{file_name}.vacuumed-{}",
Utc::now().format("%Y%m%dT%H%M%SZ")
)))
}
fn file_size(path: &Path) -> Result<u64> {
Ok(fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.len())
}
fn sqlite_string_literal(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "''"))
}
fn preserve_sqlite_metadata(src: &Path, dst: &Path) -> Result<()> {
let metadata = fs::metadata(src).with_context(|| format!("stat {}", src.display()))?;
let permissions = metadata.permissions();
fs::set_permissions(dst, permissions)
.with_context(|| format!("preserve permissions for {}", dst.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let dst_metadata = fs::metadata(dst).with_context(|| format!("stat {}", dst.display()))?;
if dst_metadata.uid() != metadata.uid() || dst_metadata.gid() != metadata.gid() {
let status = Command::new("chown")
.arg(format!("{}:{}", metadata.uid(), metadata.gid()))
.arg(dst)
.status()
.context("run chown for vacuumed SQLite DB")?;
if !status.success() {
bail!("chown failed for {}", dst.display());
}
}
}
Ok(())
}
fn systemctl_is_active(unit: &str) -> Result<bool> {
let load_state = systemctl_load_state(unit)?;
if load_state != "loaded" {
bail!("refusing SQLite VACUUM because systemd unit {unit} load_state={load_state:?}");
}
let output = Command::new("systemctl")
.args(["is-active", unit])
.output()
.with_context(|| format!("systemctl is-active {unit}"))?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() && state == "active" {
return Ok(true);
}
if output.status.code() == Some(3) && state == "inactive" {
return Ok(false);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"refusing SQLite VACUUM because systemctl is-active {unit} returned state={state:?}, status={}, stderr={stderr:?}",
output.status
);
}
fn systemctl_load_state(unit: &str) -> Result<String> {
let output = Command::new("systemctl")
.args(["show", "-p", "LoadState", "--value", unit])
.output()
.with_context(|| format!("systemctl show LoadState {unit}"))?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() && !state.is_empty() {
return Ok(state);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"refusing SQLite VACUUM because systemctl show LoadState {unit} failed with status={}, stderr={stderr:?}",
output.status
);
}
fn systemctl_action(action: &str, unit: &str) -> Result<()> {
let status = Command::new("systemctl")
.args([action, unit])
.status()
.with_context(|| format!("systemctl {action} {unit}"))?;
if status.success() {
Ok(())
} else {
bail!("systemctl {action} {unit} failed with status {status}");
}
}
impl ServiceGuard {
fn stop_if_active(unit: &str) -> Result<Self> {
let was_active = systemctl_is_active(unit)?;
if was_active {
systemctl_action("stop", unit)?;
}
Ok(Self {
unit: unit.to_string(),
was_active,
restored: !was_active,
})
}
fn restore(&mut self) -> Result<bool> {
if self.was_active && !self.restored {
systemctl_action("start", &self.unit)?;
self.restored = true;
}
Ok(self.was_active)
}
}
impl Drop for ServiceGuard {
fn drop(&mut self) {
if self.was_active && !self.restored {
let _ = systemctl_action("start", &self.unit);
}
}
}
impl TempFileGuard {
fn new(path: PathBuf) -> Self {
Self { path, keep: false }
}
fn disarm(&mut self) {
self.keep = true;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if !self.keep {
let _ = fs::remove_file(&self.path);
}
}
}
impl LockFileGuard {
fn acquire(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create lock parent {}", parent.display()))?;
}
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
bail!("maintenance lock already exists: {}", path.display());
}
Err(err) => return Err(err).with_context(|| format!("create lock {}", path.display())),
};
writeln!(
file,
"pid={} generated_at_utc={}",
std::process::id(),
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
)
.with_context(|| format!("write lock {}", path.display()))?;
Ok(Self {
path: path.to_path_buf(),
})
}
}
impl Drop for LockFileGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn print_text(report: &Report) {
println!(
"aw-db-maintenance: {}",
@@ -255,6 +660,7 @@ fn print_text(report: &Report) {
println!("planned_delete_rows: {}", report.planned_delete_rows);
println!("deleted_rows: {}", report.deleted_rows);
println!("backup_created: {}", report.backup_created);
println!("lock_path: {}", report.lock_path);
if let Some(path) = &report.backup_path {
println!("backup_path: {path}");
}
@@ -263,6 +669,38 @@ fn print_text(report: &Report) {
}
}
fn print_vacuum_text(report: &VacuumReport) {
println!(
"aw-db-vacuum: {}",
if report.apply { "apply" } else { "dry-run" }
);
println!("db_path: {}", report.db_path);
println!("service_unit: {}", report.service_unit);
println!("service_was_active: {}", report.service_was_active);
println!("service_restarted: {}", report.service_restarted);
println!("backup_created: {}", report.backup_created);
println!("lock_path: {}", report.lock_path);
if let Some(path) = &report.backup_path {
println!("backup_path: {path}");
}
if let Some(size) = report.db_size_before_bytes {
println!("db_size_before_bytes: {size}");
}
if let Some(path) = &report.vacuumed_path {
println!("vacuumed_path: {path}");
}
if let Some(size) = report.vacuumed_size_bytes {
println!("vacuumed_size_bytes: {size}");
}
if let Some(check) = &report.integrity_check {
println!("integrity_check: {check}");
}
println!("replaced_db: {}", report.replaced_db);
if let Some(reason) = &report.skipped_reason {
println!("skipped_reason: {reason}");
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -290,6 +728,9 @@ mod tests {
retention_days: 7,
chunk_size: 100,
apply: false,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_report(&cli).unwrap();
@@ -312,6 +753,9 @@ mod tests {
retention_days: 7,
chunk_size: 1,
apply: true,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_report(&cli).unwrap();
@@ -321,6 +765,73 @@ mod tests {
assert_eq!(count_events(&db), 1);
}
#[test]
fn vacuum_apply_compacts_database_and_preserves_rows() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("sqlite.db");
create_vacuum_fixture_db(&db);
let before = file_size(&db).unwrap();
let result = vacuum_sqlite_db(&db, dir.path()).unwrap();
let after = file_size(&db).unwrap();
assert!(result.vacuumed_size_bytes < result.db_size_before_bytes);
assert!(after < before);
assert_eq!(result.integrity_check, "ok");
assert!(result.backup_path.exists());
assert_eq!(count_rows(&db), 32);
}
#[test]
fn vacuum_dry_run_skips_mutation() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("sqlite.db");
create_vacuum_fixture_db(&db);
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: None,
host: None,
retention_days: 7,
chunk_size: 100,
apply: false,
vacuum: true,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_vacuum_report(&cli).unwrap();
assert!(!report.backup_created);
assert!(!report.replaced_db);
assert_eq!(report.skipped_reason.as_deref(), Some("dry-run"));
assert_eq!(count_rows(&db), 32);
}
#[test]
fn apply_refuses_when_lock_exists() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("aw.db");
create_fixture_db(&db);
let lock_path = dir.path().join("maintenance.lock");
fs::write(&lock_path, "busy").unwrap();
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: Some("aw-session-events_TEST".to_string()),
host: None,
retention_days: 7,
chunk_size: 1,
apply: true,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path,
json: true,
};
let err = build_report(&cli).unwrap_err().to_string();
assert!(err.contains("maintenance lock already exists"));
assert_eq!(count_events(&db), 3);
}
fn create_fixture_db(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
@@ -350,10 +861,34 @@ mod tests {
.unwrap();
}
fn create_vacuum_fixture_db(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"create table items (id integer primary key autoincrement, payload text);",
)
.unwrap();
let payload = "x".repeat(4096);
for _ in 0..64 {
conn.execute("insert into items (payload) values (?1)", [&payload])
.unwrap();
}
for id in 1..=32 {
conn.execute("delete from items where id = ?1", [id])
.unwrap();
}
}
fn count_events(path: &Path) -> i64 {
Connection::open(path)
.unwrap()
.query_row("select count(*) from events", [], |row| row.get(0))
.unwrap()
}
fn count_rows(path: &Path) -> i64 {
Connection::open(path)
.unwrap()
.query_row("select count(*) from items", [], |row| row.get(0))
.unwrap()
}
}
+12 -170
View File
@@ -34,6 +34,7 @@ mod risk_narrative;
mod role_access;
mod snapshot_cache;
mod static_assets;
mod telemetry_ingest;
mod workforce_kpi_explain;
use api_contracts::api_contract_summary;
@@ -51,8 +52,8 @@ use path_query::{
use portal_roles::PortalRole;
use production::{
build_healthz, build_readyz, build_version, is_limited_api_route, mark_request_started,
record_ingestion_accepted, record_ingestion_rejected, record_report_generated,
render_prometheus_metrics, validate_api_query_limits, validate_portal_config,
record_report_generated, render_prometheus_metrics, validate_api_query_limits,
validate_portal_config,
};
use readiness_api::{readiness_bundle, readiness_latest, readiness_verify};
use risk_narrative::{
@@ -65,6 +66,12 @@ use snapshot_cache::{
use static_assets::{
API_CONTRACT_OPENAPI, API_CONTRACT_TYPESCRIPT, APP_CSS, APP_JS, ARCHITECTURE_HTML, INDEX_HTML,
};
#[cfg(test)]
#[allow(unused_imports)]
pub(crate) use telemetry_ingest::{
apply_telemetry_ingest, telemetry_authorized, validate_telemetry_payload,
};
pub(crate) use telemetry_ingest::{bearer_token, constant_time_eq, handle_telemetry_ingest};
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
@@ -8558,7 +8565,7 @@ fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> {
}
}
fn read_limited_body(request: &mut Request, limit: u64) -> Result<String> {
pub(crate) fn read_limited_body(request: &mut Request, limit: u64) -> Result<String> {
let mut body = String::new();
request
.as_reader()
@@ -8570,12 +8577,12 @@ fn read_limited_body(request: &mut Request, limit: u64) -> Result<String> {
Ok(body)
}
fn is_payload_too_large(err: &anyhow::Error) -> bool {
pub(crate) fn is_payload_too_large(err: &anyhow::Error) -> bool {
err.to_string()
.contains("request payload exceeds configured limit")
}
fn respond_payload_too_large(request: Request) -> Result<()> {
pub(crate) fn respond_payload_too_large(request: Request) -> Result<()> {
respond_json_status(
request,
StatusCode(413),
@@ -8734,130 +8741,6 @@ fn handle_case_details(
}
}
fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
if !telemetry_authorized(&request, args) {
record_ingestion_rejected();
return respond_json_status(
request,
StatusCode(401),
&json!({
"ok": false,
"error": "telemetry api key is missing or invalid"
}),
);
}
let telemetry_limit = args.max_request_body_bytes.min(1024 * 1024);
let body = match read_limited_body(&mut request, telemetry_limit) {
Ok(body) => body,
Err(err) if is_payload_too_large(&err) => {
record_ingestion_rejected();
return respond_payload_too_large(request);
}
Err(err) => return Err(err),
};
let response = apply_telemetry_ingest(args, &body);
match response {
Ok(response) => {
record_ingestion_accepted();
respond_json(request, &response)
}
Err(err) => {
record_ingestion_rejected();
respond_json_status(
request,
StatusCode(400),
&json!({
"ok": false,
"error": err.to_string()
}),
)
}
}
}
fn apply_telemetry_ingest(args: &Cli, body: &str) -> Result<Value> {
let payload: Value =
serde_json::from_str(body).map_err(|err| anyhow!("invalid telemetry JSON: {err}"))?;
validate_telemetry_payload(&payload)?;
let received_at_utc = now();
let envelope = json!({
"received_at_utc": received_at_utc,
"prototype": true,
"record": payload,
});
if let Some(parent) = args.telemetry_store_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&args.telemetry_store_path)
.with_context(|| format!("open {}", args.telemetry_store_path.display()))?;
writeln!(file, "{}", serde_json::to_string(&envelope)?)
.with_context(|| format!("append {}", args.telemetry_store_path.display()))?;
Ok(json!({
"ok": true,
"prototype": true,
"stored": "file-backed-jsonl",
"received_at_utc": received_at_utc,
}))
}
fn validate_telemetry_payload(payload: &Value) -> Result<()> {
let Some(object) = payload.as_object() else {
return Err(anyhow!("telemetry payload must be a JSON object"));
};
for field in [
"agent_id",
"hostname",
"os_name",
"os_version",
"platform",
"username",
"timestamp",
"uptime_seconds",
"cpu_usage_percent",
"memory_total",
"memory_used",
"active_sessions",
"rdp_sessions",
"ssh_sessions",
"processes",
"network_interfaces",
"network_connections",
"workforce_activity",
"security_events",
"collector_version",
] {
if !object.contains_key(field) {
return Err(anyhow!("telemetry field is missing: {field}"));
}
}
for field in [
"active_sessions",
"rdp_sessions",
"ssh_sessions",
"processes",
"network_interfaces",
"network_connections",
"security_events",
] {
if payload.get(field).and_then(Value::as_array).is_none() {
return Err(anyhow!("telemetry field must be an array: {field}"));
}
}
if payload
.get("workforce_activity")
.and_then(Value::as_object)
.is_none()
{
return Err(anyhow!(
"telemetry field must be an object: workforce_activity"
));
}
Ok(())
}
fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<IncidentActionResponse> {
let action: IncidentActionRequest =
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?;
@@ -9968,47 +9851,6 @@ fn upload_authorized(request: &Request, args: &Cli) -> bool {
constant_time_eq(actual.as_bytes(), expected.as_bytes())
}
fn telemetry_authorized(request: &Request, args: &Cli) -> bool {
let expected = args.telemetry_api_key.trim();
if expected.is_empty() || expected == "change-me" {
return false;
}
let actual = request
.headers()
.iter()
.find(|header| header.field.equiv("x-api-key"))
.map(|header| header.value.as_str().trim().to_string())
.or_else(|| bearer_token(request));
actual
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
.unwrap_or(false)
}
fn bearer_token(request: &Request) -> Option<String> {
request
.headers()
.iter()
.find(|header| header.field.equiv("Authorization"))
.map(|header| header.value.as_str().trim())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
let mut diff = 0_u8;
for (a, b) in left.iter().zip(right.iter()) {
diff |= a ^ b;
}
diff == 0
}
fn request_actor(request: &Request) -> String {
for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] {
if let Some(value) = request
@@ -0,0 +1,185 @@
//! Telemetry ingest API for Rust endpoint/agent diagnostics.
//!
//! CONTRACT: this module owns `/api/telemetry` authentication, request-body
//! validation and JSONL append semantics. Keep accepted fields, status codes,
//! metrics counters and response shape stable unless telemetry contracts are
//! updated in the same PR.
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use tiny_http::{Request, StatusCode};
use crate::production::{record_ingestion_accepted, record_ingestion_rejected};
use crate::{
Cli, is_payload_too_large, now, read_limited_body, respond_json, respond_json_status,
respond_payload_too_large,
};
pub(crate) fn telemetry_authorized(request: &Request, args: &Cli) -> bool {
let expected = args.telemetry_api_key.trim();
if expected.is_empty() || expected == "change-me" {
return false;
}
let actual = request
.headers()
.iter()
.find(|header| header.field.equiv("x-api-key"))
.map(|header| header.value.as_str().trim().to_string())
.or_else(|| bearer_token(request));
actual
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
.unwrap_or(false)
}
pub(crate) fn bearer_token(request: &Request) -> Option<String> {
request
.headers()
.iter()
.find(|header| header.field.equiv("Authorization"))
.map(|header| header.value.as_str().trim())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
let mut diff = 0_u8;
for (a, b) in left.iter().zip(right.iter()) {
diff |= a ^ b;
}
diff == 0
}
pub(crate) fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
if !telemetry_authorized(&request, args) {
record_ingestion_rejected();
return respond_json_status(
request,
StatusCode(401),
&json!({
"ok": false,
"error": "telemetry api key is missing or invalid"
}),
);
}
let telemetry_limit = args.max_request_body_bytes.min(1024 * 1024);
let body = match read_limited_body(&mut request, telemetry_limit) {
Ok(body) => body,
Err(err) if is_payload_too_large(&err) => {
record_ingestion_rejected();
return respond_payload_too_large(request);
}
Err(err) => return Err(err),
};
let response = apply_telemetry_ingest(args, &body);
match response {
Ok(response) => {
record_ingestion_accepted();
respond_json(request, &response)
}
Err(err) => {
record_ingestion_rejected();
respond_json_status(
request,
StatusCode(400),
&json!({
"ok": false,
"error": err.to_string()
}),
)
}
}
}
pub(crate) fn apply_telemetry_ingest(args: &Cli, body: &str) -> Result<Value> {
let payload: Value =
serde_json::from_str(body).map_err(|err| anyhow!("invalid telemetry JSON: {err}"))?;
validate_telemetry_payload(&payload)?;
let received_at_utc = now();
let envelope = json!({
"received_at_utc": received_at_utc,
"prototype": true,
"record": payload,
});
if let Some(parent) = args.telemetry_store_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&args.telemetry_store_path)
.with_context(|| format!("open {}", args.telemetry_store_path.display()))?;
writeln!(file, "{}", serde_json::to_string(&envelope)?)
.with_context(|| format!("append {}", args.telemetry_store_path.display()))?;
Ok(json!({
"ok": true,
"prototype": true,
"stored": "file-backed-jsonl",
"received_at_utc": received_at_utc,
}))
}
pub(crate) fn validate_telemetry_payload(payload: &Value) -> Result<()> {
let Some(object) = payload.as_object() else {
return Err(anyhow!("telemetry payload must be a JSON object"));
};
for field in [
"agent_id",
"hostname",
"os_name",
"os_version",
"platform",
"username",
"timestamp",
"uptime_seconds",
"cpu_usage_percent",
"memory_total",
"memory_used",
"active_sessions",
"rdp_sessions",
"ssh_sessions",
"processes",
"network_interfaces",
"network_connections",
"workforce_activity",
"security_events",
"collector_version",
] {
if !object.contains_key(field) {
return Err(anyhow!("telemetry field is missing: {field}"));
}
}
for field in [
"active_sessions",
"rdp_sessions",
"ssh_sessions",
"processes",
"network_interfaces",
"network_connections",
"security_events",
] {
if payload.get(field).and_then(Value::as_array).is_none() {
return Err(anyhow!("telemetry field must be an array: {field}"));
}
}
if payload
.get("workforce_activity")
.and_then(Value::as_object)
.is_none()
{
return Err(anyhow!(
"telemetry field must be an object: workforce_activity"
));
}
Ok(())
}
+4
View File
@@ -285,6 +285,7 @@ fn is_allowed_python_runtime_path(rel: &str) -> bool {
|| rel.starts_with("pfsense/")
|| rel == "proxmox/tsj_guardian_bot.py"
|| rel == "proxmox/test_tsj_guardian_bot.py"
|| rel == "scripts/package_rust_release_binaries.py"
}
fn is_detmir_retired_runtime_path(rel: &str) -> bool {
@@ -344,6 +345,9 @@ mod tests {
assert!(is_allowed_python_runtime_path(
"pfsense/pfsense-aw-poller.py"
));
assert!(is_allowed_python_runtime_path(
"scripts/package_rust_release_binaries.py"
));
}
#[test]
+39
View File
@@ -16,6 +16,7 @@
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}"
aw_db_vacuum_timer_enabled: false
tasks:
- name: Установить базовые пакеты
@@ -589,6 +590,44 @@
daemon_reload: true
when: aw_db_maintenance_rust_binary_early.stat.exists | default(false)
- name: Установить aw-db-vacuum service до Influx проверок
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-db-vacuum.service"
dest: /etc/systemd/system/aw-db-vacuum.service
owner: root
group: root
mode: "0644"
when: aw_db_maintenance_rust_binary_early.stat.exists | default(false)
- name: Установить aw-db-vacuum timer до Influx проверок
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-db-vacuum.timer"
dest: /etc/systemd/system/aw-db-vacuum.timer
owner: root
group: root
mode: "0644"
when: aw_db_maintenance_rust_binary_early.stat.exists | default(false)
- name: Включить nightly aw-db-vacuum timer до Influx проверок
ansible.builtin.systemd:
name: aw-db-vacuum.timer
enabled: true
state: started
daemon_reload: true
when:
- aw_db_maintenance_rust_binary_early.stat.exists | default(false)
- aw_db_vacuum_timer_enabled | bool
- name: Отключить nightly aw-db-vacuum timer если opt-in не задан
ansible.builtin.systemd:
name: aw-db-vacuum.timer
enabled: false
state: stopped
daemon_reload: true
when:
- aw_db_maintenance_rust_binary_early.stat.exists | default(false)
- not (aw_db_vacuum_timer_enabled | bool)
- name: Прочитать текущий aw-server.env для сохранения Influx token
ansible.builtin.slurp:
path: /etc/activitywatch/aw-server.env
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=ActivityWatch SQLite nightly VACUUM
After=activitywatch-server.service
ConditionPathExists=/usr/local/bin/aw-db-maintenance
ConditionPathExists=/var/lib/activitywatch/aw-server-rust/sqlite.db
RequiresMountsFor=/var/lib/activitywatch
[Service]
Type=oneshot
EnvironmentFile=-/etc/activitywatch/aw-server.env
ExecStart=/usr/local/bin/aw-db-maintenance --vacuum --apply --json
TimeoutStartSec=2h
SyslogIdentifier=aw-db-vacuum
StandardOutput=journal
StandardError=journal
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
UMask=0077
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Nightly ActivityWatch SQLite VACUUM
[Timer]
OnCalendar=*-*-* 02:10:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
+55
View File
@@ -0,0 +1,55 @@
# Скрипты выполнения TЗ поддержки DetMir
В репозитории добавлены скрипты для регламентных проверок по
`docs/DETMIR_SUPPORT_TASKS_RU.md`:
- `scripts/detmir-support-run.sh --scope {daily|weekly|monthly}`
- `scripts/detmir-support-daily.sh`
- `scripts/detmir-support-weekly.sh`
- `scripts/detmir-support-monthly.sh`
## Быстрый старт
1. Скопировать шаблон окружения:
```bash
cp scripts/detmir-support.env.example scripts/detmir-support.env
```
2. Внести фактические IP/имена хостов, сервисы, пути бэкапов, VM IDs и SSH-данные.
3. Запустить нужный режим:
```bash
./scripts/detmir-support-daily.sh
./scripts/detmir-support-weekly.sh
./scripts/detmir-support-monthly.sh
```
Также можно указать отдельный каталог отчётов:
```bash
./scripts/detmir-support-run.sh --scope daily --output-dir /var/log/detmir-support
```
## Что делает скрипт
Собираются проверки по режиму:
- **daily**: ключевая доступность, сервисы, диски, VM/CT, бэкапы, task log, Suricata.
- **weekly**: всё из `daily` + расширенные проверки логов и журналов.
- **monthly**: всё из `weekly` + проверка апдейтов и базовая фиксация документов/DR-процесса.
## Результаты
В каталоге отчётов создаются файлы:
- `support-<scope>-<timestamp>.log` — детальный лог запуска
- `support-<scope>-<timestamp>.csv` — машинный реестр проверок
- `support-<scope>-<timestamp>.md` — человекочитаемый отчёт
Коды выхода:
- `0` — без ошибок и предупреждений
- `1` — есть WARN
- `2` — есть FAIL
+281
View File
@@ -0,0 +1,281 @@
# Задачи поддержки базового комплекса DetMir
Документ описывает задачи сопровождения того комплекса серверов,
виртуализации, сетевых сервисов и прикладного ПО, который существовал до
начала разработки AWatch-rus.
AWatch-rus, ActivityWatch-коллекторы, новые дашборды, новые контуры
телеметрии, ClickHouse-аналитика AWatch-rus и разработка продукта в этот
объем не входят. Их нужно оформлять отдельной следующей задачей.
Документ является рамочным ТЗ на постоянную техническую поддержку базового
контура. Он не заменяет договор, перечень доступов, инвентарную ведомость и
схему сети, но задает границы работ, ожидаемый режим сопровождения и результат,
который должен получать владелец.
## Цель поддержки
Поддержка должна обеспечивать штатную работу базовой ИТ-инфраструктуры
DetMir: серверы доступны, виртуальные машины запущены, web-сервер и
прикладное ПО отвечают штатно, данные сохраняются, резервные копии создаются,
удаленный доступ контролируется, а аварии устраняются с понятной фиксацией
причины.
## Границы ответственности
В рамках этой поддержки исполнитель отвечает за техническое сопровождение
существующего базового контура: диагностику, регламентные проверки,
административные настройки, восстановление штатной работы и документирование
изменений.
В поддержку входят только те серверы, сервисы, сетевые устройства, VM/CT,
правила доступа, VPN-подключения, web-сайты, базы данных и файловые каталоги,
которые переданы владельцем как часть существующего контура DetMir.
Если в процессе работ обнаруживается новый сервис, неизвестная VM, новый
публичный endpoint, сторонний сервис или компонент без понятного владельца, он
сначала фиксируется как находка и риск. Постоянное сопровождение такого
компонента начинается только после подтверждения владельцем, что он включается
в контур поддержки.
Работы по изменению бизнес-логики прикладного ПО, разработке новых функций,
миграции на новые платформы, покупке оборудования, продлению лицензий,
работам провайдеров и физическому монтажу кабельной инфраструктуры не
считаются включенными в базовую поддержку, если отдельно не согласованы.
## Состав поддерживаемого контура
- Узел виртуализации и размещенные на нем виртуальные машины или контейнеры.
- Web-сервер: HTTP/HTTPS-доступность, виртуальные хосты, TLS-сертификаты,
reverse proxy, access/error logs и место под web-контент.
- Существующие прикладные сервисы и связанные файловые каталоги.
- Существующие базы данных и служебные хранилища, если они уже использовались
до проекта AWatch-rus.
- Proxmox: состояние узла, VM/CT, storage, snapshots, backups, web-доступ.
- pfSense: firewall, NAT, маршрутизация, VLAN/interface status, DHCP, DNS,
gateway monitoring, логи и правила доступа.
- OpenVPN: сервер, клиентские профили, сертификаты, маршруты, доступность
подключений и контроль истекающих ключей.
- Suricata: состояние IDS/IPS, обновление правил, алерты, false positive,
исключения и влияние блокировок на рабочий трафик.
- Сетевой контур: маршрутизация, VPN, firewall, DNS, DHCP, удаленный доступ.
- Системные сервисы Linux, планировщики задач и фоновые службы.
- Резервное копирование, журналы, учетные записи и права доступа.
## Ежедневные задачи
- Проверять доступность ключевых серверов и виртуальных машин.
- Проверять состояние узла виртуализации: нагрузка CPU/RAM, свободное место,
состояние storage, отсутствие зависших VM/CT.
- Проверять Proxmox: состояние node, VM/CT, storage, backup jobs, snapshots и
ошибки в task log.
- Проверять web-сервер: HTTP/HTTPS-ответ, корректность виртуальных хостов,
срок действия TLS-сертификатов, ошибки 4xx/5xx и переполнение access/error
logs.
- Проверять работоспособность прикладных сервисов: запуск, доступность,
отсутствие явных ошибок в журналах.
- Проверять сетевую связность между основными узлами, VPN и удаленный доступ.
- Проверять состояние firewall/NAT/маршрутов, если были жалобы на доступ.
- Проверять pfSense: gateway status, interface status, DHCP leases,
firewall/NAT rules, логи блокировок по жалобам на доступ.
- Проверять OpenVPN server и клиентов: активные подключения, маршруты,
reachability внутренних сетей, ошибки TLS/auth/route push.
- Проверять Suricata: запущен ли сервис, нет ли массовых блокировок рабочего
трафика, критичных алертов и переполнения логов.
- Проверять, что критичные systemd services, timers и cron jobs
находятся в ожидаемом состоянии.
- Проверять наличие свежих резервных копий по базовым системам.
- Фиксировать найденные проблемы и выполненные действия в журнале поддержки.
## Еженедельные задачи
- Проверять свободное место на всех важных дисках и хранилищах.
- Проверять накопление старых логов, временных файлов, дампов и архивов.
- Проверять успешность резервного копирования за неделю.
- Проверять, что резервные копии реально читаются и доступны для восстановления.
- Проверять системные журналы Linux и web-сервера на повторяющиеся ошибки.
- Проверять web-сервер: рост 5xx, подозрительные запросы, устаревающие
сертификаты, переполнение логов, доступность ключевых URL и корректность
reverse proxy.
- Проверять pfSense и сетевые журналы: повторяющиеся блокировки, падение
gateway, flapping интерфейсов, ошибки DNS/DHCP/VPN.
- Проверять OpenVPN: список активных клиентов, неиспользуемые профили,
приближающиеся к истечению сертификаты, корректность клиентских маршрутов.
- Проверять Suricata alerts: отделять реальные риски от false positive,
фиксировать исключения и изменения правил.
- Проверять состояние учетных записей, срок действия паролей, лишние
административные доступы.
- Проверять стабильность VPN/удаленного доступа и отсутствие неожиданных
открытых портов.
- Готовить короткий отчет владельцу: что проверено, что исправлено, какие
риски остаются.
## Ежемесячные задачи
- Проводить контрольный тест восстановления из резервной копии на безопасном
участке данных или тестовой копии.
- Проверять обновления ОС и прикладного ПО, отдельно выделяя безопасные и
рискованные обновления.
- Планировать окно обслуживания для обновлений, перезагрузок и чистки.
- Проверять состояние сертификатов, доменных имен, VPN-профилей и учетных
данных удаленного доступа.
- Проводить аудит firewall-правил, пробросов портов и внешне доступных
сервисов.
- Проводить аудит web-сервера: виртуальные хосты, TLS-настройки, redirects,
reverse proxy, права на каталоги, logrotate, backup web-конфигурации и
отсутствие лишних публичных endpoints.
- Проводить аудит pfSense: WAN/LAN/VPN rules, NAT, aliases, floating rules,
DHCP/DNS settings, gateway groups и резервную копию конфигурации.
- Проводить аудит OpenVPN: server mode, client-specific overrides,
сертификаты, отозванные клиенты, push routes и доступы по пользователям.
- Проводить аудит Suricata: режим IDS/IPS, включенные rulesets, suppress list,
pass/drop правила и влияние на критичные сервисы.
- Проводить аудит Proxmox: версии, состояние repositories, backup schedule,
storage utilization, snapshots, права пользователей и доступ к web UI.
- Обновлять эксплуатационную документацию: IP-адреса, учетные записи без
паролей, роли серверов, схемы доступа, процедуры восстановления.
- Проверять, что нет устаревших или неизвестных сервисов, запущенных без
владельца и назначения.
## Результаты работ
Поддержка должна оставлять проверяемый результат, а не только устное сообщение
о выполненных действиях.
Минимальные результаты:
- журнал выполненных проверок и изменений;
- отметка о ежедневной проверке базового состояния;
- еженедельный отчет владельцу с перечнем проверок, исправлений и рисков;
- краткий инцидентный отчет после серьезных сбоев;
- актуализация схемы доступа, списка серверов, VM/CT, сетевых сервисов и
ответственных лиц при выявлении изменений;
- список открытых рисков и отложенных работ, которые требуют отдельного
решения владельца.
Еженедельный отчет должен быть коротким и практическим: что проверено, что
исправлено, что требует решения, какие риски остаются и какие действия
предлагаются на следующую неделю.
## Инцидентные задачи
- Восстановление доступа к серверу, VM, web-серверу или прикладному сервису.
- Устранение нехватки места, памяти, зависших процессов и служб.
- Восстановление работы прикладных сервисов, файловых каталогов, баз данных
или сетевых шар.
- Разбор web-инцидентов: недоступность HTTP/HTTPS, ошибки TLS, неверный
redirect, поломка reverse proxy, массовые 4xx/5xx, переполнение логов или
нехватка места под web-контент.
- Разбор проблем VPN, маршрутизации, DNS, firewall и удаленного доступа.
- Разбор и восстановление pfSense после ошибки правил, NAT, gateway, DHCP,
DNS, interface/VLAN или некорректного firewall reload.
- Разбор и восстановление OpenVPN server/clients: TLS/auth ошибки, истекшие
сертификаты, невыданные маршруты, недоступность внутренних сетей.
- Разбор Suricata-инцидентов: ложная блокировка рабочего трафика, всплеск
алертов, обновление правил, отключение/исключение проблемной сигнатуры.
- Восстановление Proxmox VM/CT после зависания, нехватки storage, ошибки
backup/snapshot или некорректного shutdown.
- Восстановление после неудачного обновления, сбоя питания или некорректной
перезагрузки.
- Восстановление данных из резервной копии.
- Проверка подозрительной активности: неизвестные подключения, новые учетные
записи, неожиданные открытые порты, странные timers или cron jobs.
- Подготовка краткого инцидентного отчета: причина, влияние, что сделано, что
нужно изменить, чтобы сбой не повторился.
## Критичность заявок
- P1, полный простой: недоступен ключевой сервис, web-сервер, VM, VPN,
firewall/gateway или хранилище, из-за чего пользователи не могут выполнять
основную работу.
- P2, деградация: сервис работает частично, есть ошибки, падение
производительности, нестабильность VPN/сети, переполнение диска или риск
скорого отказа.
- P3, плановая заявка: настройка, проверка, обновление, добавление правила,
выпуск сертификата, изменение доступа или регламентная работа без текущего
простоя.
- P4, консультация и документация: уточнение схемы, подготовка инструкции,
разбор логов без текущего влияния на работу, рекомендации владельцу.
Критичность может быть повышена, если проблема затрагивает удаленный доступ,
резервное копирование, безопасность периметра, публичный web-доступ или риск
потери данных.
## Правила выполнения работ
- Перед рискованными изменениями делать резервную копию конфигурации или
snapshot, если это технически возможно.
- Перед изменениями pfSense/OpenVPN/Suricata сохранять текущий config backup и
фиксировать путь отката.
- Перед изменениями Proxmox делать snapshot/backup для затронутой VM/CT, если
это не ухудшает ситуацию и есть достаточно места.
- Не менять сетевые маршруты, firewall, VPN и удаленный доступ без понимания
пути отката.
- Не устанавливать обновления на production без оценки риска и окна
обслуживания.
- Не удалять старые данные и логи без проверки, что они не нужны для бизнеса
или расследования.
- После каждого изменения проверять фактический результат: доступность сервиса,
состояние VM, HTTP/HTTPS-ответ web-сервера, журнал ошибок, место на диске.
- Все изменения фиксировать: дата, сервер, действие, причина, результат,
способ отката.
## Условия выполнения поддержки
Для нормального выполнения поддержки владелец должен предоставить и
поддерживать актуальными:
- административный доступ к Proxmox, pfSense, Linux-серверам, web-серверу,
VPN и другим компонентам, входящим в контур;
- безопасный способ хранения и передачи учетных данных;
- контакт ответственного лица для согласования рискованных изменений;
- список критичных сервисов и допустимые окна обслуживания;
- сведения о провайдерах, доменах, сертификатах, внешних адресах и каналах
связи;
- возможность получить физический доступ к оборудованию через сотрудника на
месте, если удаленная диагностика недостаточна;
- подтверждение, какие сервисы являются основными, резервными или
историческими.
Если доступы, контакты, резервные копии или сведения о контуре отсутствуют,
сроки диагностики и восстановления могут увеличиваться. Такие ограничения
должны фиксироваться как отдельный риск.
## Минимальный SLA
- Плановая проверка: один раз в рабочий день.
- Реакция на P1, полный простой ключевого сервиса: начало диагностики в течение
2-4 часов в рабочее время.
- Реакция на P2, частичная деградация: начало диагностики в течение рабочего
дня.
- Реакция на P3/P4: планирование и выполнение по согласованию с владельцем.
- Еженедельный отчет владельцу.
- Плановые изменения только с резервной копией и проверкой результата.
- После каждого серьезного инцидента: краткий отчет и список мер против
повторения.
SLA задает срок реакции и начала диагностики. Срок полного устранения зависит
от причины инцидента, доступности резервных копий, необходимости участия
провайдера, наличия оборудования, доступа к учетным данным и возможности
провести работы без риска для действующего контура.
## Что не входит в этот объем
- Разработка и сопровождение AWatch-rus как продукта.
- Новые ActivityWatch-коллекторы и новые telemetry pipelines.
- Новые Grafana-дашборды и управленческая аналитика AWatch-rus.
- Новые аналитические контуры, созданные специально под AWatch-rus.
- Коммерческая упаковка, roadmap, CI/CD и разработка новых модулей AWatch-rus.
- Покупка, поставка и замена оборудования, если это не оформлено отдельно.
- Оплата, продление и сопровождение лицензий, доменов, сертификатов и услуг
провайдеров, если это требует отдельного договора или платежа.
- Физическая прокладка кабелей, монтаж СКС, электрика и работы в серверной,
требующие присутствия монтажной организации.
- Полноценный аудит информационной безопасности, пентест, расследование
инцидента ИБ и внедрение новых средств защиты сверх текущих pfSense,
OpenVPN и Suricata.
- Разработка нового прикладного ПО, изменение бизнес-логики существующих
систем и перенос данных между платформами.
Эти работы нужно оформлять отдельным приложением к поддержке, когда владелец
готов заказывать сопровождение уже разработанной системы AWatch-rus.
+4 -1
View File
@@ -2,7 +2,7 @@
Цель сценария: за 10-12 минут показать AWatch-rus как рабочую платформу
Workforce Analytics + Security Analytics + Forensics без ложных заявлений о
SIEM, классическом DLP, ML/LLM или готовом pfSense ingestion.
SIEM, классическом DLP, ML/LLM .
Демонстрация проводится только на обезличенных данных. Demo fixtures не
являются live ingestion и не подменяют промышленную настройку источников.
@@ -101,6 +101,9 @@ SIEM, классическом DLP, ML/LLM или готовом pfSense ingesti
- UEBA v1 является rule-based моделью;
- pfSense readiness не означает production ingestion;
- Не заявлять готовый pfSense production ingestion / SIEM ingestion. pfSense
в текущем пилоте допускается только как contract/readiness/optional
integration layer.
- риск является сигналом для проверки, а не автоматическим вердиктом.
Что не заявлять:
+199
View File
@@ -0,0 +1,199 @@
# Pilot freeze readiness AWatch-rus
Статус: pilot freeze candidate.
Документ фиксирует gate перед заморозкой текущего `main` для пилота. Freeze не
добавляет новую функциональность, не меняет архитектуру и не упрощает
существующие контуры. Цель gate - подтвердить, что текущий main можно
демонстрировать и донастраивать в пилоте без расширения заявлений о продукте.
## 1. Границы продукта
AWatch-rus Pilot v1 позиционируется как Workforce-first платформа с отдельными
ролями Security и Forensics.
В рамках pilot freeze запрещено заявлять AWatch-rus как:
- SIEM;
- enterprise DLP;
- EDR/XDR;
- сертифицированное СЗИ;
- ML/LLM UEBA;
- готовый pfSense production ingestion или SIEM ingestion.
UEBA v1 является rule-based risk scoring для ручной проверки. Риск не является
автоматическим вердиктом и не доказывает нарушение без анализа человеком.
pfSense в текущем пилоте допускается только как
contract/readiness/optional integration layer. Его статус в demo/pilot
материалах: `contract_only` или `readiness`, если отдельный production
ingestion не включен и не принят отдельным gate.
## 2. Архитектурная фиксация
Pilot freeze candidate сохраняет текущую архитектуру:
- Rust Backend;
- Rust Agent;
- Rust-first operational CLIs and services;
- HTML/HTMX Portal;
- role-based Pilot v1;
- documented PowerShell runtime/fallback/installer/repair layer до отдельного
retirement gate;
- future React/TypeScript UI только как future direction;
- Tauri только как future desktop Forensics;
- Dioxus не рассматривается.
Breaking changes, architecture changes и simplifications в рамках freeze gate
запрещены.
## 3. Роли Pilot v1
Ожидаемая видимость ролей:
| Роль | Видимость |
|---|---|
| `executive` | Только executive-level: главный вывод, агрегированные риски, итоговые управленческие действия без сырых технических очередей. |
| `manager` | Workforce/department/owner/activity: загрузка, подразделения, ответственные, тренды и управленческие отчеты. |
| `security` | Security/UEBA/pfSense readiness: candidates, severity, rule-based UEBA, security context и `contract_only` readiness. |
| `forensics` | Investigation/evidence/reporting: карточки расследований, timeline, evidence package и Markdown export. |
| `admin` | Административные и технические функции: полнота данных, качество сбора, источники, fallback, service/readiness status. |
Если документация или тесты расходятся с этой матрицей, исправлять нужно
документацию или тестовые ожидания. Бизнес-логику менять только при отдельном
подтвержденном дефекте.
## 4. Обязательные проверки
Перед фиксацией pilot freeze candidate выполнить:
```bash
cd adk-rust
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace --release
```
Из корня репозитория выполнить:
```bash
bash scripts/verify_release_assets.sh dist/release-v0.2
bash scripts/check_private_config_guard.sh
bash scripts/check_production_inventory_placeholders.sh
bash scripts/check_demo_safety.sh
node scripts/pilot-validation-smoke.mjs
node scripts/detmir-pilot-demo-smoke.mjs
node scripts/detmir-portal-tabs-smoke.mjs
```
Для Windows/PowerShell слоя выполнить синтаксические и policy-safe проверки на
Windows-хосте или в CI-среде, где доступен PowerShell:
```powershell
Get-ChildItem .\windows -Filter *.ps1 -Recurse | ForEach-Object {
$tokens = $null
$errors = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile(
$_.FullName,
[ref]$tokens,
[ref]$errors
)
if ($errors.Count -gt 0) { throw "$($_.FullName): $($errors[0].Message)" }
}
```
Также проверить release/demo artifacts:
```bash
git diff --check
bash scripts/check_demo_safety.sh
```
Ожидаемый результат для публичных tracked docs: нет live IP, hostnames, ФИО,
логинов сотрудников заказчика, названий подразделений заказчика, live case IDs,
runtime evidence paths, токенов и паролей. Допустимы только placeholders,
TEST-NET адреса, `.example` файлы и явно обезличенные demo fixtures.
## 5. PowerShell/Rust migration gate
Rust-primary направление сохраняется.
Это не заявление о полном удалении PowerShell. В текущем pilot freeze часть
PowerShell допустима как documented fallback/installer/support layer.
PowerShell scripts не удалять автоматически. Оставшиеся runtime/fallback пути
нельзя удалять до выполнения всех условий:
- Rust replacement имеет parity по данным и ошибкам;
- прошел burn-in на пилотном контуре;
- выполнен canary/rollback gate;
- rollback path документирован и проверен;
- Scheduled Tasks/Services больше не ссылаются на удаляемый script;
- acceptance gate явно разрешает удаление.
Installer/repair PowerShell layer можно заменять постепенно: Rust dry-run,
structured report, apply mode, затем удаление старого слоя только после
проверки ссылок и отката.
Полное удаление PowerShell не входит в текущий pilot freeze.
## 6. Demo data hygiene
Demo data, screenshots, evidence pack и отчеты не должны содержать:
- реальные IP-адреса;
- реальные hostnames;
- ФИО сотрудников заказчика;
- логины сотрудников заказчика;
- названия подразделений заказчика;
- реальные security events;
- live case IDs;
- runtime evidence paths.
Для сетевых примеров использовать только RFC 5737 TEST-NET адреса:
`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`.
Автоматический gate:
```bash
bash scripts/check_demo_safety.sh
```
Gate проверяет корневой `README.md`, demo/pilot/customer-facing документы,
`docs/demo/`, `docs/fixtures/`, evidence-pack и screenshots metadata. Перед
публикацией demo-pack дополнительно нужен ручной визуальный просмотр PNG:
скрипт проверяет PNG-сигнатуру, размер и текстовые метаданные, но не выполняет
OCR содержимого изображения.
## 7. Known limitations
- Pilot v1 не является сертифицированным средством защиты информации.
- UEBA v1 rule-based и не использует ML/LLM claims.
- pfSense находится в `contract_only/readiness` контуре, если ingestion не
включен отдельным production gate.
- Некоторые PowerShell runtime/fallback пути сохраняются до завершения
burn-in/canary/rollback gate.
- Demo fixtures не являются production ingestion и не доказывают полноту
production-интеграций.
- Security candidates и derived cases являются сигналами для ручной проверки,
а не подтвержденными инцидентами.
## 8. Follow-up после freeze
- Update GitHub Actions versions / Node runtime deprecation cleanup.
Этот пункт является техническим долгом и не блокирует pilot freeze. Менять
workflow в рамках freeze можно только минимально, без изменения release
semantics и только при сохранении зеленого CI.
## 9. Freeze decision
Pilot freeze candidate можно фиксировать только если:
- обязательные проверки выполнены или documented exception внесен в release
notes;
- границы продукта в README, pilot docs и acceptance docs согласованы;
- demo data hygiene подтверждена;
- PowerShell fallback не удален без gate;
- known limitations включены в материалы пилота.
+5
View File
@@ -13,6 +13,9 @@
и Executive Action Center.
- Есть Rust Backend, Rust Agent baseline, HTML/HTMX portal и role-based
контракты Pilot v1.
- Rust-primary runtime зафиксирован честно: PowerShell не заявлен полностью
удаленным, оставшиеся runtime/fallback/installer/repair scripts сохраняются
как documented fallback/support layer.
- Подготовлен demo pack: сценарии руководителя, ИБ и расследований, синтетический
demo dataset, evidence pack, пример итогового отчета и value proposition.
- Подготовлены registry readiness документы без ложных заявлений о сертификации.
@@ -57,6 +60,8 @@
## Что отложено на roadmap
- Расширенные agentless providers: PowerShell, SSH, Syslog, 1C, VPN и SCUD.
- Отдельная PowerShell retirement-задача после burn-in периода, canary test,
rollback plan и acceptance gate.
- Production validation российских ОС: Astra Linux, РЕД ОС, Альт и РОСА.
- Расширенный React/TypeScript Enterprise UI и Tauri Desktop Forensics.
- Глубокие enterprise-коннекторы AD/LDAP, SIEM и корпоративных учетных систем.
+6
View File
@@ -73,6 +73,9 @@ Portal smoke подтвердил:
Risk -> Investigation -> Report`.
- Rust-first runtime покрывает критичные серверные helpers, DLP/worktime paths,
readiness, portal и собственный агент.
- Rust-first здесь означает Rust-primary направление, а не полное удаление
PowerShell: runtime/fallback/installer/repair scripts остаются
документированным слоем отката, установки и поддержки.
- pfSense/network perimeter оставлен optional/read-only слоем, что снижает риск
пилота и соответствует текущим ограничениям проекта.
- Python сохранен только для оговоренных исключений, не как ядро продукта.
@@ -227,6 +230,8 @@ enterprise endpoint agent`.
- Readiness bundle, checksum/signature и Prometheus/Grafana readiness ideas уже
заложены в документах и тестах.
- Legacy fallback сохранен там, где это нужно для rollback.
- Полное удаление PowerShell должно быть отдельной задачей после burn-in,
canary test, rollback plan и acceptance gate.
Слабые стороны:
@@ -246,6 +251,7 @@ enterprise endpoint agent`.
поломки старого API.
- Отсутствие JSON state файлов обрабатывается.
- PowerShell collector сохранен как legacy fallback, а не удален.
- Pilot freeze не включает автоматическое удаление PowerShell scripts.
Слабые стороны:
+4
View File
@@ -50,6 +50,10 @@
## Agent сценарий
- Rust Agent baseline описан и проверяется через документацию.
- Rust-primary направление не трактуется как полное удаление PowerShell.
- Runtime/fallback/installer/repair PowerShell scripts остаются
documented fallback/support layer до отдельного burn-in/canary/rollback
и acceptance gate.
- Для пилота определены источники данных, ожидаемое покрытие и допустимая
задержка.
- Потеря части данных снижает confidence и фиксируется как operational gap.
+110
View File
@@ -0,0 +1,110 @@
# Матрица PowerShell-скриптов AWatch-rus
Дата актуализации: 2026-06-17
Документ фиксирует фактический статус оставшихся PowerShell-файлов после
перехода на Rust-first контур. Это не означает, что PowerShell полностью
удален. Цель матрицы: отделить рабочий runtime от установочных действий,
аварийного отката и устаревших проверочных скриптов.
В tracked документации не публикуются имена рабочих хостов, private IP,
учетные записи, токены и runtime evidence paths. Live-проверка выполнялась по
фактическим Scheduled Tasks, процессам и deployment config, но приватные
идентификаторы здесь заменены на обобщенные описания.
## Классы
| Класс | Значение |
|---|---|
| `runtime` | Скрипт участвует в штатном выполнении или является зависимостью штатного выполнения. |
| `installer` | Скрипт нужен для установки, настройки, проверки или ремонта, но не должен постоянно работать. |
| `fallback` | Скрипт оставлен как аварийный откат или reference-слой после перевода штатного пути на Rust. |
| `obsolete` | Скрипт не нужен для штатной работы; кандидат на удаление после проверки ссылок и задач. |
## Фактический runtime-базис
Штатный контур уже Rust-first для основных Windows-сборщиков:
- загрузка 1C/file telemetry выполняется через `aw-windows-telemetry.exe file1c-upload`;
- синхронизация DLP evidence выполняется через `aw-windows-telemetry.exe dlp-evidence-sync`;
- browser/DLP/file-operations collectors выполняются через подкоманды `aw-windows-telemetry.exe`;
- учет рабочего времени выполняется через `awatch-agent-rs.exe`;
- collector guard выполняется через `aw-windows-telemetry.exe collector-guard`.
Оставшийся рабочий PowerShell runtime на момент проверки:
- сгенерированный recovery loop;
- сгенерированный per-user launcher;
- Hayabusa/EVTX upload path.
## Runtime и generated runtime
| Файл | Класс | Фактическая роль | Штатный путь сейчас | Решение |
|---|---|---|---|---|
| `C:\ProgramData\AWatch-rus\launch-watchers.ps1` | `runtime` | Сгенерированный пользовательский launcher для watchers/collectors. | Запускается через скрытый VBS-wrapper из per-user Scheduled Tasks. | Оставить до замены launcher-а на Rust/service-friendly task reconciler. |
| `C:\ProgramData\AWatch-rus\recovery-loop.ps1` | `runtime` | Сгенерированный recovery loop. | Запускается через скрытый VBS-wrapper из recovery Scheduled Task. | Мигрировать в Rust recovery/guard, затем убрать PowerShell runtime. |
| `windows/export-upload-hayabusa-to-aw-server.ps1` | `runtime` | Выгрузка Hayabusa/EVTX evidence на сервер. | Используется отдельной Scheduled Task. | P1: перенести в `aw-windows-telemetry.exe` или отдельный Rust EXE. До миграции не удалять. |
| `windows/export-evtx-for-hayabusa.ps1` | `runtime` | Экспорт EVTX для Hayabusa upload path. | Используется как зависимость Hayabusa upload path. | P1: перенести bounded EVTX export в Rust. До миграции не удалять. |
## Fallback после Rust-first миграции
| Файл | Класс | Фактическая роль | Штатный путь сейчас | Решение |
|---|---|---|---|---|
| `windows/worktime-session-collector.ps1` | `fallback` | Legacy worktime/RDP collector. | `awatch-agent-rs.exe` является основным runtime. | Оставить до отключения `worktimeLegacyFallbackEnabled` после acceptance gate. |
| `windows/browser-domains-native-collector.ps1` | `fallback` | Legacy browser/domain collector. | `aw-windows-telemetry.exe browser-domains-collector` является основным runtime. | Оставить как rollback/reference до расширенной parity-проверки. |
| `windows/dlp-endpoint-signals-collector.ps1` | `fallback` | Legacy DLP endpoint collector. | `aw-windows-telemetry.exe dlp-endpoint-collector` является основным runtime. | Оставить как rollback/reference до расширенной parity-проверки. |
| `windows/file-operations-collector.ps1` | `fallback` | Legacy file operations collector. | `aw-windows-telemetry.exe file-operations-collector` является основным runtime. | Оставить как rollback/reference до расширенной parity-проверки. |
| `windows/dlp-policy-client.ps1` | `fallback` | Получение policy для PowerShell DLP collector. | Нужен только если включается legacy DLP collector. | Удалять вместе с PowerShell DLP fallback, не раньше. |
| `windows/export-upload-file-1c-telemetry.ps1` | `fallback` | Legacy 1C/file telemetry upload. | `aw-windows-telemetry.exe file1c-upload` является основным runtime. | Оставить rollback до нескольких успешных циклов production upload. |
| `windows/sync-dlp-evidence-artifacts.ps1` | `fallback` | Legacy DLP evidence sync. | `aw-windows-telemetry.exe dlp-evidence-sync` является основным runtime. | Оставить rollback до закрепления Rust evidence sync. |
| `windows/aw-collector-guard.ps1` | `fallback` | Legacy collector guard. | `aw-windows-telemetry.exe collector-guard` является основным runtime. | Оставить rollback до подтверждения guard/recovery parity. |
| `windows/aw-standalone-service.ps1` | `fallback` | Legacy standalone supervisor wrapper. | Не является целевым runtime для нового Rust-first контура. | Держать только для отката старого install-kit, затем удалить. |
## Installer, repair и operator layer
| Файл | Класс | Фактическая роль | Штатный путь сейчас | Решение |
|---|---|---|---|---|
| `windows/ActivityWatch.Windows.Common.psm1` | `installer` | Общие PowerShell-функции для установки/обслуживания. | Используется установочными и repair-скриптами. | Оставить до замены installer layer. |
| `windows/ActivityWatch.Windows.Common.psd1` | `installer` | Manifest общего PowerShell-модуля. | Используется вместе с `.psm1`. | Оставить до замены installer layer. |
| `windows/deploy-single-user.ps1` | `installer` | Установка для одного пользователя. | Установочный сценарий, не постоянный runtime. | Постепенно заменить Rust bootstrap/Ansible action. |
| `windows/deploy-domain-users.ps1` | `installer` | Установка для доменных пользователей. | Установочный сценарий, не постоянный runtime. | Постепенно заменить Rust bootstrap/Ansible action. |
| `windows/deploy-ensemble.ps1` | `installer` | Оркестрация Windows-компонентов установки. | Установочный сценарий, не постоянный runtime. | Постепенно заменить Rust deploy coordinator или Ansible wrapper. |
| `windows/install-standalone-service.ps1` | `installer` | Установка standalone service. | Установочное действие. | Заменить Rust installer/bootstrap CLI. |
| `windows/install-collector-guard-service.ps1` | `installer` | Установка collector guard service. | Установочное действие. | Заменить Rust installer/bootstrap CLI. |
| `windows/install-dlp-client.ps1` | `installer` | Установка DLP client части. | Установочное действие. | Заменить Rust installer/bootstrap CLI. |
| `windows/migrate-awatch-rus-paths.ps1` | `installer` | Миграция путей AWatch-rus. | Разовое migration/repair действие. | Заменить Rust migration CLI с backup/dry-run. |
| `windows/rebuild-worktime-tasks.ps1` | `installer` | Пересборка worktime Scheduled Tasks. | Repair/installer действие. | Заменить Rust task reconciliation CLI. |
| `windows/fix-session-watchers.ps1` | `installer` | Ремонт watcher-ов по сессиям. | Repair действие. | Заменить Rust repair subcommand. |
| `windows/cleanup-disc-sessions.ps1` | `installer` | Очистка устаревших disconnected-session артефактов. | Maintenance/repair действие. | Заменить Rust maintenance subcommand. |
| `windows/hardening-recovery.ps1` | `installer` | Восстановление и hardening после повреждения установки. | Emergency repair, не постоянный runtime. | Заменить Rust recovery CLI; опасные действия только через явный apply-режим. |
| `windows/validate-deployment.ps1` | `installer` | Проверка установки. | Частично заменен `aw-windows-telemetry.exe validate-deployment`. | Расширить Rust validation до полной parity, затем удалить `.ps1`. |
| `windows/audit-cryptopro.ps1` | `installer` | Аудит CryptoPro/сертификатного окружения. | Операторская проверка, не постоянный runtime. | Оставить до появления Rust audit CLI или отдельного Ansible-only gate. |
| `scripts/powershell/detmir-powershell-profile.ps1` | `installer` | Операторский PowerShell profile/helper. | Не является runtime AWatch-rus. | Оставить как operator helper, либо заменить документацией/CLI aliases. |
## Obsolete / parked
| Файл | Класс | Фактическая роль | Штатный путь сейчас | Решение |
|---|---|---|---|---|
| `windows/run-user1-probe.ps1` | `obsolete` | Ручной диагностический probe одного пользователя. | Не нужен для штатного runtime. | Удалить после проверки отсутствия ссылок в runbook/CI/tasks. |
| `.pssa_run.ps1` | `obsolete` | Локальный helper для PSScriptAnalyzer. | Не является продуктовым компонентом. | Удалить после завершения PowerShell retirement или заменить CI-командой. |
| `windows/email-outbound-collector.ps1` | `obsolete` | Legacy outbound email metadata collector. | Функция не включена в текущий runtime. | Не возвращать в production как PowerShell; при необходимости реализовывать заново в Rust с отдельным privacy/legal gate. |
## Правила дальнейшей миграции
Полное удаление PowerShell не входит в текущий pilot freeze. Это должна быть
отдельная задача после подтверждения Rust parity и отката.
1. Не удалять `runtime` и `fallback` скрипты без burn-in/canary/rollback gate,
acceptance gate и проверенного rollback-плана.
2. Любой PowerShell runtime, который остается в Scheduled Tasks или Services,
должен иметь явный владелец, причину сохранения и целевой Rust replacement.
3. `installer`-скрипты можно заменять постепенно: сначала Rust dry-run и
structured report, затем apply-режим, затем удаление PowerShell layer.
4. `obsolete`-скрипты удалять только после `rg`-проверки ссылок, проверки
Scheduled Tasks/Services и контрольного deploy/validation прогона.
5. Новые runtime-функции не добавлять на PowerShell. Для runtime, long-running
collectors, upload, guard и validation целевой путь: Rust EXE или service.
6. Pilot freeze не является основанием для автоматического удаления
PowerShell layer: оставшиеся runtime/fallback пути сохраняются до
подтвержденной стабильности Rust replacement, canary-наблюдения и
документированного отката.
+3
View File
@@ -9,6 +9,9 @@
Документ описывает порядок миграции. Он не вводит новые функции: каждая Rust
замена сначала должна повторить текущий контракт PowerShell-компонента.
Актуальная статусная матрица оставшихся PowerShell-файлов:
[`POWERSHELL_SCRIPT_STATUS_MATRIX_RU.md`](POWERSHELL_SCRIPT_STATUS_MATRIX_RU.md).
Операторский checkpoint от 2026-06-05 сохранен в private `.ops`-контуре:
Phase 0 live inventory выполнен, Phase 2 validation для уже переведенных
Windows Rust paths выполнен успешно. В tracked документации не публикуются live
+3
View File
@@ -128,6 +128,9 @@ git ls-files | grep -E '(^|/)secrets(/|$)|\.env$|inventory\.ini$' || true
- Live inventory, live domains, private IPs, case IDs, forensic paths,
screenshots с реальными пользователями и runtime evidence не входят в Git.
- Telegram runtime остается Python.
- PowerShell не считается полностью удаленным: оставшиеся
runtime/fallback/installer/repair scripts допустимы как документированный
слой отката и поддержки до отдельного burn-in/canary/rollback/acceptance gate.
- pfSense/network layer не входит в этот release-readiness этап.
## 8. Следующий уровень
+20
View File
@@ -14,6 +14,7 @@
| CI checksum/signature self-test | `.github/workflows/release-assets.yml` |
| Pilot acceptance act | `docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md` |
| pfSense perimeter positioning | `docs/NETWORK_PERIMETER_PFSENSE_RU.md` |
| Pilot freeze gate | `docs/PILOT_FREEZE_READINESS_RU.md` |
## 2. Machine SBOM as GitHub Release asset
@@ -94,6 +95,22 @@ docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md
Использовать для коммерческого пилота после установки и первичной настройки.
Публичная версия должна оставаться обезличенной.
Перед фиксацией pilot freeze candidate использовать:
```text
docs/PILOT_FREEZE_READINESS_RU.md
```
Этот gate не добавляет функциональность. Он проверяет, что текущий main
остается в границах Pilot v1: Rust Backend, Rust Agent, HTML/HTMX Portal,
role-based views, rule-based UEBA v1, pfSense только как
contract/readiness/optional integration layer и сохраненный PowerShell
runtime/fallback до burn-in/canary/rollback gate.
Полное удаление PowerShell не входит в `release-readiness-v0.2` или текущий
pilot freeze: оставшиеся runtime/fallback/installer/repair scripts считаются
документированным слоем отката, установки и поддержки.
## 7. pfSense perimeter
Документ:
@@ -115,3 +132,6 @@ docs/NETWORK_PERIMETER_PFSENSE_RU.md
- GitHub release содержит SBOM JSON/TXT, manifest, checksum и signature.
- `CUSTOMER_PILOT_ACCEPTANCE_RU.md` заполнен для пилотного заказчика.
- pfSense не описан как обязательный компонент продукта.
- `PILOT_FREEZE_READINESS_RU.md` заполнен для pilot freeze candidate.
- PowerShell retirement не заявлен как выполненный без отдельного
burn-in/canary/rollback/acceptance gate.
+3
View File
@@ -41,4 +41,7 @@ v0.3 добавляет объяснительный audit layer поверх э
- Документы v0.3 не содержат live IP/domains/secrets/evidence.
- pfSense описан только как optional integration/perimeter layer.
- SIEM/UEBA/DLP не заявлены как основной сертифицированный класс.
- Rust-primary runtime описан честно: PowerShell не заявлен полностью
удаленным, а оставшиеся runtime/fallback/installer/repair scripts сохраняются
до отдельного burn-in/canary/rollback/acceptance gate.
- Pilot checklist и acceptance act готовы для customer-facing заполнения.
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
python3 - "$@" <<'PY'
import ipaddress
import json
import re
import sys
from pathlib import Path
ROOT = Path.cwd()
JSON_MODE = "--json" in sys.argv[1:]
TEXT_SUFFIXES = {
".csv",
".html",
".htm",
".json",
".md",
".mjs",
".sql",
".txt",
".yaml",
".yml",
}
SAFE_IP_NETWORKS = [
ipaddress.ip_network("192.0.2.0/24"),
ipaddress.ip_network("198.51.100.0/24"),
ipaddress.ip_network("203.0.113.0/24"),
]
SAFE_IPS = {
ipaddress.ip_address("0.0.0.0"),
ipaddress.ip_address("127.0.0.1"),
}
SAFE_EMAIL_DOMAINS = {
"example.com",
"example.net",
"example.org",
"invalid",
"localhost",
}
SAFE_SECRET_VALUES = {
"",
"***",
"****",
"xxxxx",
"xxxx",
"change_me",
"changeme",
"redacted",
"placeholder",
"example",
"demo",
"demo-only",
"<token>",
"<secret>",
"<password>",
"<cookie>",
}
IPV4_RE = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,}|localhost|invalid)\b")
FORBIDDEN_DOMAIN_RE = re.compile(
r"\b(?:dm\.iri|sevnb\.ru|msk\.sevnb\.ru|dns\.sevnb\.ru|iri1968|SHARKON2025)\b",
re.IGNORECASE,
)
SECRET_ASSIGN_RE = re.compile(
r"""(?ix)
\b(password|passwd|pwd|token|secret|api[_-]?key|bearer|cookie|session[_-]?(?:id|secret|token)?)\b
\s*[:=]\s*
["']?([^"'\s,;}]+)
"""
)
BEARER_RE = re.compile(r"\bAuthorization\s*:\s*Bearer\s+([A-Za-z0-9._~+/=-]+)", re.IGNORECASE)
WORKSTATION_RE = re.compile(r"\b(?:DESKTOP|LAPTOP|WIN|WS)-[A-Z0-9][A-Z0-9-]{3,}\b")
CYRILLIC_FIO_RE = re.compile(r"\b[А-ЯЁ][а-яё]{2,}\s+[А-ЯЁ][а-яё]{2,}\s+[А-ЯЁ][а-яё]{2,}\b")
def is_safe_ip(value: str) -> bool:
try:
ip = ipaddress.ip_address(value)
except ValueError:
return True
return ip in SAFE_IPS or any(ip in network for network in SAFE_IP_NETWORKS)
def is_safe_email(domain: str, email: str) -> bool:
normalized = domain.lower()
if normalized in SAFE_EMAIL_DOMAINS:
return True
if normalized.endswith(".example") or normalized.endswith(".invalid"):
return True
return "demo" in email.lower()
def is_safe_secret_value(value: str) -> bool:
cleaned = value.strip().strip("\"'").strip()
lowered = cleaned.lower()
if lowered in SAFE_SECRET_VALUES:
return True
if cleaned.startswith("<") and cleaned.endswith(">"):
return True
if set(cleaned) <= {"*", "x", "X", "-"}:
return True
return False
def collect_scope() -> list[Path]:
files = [ROOT / "README.md"]
for path in (ROOT / "docs").rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(ROOT).as_posix()
name = path.name
if rel.startswith(("docs/demo/", "docs/fixtures/", "docs/screenshots/", "docs/assets/screenshots/")):
files.append(path)
continue
if any(marker in name for marker in ("DEMO", "PILOT", "CUSTOMER", "RELEASE_READINESS", "FREEZE")):
files.append(path)
return sorted(set(files))
def add_finding(findings: list[dict], path: Path, line_no: int, rule: str, value: str) -> None:
findings.append(
{
"file": path.relative_to(ROOT).as_posix(),
"line": line_no,
"rule": rule,
"value": value,
}
)
def scan_line(findings: list[dict], path: Path, line_no: int, line: str) -> None:
stripped = line.strip()
if stripped.startswith("#") and "grep -n" in stripped:
return
for match in IPV4_RE.finditer(line):
value = match.group(0)
if not is_safe_ip(value):
add_finding(findings, path, line_no, "real_ipv4_not_in_demo_range", value)
for match in EMAIL_RE.finditer(line):
email = match.group(0)
domain = match.group(1)
if not is_safe_email(domain, email):
add_finding(findings, path, line_no, "non_demo_email", email)
for match in FORBIDDEN_DOMAIN_RE.finditer(line):
add_finding(findings, path, line_no, "known_live_domain_or_codename", match.group(0))
for match in SECRET_ASSIGN_RE.finditer(line):
value = match.group(2)
if not is_safe_secret_value(value):
add_finding(findings, path, line_no, "credential_like_assignment", match.group(0))
for match in BEARER_RE.finditer(line):
value = match.group(1)
if not is_safe_secret_value(value):
add_finding(findings, path, line_no, "bearer_secret", "Authorization: Bearer ...")
for match in WORKSTATION_RE.finditer(line):
value = match.group(0)
if "DEMO" not in value:
add_finding(findings, path, line_no, "realistic_workstation_name", value)
for match in CYRILLIC_FIO_RE.finditer(line):
add_finding(findings, path, line_no, "possible_cyrillic_fio", match.group(0))
def scan_text_file(path: Path) -> list[dict]:
findings: list[dict] = []
text = path.read_text(encoding="utf-8", errors="ignore")
for line_no, line in enumerate(text.splitlines(), start=1):
scan_line(findings, path, line_no, line)
return findings
def scan_png(path: Path) -> list[dict]:
findings: list[dict] = []
data = path.read_bytes()
png_signature = b"\x89PNG\r\n\x1a\n"
if len(data) <= 1000 or not data.startswith(png_signature):
add_finding(findings, path, 0, "screenshot_not_png_or_too_small", str(len(data)))
return findings
metadata_text = data.decode("latin-1", errors="ignore")
for line_no, line in enumerate(metadata_text.splitlines(), start=1):
scan_line(findings, path, line_no, line)
return findings
def main() -> int:
files = collect_scope()
findings: list[dict] = []
for path in files:
if path.suffix.lower() == ".png":
findings.extend(scan_png(path))
elif path.suffix.lower() in TEXT_SUFFIXES:
findings.extend(scan_text_file(path))
result = {
"ok": not findings,
"scope_files": [path.relative_to(ROOT).as_posix() for path in files],
"findings": findings,
}
if JSON_MODE:
print(json.dumps(result, ensure_ascii=False, indent=2))
elif findings:
print("Demo safety check failed:", file=sys.stderr)
for finding in findings:
print(
f"{finding['file']}:{finding['line']}: {finding['rule']}: {finding['value']}",
file=sys.stderr,
)
else:
print(f"Demo safety check passed ({len(files)} files scanned).")
return 0 if not findings else 2
if __name__ == "__main__":
raise SystemExit(main())
PY
@@ -0,0 +1,41 @@
# Полная диагностика развернутого контура DetMir
Пакет предназначен для расширенной проверки уже развернутого контура
DetMir, не заменяет и не дублирует скрипты TЗ поддержки.
Состав:
- `detmir-full-diagnostics.sh` — оркестратор:
- `aw-contour-diag.sh` (по-умолчанию полный прогон),
- `detmir-support-run.sh` (daily/weekly/monthly),
- `check_production_inventory_placeholders.sh`.
- `detmir-full-diagnostics-daily.sh` — быстрый старт с `--scope daily`.
- `detmir-full-diagnostics-weekly.sh` — запуск с `--scope weekly`.
- `detmir-full-diagnostics-monthly.sh` — запуск с `--scope monthly`.
- `detmir-full-diagnostics.env.example` — шаблон параметров.
## Быстрый старт
1. Скопировать файл конфигурации:
```bash
cp scripts/detmir-full-diagnostics/detmir-full-diagnostics.env.example \
scripts/detmir-full-diagnostics/detmir-full-diagnostics.env
```
2. Заполнить пути/хосты под текущий контур.
3. Запуск:
```bash
./scripts/detmir-full-diagnostics/detmir-full-diagnostics-daily.sh
./scripts/detmir-full-diagnostics/detmir-full-diagnostics.sh --scope weekly --quick
./scripts/detmir-full-diagnostics/detmir-full-diagnostics-monthly.sh --output-dir /var/log/detmir-full-diagnostics
```
## Примечание
- Для запуска в `~/root/scripts/support` пакет может использовать скопированные
`aw-contour-diag.sh` и `check_production_inventory_placeholders.sh`.
Если локально эти файлы не требуются — удалите их или отключите шаги флагами.
- Для запуска в окружении с root-доступом через `sudo` выполняйте скрипты как
пользователь с правом `sudo` без `NOPASSWD`.
@@ -0,0 +1,507 @@
#!/usr/bin/env bash
# aw-contour-diag.sh - Диагностика всего контура ActivityWatch-Russian
# Запускать с машины администратора (где есть доступ по SSH/curl ко всем узлам).
#
# Использование:
# ./scripts/aw-contour-diag.sh # полная диагностика
# ./scripts/aw-contour-diag.sh --quick # быстрая (только AW server + buckets)
# ./scripts/aw-contour-diag.sh --skip-windows # без RDP/WinRM проверок
#
# При красных проверках в скрипте указаны разделы 'REMEDIATION: ...'
# с конкретными командами для восстановления.
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ANSIBLE_DIR="$REPO_ROOT/ansible"
INVENTORY="$ANSIBLE_DIR/inventory.ini"
AW_SERVER="http://10.10.10.13:5600"
AW_WORKTIME_API="http://10.10.10.13:5610"
INFLUXDB_URL="http://10.10.10.10:8086"
GRAFANA_URL="http://10.10.10.11:3000"
PROXMOX_HOST="10.10.10.2"
AW_HOST="10.10.10.13"
GRAFANA_HOST="10.10.10.11"
INFLUXDB_HOST="10.10.10.10"
WINDOWS_HOST="192.168.100.18"
CLICKHOUSE_HOST="10.10.10.2"
SOURCE_HOSTNAME="SHARKON2025"
QUICK_MODE=0
SKIP_WINDOWS=0
while [[ $# -gt 0 ]]; do
case "$1" in
--quick) QUICK_MODE=1; shift ;;
--skip-windows) SKIP_WINDOWS=1; shift ;;
-h|--help)
echo "Usage: $(basename "$0") [--quick] [--skip-windows]"
exit 0 ;;
*) echo "Unknown: $1"; exit 2 ;;
esac
done
export no_proxy="localhost,127.0.0.1,$PROXMOX_HOST,$AW_HOST,$GRAFANA_HOST,$INFLUXDB_HOST,$WINDOWS_HOST,$CLICKHOUSE_HOST,10.10.10.0/24,192.168.100.0/24"
export NO_PROXY="$no_proxy"
OK_COUNT=0; WARN_COUNT=0; FAIL_COUNT=0; SKIP_COUNT=0
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; NC=''
fi
pass() { OK_COUNT=$((OK_COUNT+1)); printf "%b[OK]%b %s\n" "$GREEN" "$NC" "$*"; }
warn() { WARN_COUNT=$((WARN_COUNT+1)); printf "%b[WARN]%b %s\n" "$YELLOW" "$NC" "$*"; }
fail() { FAIL_COUNT=$((FAIL_COUNT+1)); printf "%b[FAIL]%b %s\n" "$RED" "$NC" "$*"; }
skip() { SKIP_COUNT=$((SKIP_COUNT+1)); printf "%b[SKIP]%b %s\n" "$YELLOW" "$NC" "$*"; }
section() { printf "\n%b=== %s ===%b\n" "$CYAN" "$*" "$NC"; }
have() { command -v "$1" >/dev/null 2>&1; }
check_tcp() {
local name="$1" host="$2" port="$3"
if timeout 4 bash -c ":</dev/tcp/${host}/${port}" >/dev/null 2>&1; then
pass "TCP $host:$port ($name)"
else
fail "TCP $host:$port ($name)"
echo " REMEDIATION: Проверьте, запущен ли сервис на $host:$port."
echo " Для systemd: ssh igor@$host 'systemctl status <unit>'"
echo " Для Docker: ssh igor@$PROXMOX_HOST 'sudo docker ps | grep <container>'"
fi
}
check_http_code() {
local name="$1" url="$2" expected="${3:-^2[0-9][0-9]$}"
local tmp code
tmp="$(mktemp)"
code="$(curl -k -sS --connect-timeout 5 --max-time 15 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")"
if printf "%s" "$code" | grep -Eq "$expected"; then
pass "HTTP $code $url ($name)"
else
fail "HTTP $code $url ($name)"
sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -20
fi
rm -f "$tmp" "$tmp.err"
}
check_http_json_key() {
local name="$1" url="$2" jq_filter="$3" remediation="$4"
local tmp
tmp="$(mktemp)"
if curl -k -fsS --connect-timeout 5 --max-time 20 "$url" -o "$tmp" 2>"$tmp.err" && jq -e "$jq_filter" "$tmp" >/dev/null 2>&1; then
pass "$name"
else
fail "$name"
sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -10
echo " REMEDIATION: $remediation"
fi
rm -f "$tmp" "$tmp.err"
}
check_bucket_freshness() {
local bucket="$1" label="$2" remediation="$3"
local bucket_id="${bucket}_${SOURCE_HOSTNAME}"
local tmp last_ts event_epoch now age_sec
tmp="$(mktemp)"
if ! curl -fsS --connect-timeout 5 --max-time 15 "$AW_SERVER/api/0/buckets/$bucket_id/events?limit=1" -o "$tmp" 2>"$tmp.err"; then
fail "bucket $label ($bucket_id) — запрос не удался"
sed 's/^/ /' "$tmp.err" | head -5
echo " REMEDIATION: $remediation"
rm -f "$tmp" "$tmp.err"
return
fi
last_ts="$(jq -r '.[0].timestamp // empty' "$tmp" 2>/dev/null)"
rm -f "$tmp"
if [ -z "$last_ts" ]; then
warn "bucket $label ($bucket_id) — нет событий"
echo " REMEDIATION: $remediation"
return
fi
event_epoch="$(date -d "$last_ts" +%s 2>/dev/null || echo 0)"
now="$(date -u +%s)"
age_sec=$((now - event_epoch))
case "$bucket" in
aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events)
if [ "$age_sec" -lt 86400 ]; then
pass "bucket $label${age_sec}s назад"
else
warn "bucket $label${age_sec}s назад (event-driven)"
fi ;;
aw-watcher-window|aw-dlp-endpoint-signals)
if [ "$age_sec" -lt 7200 ]; then
pass "bucket $label${age_sec}s назад"
else
warn "bucket $label${age_sec}s назад (INACTIVE)"
fi ;;
*)
if [ "$age_sec" -lt 3600 ]; then
pass "bucket $label${age_sec}s назад"
elif [ "$age_sec" -lt 86400 ]; then
warn "bucket $label${age_sec}s назад (STALE)"
echo " REMEDIATION: $remediation"
else
fail "bucket $label${age_sec}s назад (DEAD)"
echo " REMEDIATION: $remediation"
fi ;;
esac
}
check_ansible_shell() {
local name="$1" group="$2" command="$3"
if ! have ansible; then
skip "$name (ansible not available)"
return
fi
if [ ! -f "$INVENTORY" ]; then
skip "$name (inventory not found: $INVENTORY)"
return
fi
local tmp
tmp="$(mktemp)"
if ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m shell -a "$command" >"$tmp" 2>&1; then
pass "$name"
else
fail "$name"
sed 's/^/ /' "$tmp" | head -20
fi
rm -f "$tmp"
}
check_ansible_win_shell() {
local name="$1" command="$2"
if ! have ansible; then skip "$name (ansible not available)"; return; fi
if [ ! -f "$INVENTORY" ]; then skip "$name (inventory not found)"; return; fi
local tmp
tmp="$(mktemp)"
if ANSIBLE_NOCOLOR=1 ansible aw_windows -i "$INVENTORY" -m win_shell -a "$command" >"$tmp" 2>&1; then
pass "$name"
else
fail "$name"
sed 's/^/ /' "$tmp" | head -20
fi
rm -f "$tmp"
}
check_ansible_module() {
local name="$1" group="$2" module="$3" args="${4:-}"
if ! have ansible; then skip "$name (ansible not available)"; return; fi
if [ ! -f "$INVENTORY" ]; then skip "$name (inventory not found)"; return; fi
local tmp
tmp="$(mktemp)"
if ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m "$module" ${args:+-a "$args"} >"$tmp" 2>&1; then
pass "$name"
else
fail "$name"
sed 's/^/ /' "$tmp" | head -20
fi
rm -f "$tmp"
}
ssh_with_diag_password() {
local host="$1"
shift
if [[ -z "${AW_DIAG_SSH_PASSWORD:-}" ]]; then
return 125
fi
sshpass -p "$AW_DIAG_SSH_PASSWORD" ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no "igor@$host" "$@"
}
ssh_aw() { ssh_with_diag_password 10.10.10.13 "$@"; }
ssh_pve() { ssh_with_diag_password 10.10.10.2 "$@"; }
check_service_remote() {
local name="$1" host="$2" unit="$3" remediation="$4"
local result
result=$(ssh_with_diag_password "$host" "systemctl is-active $unit 2>/dev/null || echo not_found" 2>/dev/null)
local rc=$?
if [ "$rc" -eq 125 ]; then
skip "$name ($unit on $host — set AW_DIAG_SSH_PASSWORD for SSH checks)"
return
fi
if [ "$rc" -ne 0 ] || [ "$result" = "not_found" ]; then
skip "$name ($unit on $host — не удалось проверить)"
return
fi
if [ "$result" = "active" ]; then
pass "$name ($unit active on $host)"
else
fail "$name ($unit $result on $host)"
echo " REMEDIATION: $remediation"
fi
}
printf "%b=== ActivityWatch-Russian: Диагностика контура ===%b\n" "$CYAN" "$NC"
echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
# ============================================================
section "1. Локальные предусловия"
# ============================================================
for cmd in bash curl jq timeout ssh sshpass; do
if have "$cmd"; then pass "утилита $cmd найдена"; else fail "утилита $cmd не найдена (установите: apt install $cmd)"; fi
done
echo ""
# ============================================================
section "2. TCP доступность узлов"
# ============================================================
check_tcp "AW Server" "$AW_HOST" 5600
check_tcp "Worktime API" "$AW_HOST" 5610
check_tcp "RDP WinRM" "$WINDOWS_HOST" 5985
check_tcp "Proxmox SSH" "$PROXMOX_HOST" 22
check_tcp "Proxmox HTTPS" "$PROXMOX_HOST" 443
check_tcp "1C Company API" "$PROXMOX_HOST" 8710
check_tcp "ClickHouse HTTP" "$CLICKHOUSE_HOST" 8123
check_tcp "ClickHouse Native" "$CLICKHOUSE_HOST" 9000
check_tcp "InfluxDB" "$INFLUXDB_HOST" 8086
check_tcp "Grafana" "$GRAFANA_HOST" 3000
if [ "$QUICK_MODE" = "1" ]; then
# В быстром режиме проверяем только AW Server и buckets
echo ""
section "3. AW Server (быстрый режим)"
check_http_code "AW Server info" "$AW_SERVER/api/0/info" '^200$'
check_http_code "AW Server CORS" "$AW_SERVER/api/0/settings/" '^200$'
check_http_code "Worktime API health" "$AW_WORKTIME_API/health" '^200$'
section "4. Buckets (быстрый режим)"
for entry in \
"aw-watcher-afk|AFK watcher|Запустите на RDP: schtasks /Run /TN \"ActivityWatch Recovery\" или schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"" \
"aw-watcher-window|Window watcher|Запустите через ansible: ansible aw_windows -i $INVENTORY -m win_shell -a 'Start-Process -FilePath \"C:\\Program Files\\AWatch-rus\\bin\\aw-watcher-window\\aw-watcher-window.exe\" -ArgumentList @(\"--host\", \"10.10.10.13\", \"--port\", \"5600\") -WindowStyle Hidden'" \
"aw-worktime-sessions|Worktime sessions|Проверьте работу worktime-api: systemctl status aw-worktime-api на AW сервере" \
"aw-session-events|Session events|Проверьте collector-guard и aw-session-events-collector на RDP" \
"aw-dlp-endpoint-signals|DLP signals|Запустите: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"; Start-Sleep 30'" \
"aw-dlp-incidents|DLP incidents|Проверьте aw-detmir-dlp-collector.ps1 на RDP (логи: C:\\ProgramData\\AWatch-rus\\logs\\)" \
; do
bucket="${entry%%|*}"; rest="${entry#*|}"
label="${rest%%|*}"; remediation="${rest#*|}"
check_bucket_freshness "$bucket" "$label" "$remediation"
done
echo ""
echo "=== Быстрая диагностика завершена ==="
printf "OK=%s WARN=%s FAIL=%s SKIP=%s\n" "$OK_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$SKIP_COUNT"
[ "$FAIL_COUNT" -gt 0 ] && exit 2 || exit 0
fi
# ============================================================
section "3. AW Server (10.10.10.13)"
# ============================================================
check_http_json_key "AW Server info" "$AW_SERVER/api/0/info" \
'.version' \
"Проверьте: ssh igor@$AW_HOST 'systemctl status activitywatch-server'"
check_http_code "AW Server CORS" "$AW_SERVER/api/0/settings/" '^200$'
check_http_code "AW WebUI" "$AW_SERVER/" '^200$'
check_http_json_key "Worktime API health" "$AW_WORKTIME_API/health" \
'.status // .ok' \
"Проверьте: ssh igor@$AW_HOST 'systemctl status aw-worktime-api && journalctl -u aw-worktime-api -n 20'"
# ============================================================
section "4. Buckets (свежесть данных)"
# ============================================================
for entry in \
"aw-watcher-afk|AFK watcher|Запустите на RDP: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Recovery\"'" \
"aw-watcher-window|Window watcher|Запустите: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"; Start-Process -FilePath \"C:\\Program Files\\AWatch-rus\\bin\\aw-watcher-window\\aw-watcher-window.exe\" -ArgumentList @(\"--host\", \"10.10.10.13\", \"--port\", \"5600\") -WindowStyle Hidden'" \
"aw-worktime-sessions|Worktime sessions|Проверьте: ssh igor@$AW_HOST 'systemctl status aw-worktime-api && journalctl -u aw-worktime-api -n 20'" \
"aw-session-events|Session events|Проверьте collector-guard на RDP: ansible aw_windows -i $INVENTORY -m win_shell -a 'Get-Process -Name aw-session-events-* -ErrorAction SilentlyContinue'" \
"aw-dlp-endpoint-signals|DLP endpoint signals|Запустите: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"; Start-Sleep 60'" \
"aw-dlp-incidents|DLP incidents|Проверьте: ansible aw_windows -i $INVENTORY -m win_shell -a \"Get-Content 'C:\\ProgramData\\AWatch-rus\\logs\\dlp-*.log' -Tail 20\"" \
"aw-dlp-review|DLP review|Проверьте: ssh igor@$AW_HOST 'journalctl -u aw-dlp-policy-engine.service -n 20 --no-pager'" \
"aw-dlp-rules|DLP rules|Проверьте: ssh igor@$AW_HOST 'journalctl -u aw-dlp-ioc-refresh.service -n 20 --no-pager'" \
; do
bucket="${entry%%|*}"; rest="${entry#*|}"
label="${rest%%|*}"; remediation="${rest#*|}"
check_bucket_freshness "$bucket" "$label" "$remediation"
done
# ============================================================
section "5. InfluxDB (10.10.10.10:8086)"
# ============================================================
check_http_json_key "InfluxDB health" "$INFLUXDB_URL/health" \
'.status == "pass"' \
"Проверьте InfluxDB на LXC 200: ssh igor@$PROXMOX_HOST 'sudo pct exec 200 -- systemctl status influxdb'"
# ============================================================
section "6. Grafana (10.10.10.11:3000)"
# ============================================================
check_http_json_key "Grafana health" "$GRAFANA_URL/api/health" \
'.database == "ok"' \
"Проверьте: ssh igor@$PROXMOX_HOST 'sudo pct exec 201 -- systemctl status grafana-server'"
check_http_code "Grafana datasources API" "$GRAFANA_URL/api/datasources" '^200$|^302$|^401$'
# ============================================================
section "7. ClickHouse (10.10.10.2:8123)"
# ============================================================
# Проверяем через прямой HTTP — AUTHENTICATION_FAILED = сервер жив
local_ch_ok=0
ch_code=$(curl -sS --max-time 5 "http://$CLICKHOUSE_HOST:8123/?query=SELECT%201" 2>/dev/null | head -1)
if echo "$ch_code" | grep -q "AUTHENTICATION_FAILED"; then
pass "ClickHouse HTTP — отвечает (требуется аутентификация, это нормально)"
local_ch_ok=1
elif echo "$ch_code" | grep -q "1"; then
pass "ClickHouse HTTP — SELECT 1 OK"
local_ch_ok=1
else
fail "ClickHouse HTTP — не отвечает: $ch_code"
echo " REMEDIATION: ssh igor@$PROXMOX_HOST 'cd /opt/activitywatch/clickhouse-1c && sudo docker compose ps; sudo docker compose logs --tail=20'"
fi
# Проверка Docker контейнера через SSH
container_status=$(ssh_pve 'sudo docker ps --filter name=aw-rus-1c-clickhouse --format "{{.Status}}" 2>/dev/null' 2>/dev/null)
if [ -n "$container_status" ]; then
pass "ClickHouse Docker контейнер: $container_status"
else
fail "ClickHouse Docker контейнер не запущен"
echo " REMEDIATION: ssh igor@$PROXMOX_HOST 'cd /opt/activitywatch/clickhouse-1c && sudo docker compose up -d'"
fi
# ClickHouse health timer
check_service_remote "ClickHouse health timer" "$PROXMOX_HOST" "aw-1c-clickhouse-health.timer" \
"Проверьте: ssh igor@$PROXMOX_HOST 'sudo journalctl -u aw-1c-clickhouse-health.service -n 30 --no-pager'"
# ClickHouse network health timer (с AW сервера)
check_service_remote "ClickHouse network health timer" "$AW_HOST" "aw-clickhouse-network-health.timer" \
"Проверьте: ssh igor@$AW_HOST 'sudo journalctl -u aw-clickhouse-network-health.service -n 30 --no-pager'"
# 1C-ingest timer
check_service_remote "1C ingest timer" "$PROXMOX_HOST" "aw-1c-ingest.timer" \
"Проверьте: ssh igor@$PROXMOX_HOST 'sudo systemctl status aw-1c-ingest.timer; sudo journalctl -u aw-1c-ingest.service -n 20'"
# ============================================================
section "8. 1C Manager API (10.10.10.2:8710)"
# ============================================================
check_http_json_key "1C /api/health" "http://$PROXMOX_HOST:8710/api/health" \
'.status == "ok"' \
"Проверьте Python процесс: ssh igor@$PROXMOX_HOST 'ps aux | grep 8710 | grep -v grep'"
check_http_code "1C /manager/brief" "http://$PROXMOX_HOST:8710/manager/brief" '^200$'
# ============================================================
section "9. Nginx Gateway (10.10.10.2)"
# ============================================================
check_http_code "Gateway /healthz" "https://$PROXMOX_HOST/healthz" '^200$'
check_http_code "Gateway /go/proxmox-gui (401=protected, OK)" "https://$PROXMOX_HOST/go/proxmox-gui" '^30[1278]$|^401$'
check_http_code "Gateway /go/file1c-brief (401=protected, OK)" "https://$PROXMOX_HOST/go/file1c-brief" '^30[1278]$|^401$'
check_service_remote "Nginx service" "$PROXMOX_HOST" "nginx.service" \
"Проверьте: ssh igor@$PROXMOX_HOST 'sudo systemctl status nginx; sudo nginx -t'"
# ============================================================
section "10. DLP Pipeline (10.10.10.13)"
# ============================================================
# DLP Policy Engine — должен быть active (running)
check_service_remote "DLP Policy Engine" "$AW_HOST" "aw-dlp-policy-engine.service" \
"Проверьте: ssh igor@$AW_HOST 'sudo journalctl -u aw-dlp-policy-engine.service -n 30 --no-pager'"
# DLP Case Management
check_service_remote "DLP Case Management" "$AW_HOST" "aw-dlp-case-management.service" \
"Проверьте: ssh igor@$AW_HOST 'sudo journalctl -u aw-dlp-case-management.service -n 30 --no-pager'"
# DLP Aggregator
aggr_status=$(ssh_aw 'systemctl is-active activitywatch-dlp-aggregator.timer 2>/dev/null || echo not_found' 2>/dev/null)
if [ "$aggr_status" = "active" ]; then
pass "DLP Aggregator timer (active)"
else
fail "DLP Aggregator timer ($aggr_status)"
echo " REMEDIATION: ssh igor@$AW_HOST 'sudo systemctl enable --now activitywatch-dlp-aggregator.timer; sudo journalctl -u activitywatch-dlp-aggregator.service -n 30'"
fi
# DLP Influx Exporter
influx_exp_status=$(ssh_aw 'systemctl is-active aw-dlp-influx-exporter.timer 2>/dev/null || echo not_found' 2>/dev/null)
if [ "$influx_exp_status" = "active" ]; then
pass "DLP Influx Exporter timer (active)"
else
fail "DLP Influx Exporter timer ($influx_exp_status)"
echo " REMEDIATION: ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-influx-exporter.timer; sudo journalctl -u aw-dlp-influx-exporter.service -n 30'"
fi
# DLP CEF Exporter
check_service_remote "DLP CEF Exporter timer" "$AW_HOST" "aw-dlp-cef-exporter.timer" \
"ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-cef-exporter.timer; journalctl -u aw-dlp-cef-exporter.service -n 20'"
# DLP IOC Refresh
check_service_remote "DLP IOC Refresh timer" "$AW_HOST" "aw-dlp-ioc-refresh.timer" \
"ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-ioc-refresh.timer'"
# DLP Syslog Forwarder
check_service_remote "DLP Syslog Forwarder timer" "$AW_HOST" "aw-dlp-syslog-forwarder.timer" \
"ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-syslog-forwarder.timer'"
# DLP Webhook Sender
check_service_remote "DLP Webhook Sender timer" "$AW_HOST" "aw-dlp-webhook-sender.timer" \
"ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-webhook-sender.timer'"
# DLP Report Scheduler
check_service_remote "DLP Report Scheduler timer" "$AW_HOST" "aw-dlp-report-scheduler.timer" \
"ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-report-scheduler.timer'"
# Worktime Influx Exporter
check_service_remote "Worktime Influx Exporter timer" "$AW_HOST" "aw-worktime-influx-exporter.timer" \
"ssh igor@$AW_HOST 'sudo systemctl enable --now aw-worktime-influx-exporter.timer; journalctl -u aw-worktime-influx-exporter.service -n 20'"
# ============================================================
if [ "$SKIP_WINDOWS" = "1" ]; then
skip "Проверки RDP хоста пропущены (--skip-windows)"
else
section "11. RDP хост (192.168.100.18)"
if have ansible && [ -f "$INVENTORY" ]; then
check_ansible_module "WinRM ping" aw_windows win_ping
check_ansible_win_shell "Сессии RDP" 'query user 2>&1'
check_ansible_win_shell "Процессы watcher" \
'Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue | Select-Object Name,Id,SessionId,StartTime | Format-Table -AutoSize'
check_ansible_win_shell "Количество процессов powershell" \
'(Get-Process powershell -ErrorAction SilentlyContinue | Measure-Object).Count'
check_ansible_win_shell "Scheduled tasks (Recovery)" \
'schtasks /Query /TN "ActivityWatch Recovery" /FO LIST /V | Select-String "Status|Run|Next"'
check_ansible_win_shell "Scheduled tasks (Launch Admin)" \
'schtasks /Query /TN "ActivityWatch Launch [SHARKON2025_Администратор]" /FO LIST /V | Select-String "Status|Run|Next"'
else
skip "Ansible или inventory не найдены"
fi
fi
# ============================================================
section "12. Systemd health (AW server)"
# ============================================================
check_ansible_shell "AW server core units" aw_server \
'systemctl is-active activitywatch-server aw-worktime-api aw-dlp-policy-engine aw-dlp-case-management aw-worktime-influx-exporter.timer aw-dlp-influx-exporter.timer activitywatch-dlp-aggregator.timer aw-clickhouse-network-health.timer | paste -sd,'
check_ansible_shell "AW server — нет failed units" aw_server \
'failed=$(systemctl --failed --no-legend | awk "{print \$1}" | grep -E "activitywatch|aw-|dlp" || true); test -z "$failed" && echo "no AW-related failed units" || { echo "$failed"; exit 1; }'
# ============================================================
section "13. Systemd health (Proxmox)"
# ============================================================
check_ansible_shell "Proxmox core units" proxmox \
'systemctl is-active nginx docker aw-1c-clickhouse-health.timer aw-1c-ingest.timer 2>/dev/null | paste -sd,'
# ============================================================
section "14. Диск и память"
# ============================================================
check_ansible_shell "Диски AW server" aw_server 'df -h / /var /opt 2>/dev/null | tail -5'
check_ansible_shell "Память AW server" aw_server 'free -h | tail -5'
# ============================================================
section "Итог диагностики"
# ============================================================
printf " OK=%s WARN=%s FAIL=%s SKIP=%s\n" "$OK_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$SKIP_COUNT"
if [ "$FAIL_COUNT" -gt 0 ]; then
echo ""
echo " Есть проблемы! Смотрите REMEDIATION выше для каждого FAIL."
echo " После исправления запустите повторно: $0"
exit 2
elif [ "$WARN_COUNT" -gt 0 ]; then
echo ""
echo " Есть предупреждения (WARN) — стоит проверить, но не критично."
exit 1
else
echo ""
echo " Все проверки пройдены. Контур в рабочем состоянии."
exit 0
fi
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/check_production_inventory_placeholders.sh [--allow-missing] [--strict] FILE...
DETMIR_PRODUCTION_CONFIG_PATHS="file1:file2" scripts/check_production_inventory_placeholders.sh
scripts/check_production_inventory_placeholders.sh --self-test
Fails when production inventory/env files contain public placeholder values:
TEST-NET addresses, HOST-EXAMPLE, WINDOWS_USER_EXAMPLE, CHANGE_ME, YOUR_*,
replace-me, or angle-bracket placeholders.
Use --strict for private production override files. Strict mode requires at
least one existing path and rejects public/example/default files.
EOF
}
pattern='(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE|CHANGE_ME|CHANGEME|REPLACE_ME|replace-me|YOUR_[A-Z0-9_]*|<[A-Z0-9_ -]+>)'
allow_missing=0
strict=0
self_test=0
paths=()
while (($#)); do
case "$1" in
--allow-missing)
allow_missing=1
;;
--strict)
strict=1
;;
--self-test)
self_test=1
;;
-h|--help)
usage
exit 0
;;
*)
paths+=("$1")
;;
esac
shift
done
run_scan() {
local file
local found=0
for file in "$@"; do
if (( strict == 1 )) && [[ "$file" == ansible/group_vars* || "$file" == ansible/inventory.ini || "$file" == *".example."* || "$file" == *"example."* ]]; then
printf 'strict mode refuses public/default config path: %s\n' "$file" >&2
found=1
continue
fi
if [[ ! -e "$file" ]]; then
if (( allow_missing == 0 )); then
printf 'missing production config path: %s\n' "$file" >&2
found=1
fi
continue
fi
if [[ -d "$file" ]]; then
while IFS= read -r -d '' child; do
if grep -nE "$pattern" "$child" >/dev/null; then
printf 'placeholder found in %s\n' "$child" >&2
grep -nE "$pattern" "$child" | sed -E 's/^([0-9]+):.*/ line \1: placeholder marker/' >&2
found=1
fi
done < <(find "$file" -type f \( -name '*.env' -o -name '*.ini' -o -name '*.yml' -o -name '*.yaml' \) -print0)
elif grep -nE "$pattern" "$file" >/dev/null; then
printf 'placeholder found in %s\n' "$file" >&2
grep -nE "$pattern" "$file" | sed -E 's/^([0-9]+):.*/ line \1: placeholder marker/' >&2
found=1
fi
done
return "$found"
}
if (( self_test == 1 )); then
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
good="$tmp_dir/good.env"
bad="$tmp_dir/bad.env"
cat >"$good" <<'EOF'
AW_WORKTIME_INFLUX_URL=http://influxdb.internal:8086
AW_WORKTIME_INFLUX_HOSTS=WINDOWS-HOST
AW_WORKTIME_INFLUX_TOKEN=prod-write-token-value
EOF
cat >"$bad" <<'EOF'
AW_WORKTIME_INFLUX_URL=http://192.0.2.10:8086
AW_WORKTIME_INFLUX_HOSTS=HOST-EXAMPLE
AW_WORKTIME_INFLUX_TOKEN=CHANGE_ME
EOF
run_scan "$good"
if run_scan "$bad" >/dev/null 2>&1; then
echo "self-test failed: bad fixture was accepted" >&2
exit 1
fi
saved_strict="$strict"
strict=1
if run_scan "ansible/group_vars/all.yml" >/dev/null 2>&1; then
echo "self-test failed: strict mode accepted public/default file name" >&2
exit 1
fi
strict="$saved_strict"
echo "production inventory placeholder guard self-test: OK"
exit 0
fi
if ((${#paths[@]} == 0)) && [[ -n "${DETMIR_PRODUCTION_CONFIG_PATHS:-}" ]]; then
IFS=':' read -r -a paths <<<"$DETMIR_PRODUCTION_CONFIG_PATHS"
fi
if ((${#paths[@]} == 0)); then
if (( allow_missing == 1 && strict == 0 )); then
echo "production inventory placeholder guard: skipped (no production paths)"
exit 0
fi
usage >&2
exit 2
fi
run_scan "${paths[@]}"
echo "production inventory placeholder guard: OK"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/detmir-full-diagnostics.sh" --scope daily "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/detmir-full-diagnostics.sh" --scope monthly "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/detmir-full-diagnostics.sh" --scope weekly "$@"
@@ -0,0 +1,20 @@
## Базовые параметры для расширенной диагностики контуров DetMir
DETMIR_FULL_DIAGNOSTICS_SCOPE=daily
DETMIR_FULL_DIAGNOSTICS_OUTPUT_DIR=/var/log/detmir-full-diagnostics
# Опционально указывайте явные пути.
# Если переменная не задана, используются пути относительно этой папки:
# - aw-contour-diag.sh: ./aw-contour-diag.sh
# - detmir-support-run.sh: ../detmir-support-run.sh
# - detmir-support.env: ../detmir-support.env
DETMIR_FULL_DIAGNOSTICS_AW_DIAG_SCRIPT=
DETMIR_FULL_DIAGNOSTICS_AW_DIAG_QUICK=0
DETMIR_FULL_DIAGNOSTICS_AW_DIAG_SKIP_WINDOWS=0
DETMIR_FULL_DIAGNOSTICS_SUPPORT_RUN=
DETMIR_FULL_DIAGNOSTICS_SUPPORT_ENV=
# Проверка placeholder-значений в production-конфиге (по желанию)
DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_GUARD=1
DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_SCRIPT=
# Если путь пустой — проверка placeholder-guard будет пропущена.
DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_PATHS=
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${DETMIR_FULL_DIAGNOSTICS_ENV_FILE:-$SCRIPT_DIR/detmir-full-diagnostics.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
fi
usage() {
cat <<'USAGE'
Usage:
scripts/detmir-full-diagnostics/detmir-full-diagnostics.sh [options]
Options:
--scope daily|weekly|monthly Scope of operational checks (default: daily)
--output-dir PATH Root output directory for all checks
--quick Pass --quick to aw-contour-diag
--skip-windows Skip WinRM checks in aw-contour-diag
--no-support Skip detmir-support-run checks
--no-aw-diagnostic Skip aw-contour-diag
--no-placeholder-check Skip production placeholder guard check
--help Show this help
Examples:
scripts/detmir-full-diagnostics/detmir-full-diagnostics.sh --scope daily
scripts/detmir-full-diagnostics/detmir-full-diagnostics.sh --scope weekly --quick
scripts/detmir-full-diagnostics/detmir-full-diagnostics-daily.sh
USAGE
}
SCOPE="${DETMIR_FULL_DIAGNOSTICS_SCOPE:-daily}"
OUTPUT_DIR="${DETMIR_FULL_DIAGNOSTICS_OUTPUT_DIR:-$REPO_ROOT/output/detmir-full-diagnostics}"
RUN_AW_DIAG=1
RUN_SUPPORT=1
RUN_PLACEHOLDER_GUARD=1
AW_QUICK="${DETMIR_FULL_DIAGNOSTICS_AW_DIAG_QUICK:-0}"
AW_SKIP_WINDOWS="${DETMIR_FULL_DIAGNOSTICS_AW_DIAG_SKIP_WINDOWS:-0}"
AW_DIAG_SCRIPT="${DETMIR_FULL_DIAGNOSTICS_AW_DIAG_SCRIPT:-$SCRIPT_DIR/aw-contour-diag.sh}"
SUPPORT_RUN_SCRIPT="${DETMIR_FULL_DIAGNOSTICS_SUPPORT_RUN:-$SCRIPT_DIR/../detmir-support-run.sh}"
SUPPORT_ENV_FILE="${DETMIR_FULL_DIAGNOSTICS_SUPPORT_ENV:-$SCRIPT_DIR/../detmir-support.env}"
PLACEHOLDER_SCRIPT="${DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_SCRIPT:-$SCRIPT_DIR/../check_production_inventory_placeholders.sh}"
PLACEHOLDER_GUARD="${DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_GUARD:-1}"
PLACEHOLDER_PATHS="${DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_PATHS:-$REPO_ROOT/ansible:$REPO_ROOT/private-config}"
while (( $# > 0 )); do
case "$1" in
--scope)
if (( $# < 2 )); then
usage
echo "error: --scope requires daily|weekly|monthly" >&2
exit 2
fi
SCOPE="$2"
shift 2
;;
--output-dir)
if (( $# < 2 )); then
usage
echo "error: --output-dir requires path" >&2
exit 2
fi
OUTPUT_DIR="$2"
shift 2
;;
--quick)
AW_QUICK=1
shift
;;
--skip-windows)
AW_SKIP_WINDOWS=1
shift
;;
--no-support)
RUN_SUPPORT=0
shift
;;
--no-aw-diagnostic)
RUN_AW_DIAG=0
shift
;;
--no-placeholder-check)
RUN_PLACEHOLDER_GUARD=0
shift
;;
--help)
usage
exit 0
;;
*)
usage
echo "error: unknown arg '$1'" >&2
exit 2
;;
esac
done
if [[ "$SCOPE" != "daily" && "$SCOPE" != "weekly" && "$SCOPE" != "monthly" ]]; then
echo "error: invalid scope '$SCOPE', expected daily|weekly|monthly" >&2
exit 2
fi
if [[ "$RUN_PLACEHOLDER_GUARD" == "1" ]]; then
RUN_PLACEHOLDER_GUARD="$PLACEHOLDER_GUARD"
fi
RUN_DIR="$OUTPUT_DIR/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$RUN_DIR"
LOG_FILE="$RUN_DIR/full-diagnostics.log"
exec > >(tee -a "$LOG_FILE") 2>&1
TOTAL=0
OK_COUNT=0
FAIL_COUNT=0
log() {
printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"
}
run_step() {
local step="$1"
shift
TOTAL=$((TOTAL + 1))
log "----"
log "STEP [$TOTAL]: $step"
if "$@"; then
OK_COUNT=$((OK_COUNT + 1))
log "RESULT: OK"
return 0
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
log "RESULT: FAIL"
return 1
}
log "DetMir full diagnostics started"
log "Repository: $REPO_ROOT"
log "Scope: $SCOPE"
log "Output: $RUN_DIR"
log "Log: $LOG_FILE"
if [[ ! -x "$AW_DIAG_SCRIPT" ]] && (( RUN_AW_DIAG == 1 )); then
log "WARN: aw-contour-diag script not found or not executable: $AW_DIAG_SCRIPT"
log " Диагностика AW-контуром будет пропущена."
RUN_AW_DIAG=0
fi
if [[ ! -x "$SUPPORT_RUN_SCRIPT" ]] && (( RUN_SUPPORT == 1 )); then
log "ERROR: support script not found or not executable: $SUPPORT_RUN_SCRIPT"
exit 2
fi
if [[ ! -f "$SUPPORT_ENV_FILE" ]] && (( RUN_SUPPORT == 1 )); then
log "WARN: support env file not found, defaults will be used from detmir-support-run defaults: $SUPPORT_ENV_FILE"
fi
if [[ ! -f "$PLACEHOLDER_SCRIPT" ]] && (( RUN_PLACEHOLDER_GUARD == 1 )); then
log "WARN: placeholder guard script not found: $PLACEHOLDER_SCRIPT"
RUN_PLACEHOLDER_GUARD=0
fi
if (( RUN_AW_DIAG == 1 )); then
if [[ -x "$AW_DIAG_SCRIPT" ]]; then
AW_ARGS=()
if [[ "$AW_QUICK" == "1" ]]; then
AW_ARGS+=(--quick)
fi
if [[ "$AW_SKIP_WINDOWS" == "1" ]]; then
AW_ARGS+=(--skip-windows)
fi
run_step "aw-contour-diag (scope=$SCOPE)" "$AW_DIAG_SCRIPT" "${AW_ARGS[@]}" || true
fi
fi
if (( RUN_SUPPORT == 1 )); then
run_step "detmir-support-run --scope $SCOPE" \
env \
DETMIR_SUPPORT_ENV_FILE="$SUPPORT_ENV_FILE" \
DETMIR_SUPPORT_OUTPUT_DIR="$RUN_DIR/support" \
"$SUPPORT_RUN_SCRIPT" --scope "$SCOPE" --output-dir "$RUN_DIR/support" || true
fi
if [[ "$RUN_PLACEHOLDER_GUARD" == "1" ]]; then
if [[ -z "${PLACEHOLDER_PATHS// }" ]]; then
log "WARN: DETMIR_FULL_DIAGNOSTICS_PLACEHOLDER_PATHS is empty. Placeholder guard skipped."
else
run_step "check production placeholders" \
env DETMIR_PRODUCTION_CONFIG_PATHS="$PLACEHOLDER_PATHS" \
"$PLACEHOLDER_SCRIPT" --allow-missing || true
fi
fi
SUMMARY_FILE="$RUN_DIR/summary.txt"
{
printf "Scope: %s\n" "$SCOPE"
printf "Total steps: %s\n" "$TOTAL"
printf "OK: %s\n" "$OK_COUNT"
printf "FAIL: %s\n" "$FAIL_COUNT"
if (( FAIL_COUNT == 0 )); then
printf "Result: OK\n"
else
printf "Result: FAIL\n"
fi
} > "$SUMMARY_FILE"
log "Summary: $SUMMARY_FILE"
log "DetMir full diagnostics completed"
if (( FAIL_COUNT > 0 )); then
exit 1
fi
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/detmir-support-run.sh" --scope daily "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/detmir-support-run.sh" --scope monthly "$@"
+694
View File
@@ -0,0 +1,694 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DEFAULT_ENV_FILE="$SCRIPT_DIR/detmir-support.env"
ENV_FILE="${DETMIR_SUPPORT_ENV_FILE:-$DEFAULT_ENV_FILE}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
fi
usage() {
cat <<'USAGE'
Usage:
scripts/detmir-support-run.sh --scope daily|weekly|monthly [--output-dir PATH]
scripts/detmir-support-daily.sh
scripts/detmir-support-weekly.sh
scripts/detmir-support-monthly.sh
Arguments:
--scope daily|weekly|monthly (default: daily)
--output-dir directory for logs/reports (default: output/detmir-support)
--help show help
USAGE
}
SCOPE="daily"
OUTPUT_DIR="${DETMIR_SUPPORT_OUTPUT_DIR:-$REPO_ROOT/output/detmir-support}"
while (( $# > 0 )); do
case "$1" in
--scope)
if (( $# < 2 )); then
usage
echo "error: --scope requires daily|weekly|monthly" >&2
exit 2
fi
SCOPE="$2"
shift 2
;;
--output-dir)
if (( $# < 2 )); then
usage
echo "error: --output-dir requires directory path" >&2
exit 2
fi
OUTPUT_DIR="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
usage
echo "error: unknown argument '$1'" >&2
exit 2
;;
esac
done
if [[ "$SCOPE" != "daily" && "$SCOPE" != "weekly" && "$SCOPE" != "monthly" ]]; then
echo "error: invalid scope '$SCOPE', expected daily|weekly|monthly" >&2
exit 2
fi
DETMIR_SUPPORT_PVE_HOST="${DETMIR_SUPPORT_PVE_HOST:-10.10.10.2}"
DETMIR_SUPPORT_AW_HOST="${DETMIR_SUPPORT_AW_HOST:-10.10.10.13}"
DETMIR_SUPPORT_WEB_HOST="${DETMIR_SUPPORT_WEB_HOST:-10.10.10.11}"
DETMIR_SUPPORT_WINDOWS_HOST="${DETMIR_SUPPORT_WINDOWS_HOST:-192.168.100.18}"
DETMIR_SUPPORT_PFSENSE_HOST="${DETMIR_SUPPORT_PFSENSE_HOST:-}"
DETMIR_SUPPORT_PFSENSE_URL="${DETMIR_SUPPORT_PFSENSE_URL:-}"
DETMIR_SUPPORT_OPENVPN_HOST="${DETMIR_SUPPORT_OPENVPN_HOST:-}"
DETMIR_SUPPORT_OPENVPN_WEB_URL="${DETMIR_SUPPORT_OPENVPN_WEB_URL:-}"
DETMIR_SUPPORT_SURICATA_HOST="${DETMIR_SUPPORT_SURICATA_HOST:-$DETMIR_SUPPORT_PVE_HOST}"
DETMIR_SUPPORT_WEB_TLS_HOST="${DETMIR_SUPPORT_WEB_TLS_HOST:-$DETMIR_SUPPORT_WEB_HOST}"
DETMIR_SUPPORT_SSH_USER="${DETMIR_SUPPORT_SSH_USER:-root}"
DETMIR_SUPPORT_SSH_IDENTITY="${DETMIR_SUPPORT_SSH_IDENTITY:-}"
DETMIR_SUPPORT_SKIP_REMOTE="${DETMIR_SUPPORT_SKIP_REMOTE:-0}"
DETMIR_SUPPORT_CONNECT_TIMEOUT="${DETMIR_SUPPORT_CONNECT_TIMEOUT:-8}"
DETMIR_SUPPORT_PVE_SERVICES="${DETMIR_SUPPORT_PVE_SERVICES:-pve-cluster pvedaemon pvestatd pveproxy pvedaemon.service pvestatd.service}"
DETMIR_SUPPORT_AW_SERVICES="${DETMIR_SUPPORT_AW_SERVICES:-activitywatch-server aw-server-rust}"
DETMIR_SUPPORT_WEB_SERVICES="${DETMIR_SUPPORT_WEB_SERVICES:-nginx apache2}"
DETMIR_SUPPORT_SURICATA_SERVICES="${DETMIR_SUPPORT_SURICATA_SERVICES:-suricata}"
DETMIR_SUPPORT_PVE_VM_IDS="${DETMIR_SUPPORT_PVE_VM_IDS:-}"
DETMIR_SUPPORT_PVE_BACKUP_DIRS="${DETMIR_SUPPORT_PVE_BACKUP_DIRS:-/var/lib/vz/dump}"
DETMIR_SUPPORT_BACKUP_MAX_AGE_DAYS="${DETMIR_SUPPORT_BACKUP_MAX_AGE_DAYS:-14}"
DETMIR_SUPPORT_DISK_WARN_PERCENT="${DETMIR_SUPPORT_DISK_WARN_PERCENT:-85}"
DETMIR_SUPPORT_DISK_CRIT_PERCENT="${DETMIR_SUPPORT_DISK_CRIT_PERCENT:-93}"
DETMIR_SUPPORT_LOG_WARNING_LIMIT="${DETMIR_SUPPORT_LOG_WARNING_LIMIT:-5}"
DETMIR_SUPPORT_LOG_CRIT_LIMIT="${DETMIR_SUPPORT_LOG_CRIT_LIMIT:-20}"
DETMIR_SUPPORT_TLS_WARN_DAYS="${DETMIR_SUPPORT_TLS_WARN_DAYS:-30}"
DETMIR_SUPPORT_TLS_CRIT_DAYS="${DETMIR_SUPPORT_TLS_CRIT_DAYS:-14}"
DETMIR_SUPPORT_HTTP_WARNING_LATENCY_MS="${DETMIR_SUPPORT_HTTP_WARNING_LATENCY_MS:-2000}"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_DIR="$OUTPUT_DIR/$(date -u +%F)"
mkdir -p "$RUN_DIR"
LOG_FILE="$RUN_DIR/support-$SCOPE-$RUN_ID.log"
CSV_FILE="$RUN_DIR/support-$SCOPE-$RUN_ID.csv"
MD_FILE="$RUN_DIR/support-$SCOPE-$RUN_ID.md"
TOTAL=0
OK_COUNT=0
WARN_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
REMOTE_OUTPUT=""
printf '%s\n' "timestamp;scope;section;status;check;result;action" > "$CSV_FILE"
log() {
printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*" | tee -a "$LOG_FILE"
}
sanitize() {
local value="$1"
value="${value//$'\n'/ }"
value="${value//$'\r'/ }"
value="${value//;/ }"
printf '%s' "$value"
}
record() {
local section="$1"
local status="$2"
local check_name="$3"
local result="$4"
local action="${5:-}"
section="$(sanitize "$section")"
check_name="$(sanitize "$check_name")"
status="$(sanitize "$status")"
result="$(sanitize "$result")"
action="$(sanitize "$action")"
TOTAL=$((TOTAL + 1))
case "$status" in
OK) OK_COUNT=$((OK_COUNT + 1)) ;;
WARN) WARN_COUNT=$((WARN_COUNT + 1)) ;;
FAIL) FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
SKIP) SKIP_COUNT=$((SKIP_COUNT + 1)) ;;
esac
printf '%s;%s;%s;%s;%s;%s;%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$SCOPE" "$section" "$status" "$check_name" "$result" "$action" >> "$CSV_FILE"
case "$status" in
OK) log "[OK] ${section} :: ${check_name} -- ${result}" ;;
WARN) log "[WARN] ${section} :: ${check_name} -- ${result}" ;;
FAIL) log "[FAIL] ${section} :: ${check_name} -- ${result}" ;;
SKIP) log "[SKIP] ${section} :: ${check_name} -- ${result}" ;;
esac
if [[ -n "$action" ]]; then
log " action: $action"
fi
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
record "Подготовка" FAIL "$1" "not found" "Install command and re-run"
return 1
fi
}
run_remote() {
local host="$1"
shift
local remote_cmd="$*"
if [[ "$DETMIR_SUPPORT_SKIP_REMOTE" == "1" ]]; then
return 125
fi
local -a ssh_opts=(
-o BatchMode=yes
-o ConnectTimeout="$DETMIR_SUPPORT_CONNECT_TIMEOUT"
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
)
if [[ -n "$DETMIR_SUPPORT_SSH_IDENTITY" ]]; then
ssh_opts=("-i" "$DETMIR_SUPPORT_SSH_IDENTITY" "${ssh_opts[@]}")
fi
if REMOTE_OUTPUT="$(printf '%s\n' "$remote_cmd" | ssh "${ssh_opts[@]}" "${DETMIR_SUPPORT_SSH_USER}@${host}" bash -s 2>&1)"; then
return 0
fi
return "$?"
}
check_tcp() {
local section="$1" host="$2" port="$3" name="$4"
if timeout 8 bash -c ": >/dev/tcp/$host/$port" >/dev/null 2>&1; then
record "$section" OK "$name" "${host}:${port} reachable"
else
record "$section" FAIL "$name" "${host}:${port} unreachable"
fi
}
check_http() {
local section="$1" url="$2" expected_code="$3" name="$4"
local tmp_file code latency_ms
tmp_file="$(mktemp)"
code="$(curl -ksS --connect-timeout 5 --max-time 20 -o "$tmp_file" -w '%{http_code};%{time_total}' "$url" 2>/dev/null || true)"
latency_ms="${code#*;}"
code="${code%;*}"
latency_ms="$(awk "BEGIN { printf \"%.0f\", ${latency_ms:-0} * 1000 }")"
if [[ -z "$code" ]]; then
record "$section" WARN "$name" "No HTTP response" "Check service, DNS and auth"
rm -f "$tmp_file"
return
fi
if echo "$code" | grep -Eq "$expected_code"; then
if (( latency_ms > DETMIR_SUPPORT_HTTP_WARNING_LATENCY_MS )); then
record "$section" WARN "$name" "$url -> HTTP ${code}, latency ${latency_ms}ms" "Check service latency/perf"
else
record "$section" OK "$name" "$url -> HTTP ${code}, latency ${latency_ms}ms"
fi
else
record "$section" WARN "$name" "$url -> HTTP ${code}" "Verify endpoint auth/cert/rewrite"
fi
rm -f "$tmp_file"
}
check_tls() {
local section="$1" host="$2" port="$3" name="$4"
if ! command -v openssl >/dev/null 2>&1; then
record "$section" SKIP "$name" "openssl unavailable" "Install openssl to validate cert"
return
fi
local end_line now_ts end_ts days_left
end_line="$(timeout 8 sh -c "openssl s_client -connect '$host:$port' -servername '$host' </dev/null 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | sed -n 's/^notAfter=//p' || true")"
if [[ -z "$end_line" ]]; then
record "$section" WARN "$name" "Unable to read certificate" "Check TLS termination"
return
fi
now_ts="$(date -u +%s)"
end_ts="$(date -d "$end_line" +%s 2>/dev/null || true)"
if [[ -z "$end_ts" ]]; then
record "$section" WARN "$name" "Certificate expiry parse failed" "Update openssl/timezone settings"
return
fi
days_left=$(( (end_ts - now_ts) / 86400 ))
if (( days_left < 0 )); then
record "$section" FAIL "$name" "Certificate already expired (${days_left#-} days ago)" "Renew certificate immediately"
elif (( days_left <= DETMIR_SUPPORT_TLS_CRIT_DAYS )); then
record "$section" FAIL "$name" "Expires in ${days_left} days" "Renew certificate before expiry"
elif (( days_left <= DETMIR_SUPPORT_TLS_WARN_DAYS )); then
record "$section" WARN "$name" "Expires in ${days_left} days" "Plan renewal window"
else
record "$section" OK "$name" "Expires in ${days_left} days" ""
fi
}
check_remote_service_status() {
local section="$1" host="$2" services="$3"
if [[ -z "$services" ]]; then
return
fi
local service
for service in $services; do
if run_remote "$host" "systemctl is-active --quiet \"$service\""; then
record "$section" OK "$service" "$host active" ""
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "$service" "SSH skipped" "Provide SSH access or run locally"
else
record "$section" WARN "$service" "$host inactive or unit missing" "Check unit status"
fi
fi
done
}
check_node_load() {
local section="$1" host="$2"
if run_remote "$host" "awk '{print \$1 \" \" \$2 \" \" \$3}' /proc/loadavg"; then
local load_line="$REMOTE_OUTPUT"
if [[ -z "$load_line" ]]; then
record "$section" SKIP "Node load" "Empty loadavg output" "Check /proc on remote"
else
local avg1 avg5 avg15
avg1="${load_line%% *}"
avg5="$(echo "$load_line" | awk '{print $2}')"
avg15="$(echo "$load_line" | awk '{print $3}')"
record "$section" OK "Node load" "1m=$avg1 5m=$avg5 15m=$avg15" ""
fi
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "Node load" "SSH skipped" "Provide SSH access or run locally"
else
record "$section" WARN "Node load" "Cannot read /proc/loadavg" "Check remote shell access"
fi
fi
}
check_disk() {
local section="$1" host="$2" mount="$3"
if run_remote "$host" "df -P '$mount' | awk 'NR==2 {print \$5}'"; then
local used=0
local raw_total
raw_total="$(echo "$REMOTE_OUTPUT" | tr '\\t' ' ')"
used="${raw_total%%\%*}"
if ! [[ "$used" =~ ^[0-9]+$ ]]; then
record "$section" SKIP "disk $mount" "Unable to parse df output ($raw_total)" "Check mount and fs output"
return
fi
if (( used >= DETMIR_SUPPORT_DISK_CRIT_PERCENT )); then
record "$section" FAIL "disk $mount" "Used ${used}% on ${host}:${mount}" "Free space now"
elif (( used >= DETMIR_SUPPORT_DISK_WARN_PERCENT )); then
record "$section" WARN "disk $mount" "Used ${used}% on ${host}:${mount}" "Plan cleanup/rebalance"
else
record "$section" OK "disk $mount" "Used ${used}% on ${host}:${mount}" ""
fi
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "disk $mount" "SSH skipped" "Provide SSH access"
else
record "$section" FAIL "disk $mount" "df failed" "Check permissions and mount"
fi
fi
}
check_backups() {
local section="$1" host="$2" path="$3"
local remote_cmd
# shellcheck disable=SC2016
remote_cmd=$(cat <<EOF
if [ ! -d '$path' ]; then
echo NO_PATH
exit 0
fi
find '$path' -type f \( -name 'vzdump-*' -o -name '*.vma*' -o -name '*.tar*' -o -name '*.zip' -o -name '*.bak' \) -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -1 | awk '{print \$1}'
EOF
)
if run_remote "$host" "$remote_cmd"; then
if [[ "$REMOTE_OUTPUT" == "NO_PATH" ]]; then
record "$section" SKIP "backup" "Path not found: ${path}" "Fix backup path in env"
return
fi
if [[ -z "$REMOTE_OUTPUT" ]]; then
record "$section" WARN "backup" "No backup files found in ${path}" "Check backup schedule"
return
fi
local epoch_now age_days
epoch_now="$(date -u +%s)"
age_days=$(( (epoch_now - ${REMOTE_OUTPUT%.*}) / 86400 ))
if (( age_days > DETMIR_SUPPORT_BACKUP_MAX_AGE_DAYS )); then
record "$section" WARN "backup" "Last backup ${age_days} day(s) ago" "Check scheduler and retention"
else
record "$section" OK "backup" "Last backup ${age_days} day(s) ago" ""
fi
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "backup" "SSH skipped" "Provide SSH access"
else
record "$section" FAIL "backup" "Check failed" "Inspect permissions/path"
fi
fi
}
check_vms() {
local section="$1" host="$2" vm_ids="$3"
if [[ -z "$vm_ids" ]]; then
return
fi
if run_remote "$host" "if command -v qm >/dev/null 2>&1; then qm list; else echo NO_QM; fi"; then
if [[ "$REMOTE_OUTPUT" == "NO_QM" ]]; then
record "$section" SKIP "VM/CT state" "qm command not found" "Run checks only on Proxmox host"
return
fi
local vm_id state
for vm_id in $vm_ids; do
state="$(echo "$REMOTE_OUTPUT" | awk -v id="$vm_id" '$1==id {print $3}')"
if [[ -z "$state" ]]; then
record "$section" WARN "VM/CT $vm_id" "Not found in qm list" "Verify ID list"
elif [[ "$state" == "running" ]]; then
record "$section" OK "VM/CT $vm_id" "running" ""
else
record "$section" WARN "VM/CT $vm_id" "state=$state" "Inspect console before maintenance"
fi
done
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "VM/CT state" "SSH skipped" "Provide SSH access"
else
record "$section" FAIL "VM/CT state" "Cannot run qm list" "Check Proxmox shell"
fi
fi
}
check_task_log() {
local section="$1" host="$2"
if run_remote "$host" "journalctl -p 3 --no-pager -n 50 2>/dev/null | grep -Eci '(error|fail|critical|timeout)'"; then
local errors="${REMOTE_OUTPUT//[$'\n']/ }"
if ! [[ "$errors" =~ ^[0-9]+$ ]]; then
record "$section" SKIP "task log" "Cannot parse journal count" "Check journald"
return
fi
if (( errors > 10 )); then
record "$section" WARN "task log" "Found ${errors} critical/error lines" "Review pve/task.log scope"
else
record "$section" OK "task log" "Critical/error lines: ${errors}" ""
fi
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "task log" "SSH skipped" "Provide SSH access"
else
record "$section" WARN "task log" "Cannot read task log" "Check journald config"
fi
fi
}
check_log_growth() {
local section="$1" host="$2"
local dir total dir_count
total=0
for dir in /var/log /var/log/pve /var/log/nginx; do
if run_remote "$host" "[ -d '$dir' ] && find '$dir' -type f -size +100M 2>/dev/null | wc -l"; then
dir_count="${REMOTE_OUTPUT//[$'\n']/ }"
dir_count="${dir_count// /}"
if ! [[ "$dir_count" =~ ^[0-9]+$ ]]; then
dir_count=0
fi
total=$((total + dir_count))
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "log growth" "SSH skipped" "Provide SSH access"
else
record "$section" FAIL "log growth" "scan failed" "Check permissions"
fi
return
fi
done
local large_files="${total}"
if (( large_files >= DETMIR_SUPPORT_LOG_CRIT_LIMIT )); then
record "$section" WARN "log growth" "${large_files} files >100M" "Run cleanup/logrotate"
elif (( large_files >= DETMIR_SUPPORT_LOG_WARNING_LIMIT )); then
record "$section" WARN "log growth" "${large_files} files >100M" "Monitor growth"
else
record "$section" OK "log growth" "${large_files} files >100M" ""
fi
}
check_pfsense() {
local section="$1"
if [[ -n "$DETMIR_SUPPORT_PFSENSE_URL" ]]; then
check_http "$section" "$DETMIR_SUPPORT_PFSENSE_URL" '^2[0-9][0-9]$' "pfSense Web"
fi
if [[ -n "$DETMIR_SUPPORT_PFSENSE_HOST" ]]; then
check_tcp "$section" "$DETMIR_SUPPORT_PFSENSE_HOST" 443 "pfSense HTTPS"
fi
}
check_openvpn() {
local section="$1"
if [[ -n "$DETMIR_SUPPORT_OPENVPN_WEB_URL" ]]; then
check_http "$section" "$DETMIR_SUPPORT_OPENVPN_WEB_URL" '^2[0-9][0-9]$' "OpenVPN Web"
fi
if [[ -n "$DETMIR_SUPPORT_OPENVPN_HOST" ]]; then
check_tcp "$section" "$DETMIR_SUPPORT_OPENVPN_HOST" 1194 "OpenVPN UDP/TCP"
fi
}
check_suricata_service() {
local section="$1" host="$2"
check_remote_service_status "$section" "$host" "$DETMIR_SUPPORT_SURICATA_SERVICES"
if run_remote "$host" "command -v suricata >/dev/null 2>&1 && pgrep -af suricata >/dev/null"; then
record "$section" OK "Suricata process" "running"
else
local rc=$?
if (( rc == 125 )); then
record "$section" SKIP "Suricata process" "SSH skipped" "Provide SSH access"
else
record "$section" WARN "Suricata process" "No running process or suricata missing" "Verify IDS/IPS host"
fi
fi
}
run_daily() {
check_tcp "Доступность" "$DETMIR_SUPPORT_PVE_HOST" 22 "Proxmox SSH"
check_tcp "Доступность" "$DETMIR_SUPPORT_PVE_HOST" 8006 "Proxmox WEB"
check_tcp "Доступность" "$DETMIR_SUPPORT_AW_HOST" 5600 "AW API"
check_tcp "Доступность" "$DETMIR_SUPPORT_WEB_HOST" 80 "Web HTTP"
check_tcp "Доступность" "$DETMIR_SUPPORT_WEB_HOST" 443 "Web HTTPS"
[[ -n "$DETMIR_SUPPORT_WINDOWS_HOST" ]] && check_tcp "Удаленный доступ" "$DETMIR_SUPPORT_WINDOWS_HOST" 3389 "Windows RDP"
check_http "Ключевые сервисы" "https://$DETMIR_SUPPORT_AW_HOST:5600" '^2[0-9][0-9]$' "AW API health"
check_http "Web" "https://$DETMIR_SUPPORT_WEB_TLS_HOST/" '^2[0-9][0-9]$' "Public HTTPS"
check_tls "TLS" "$DETMIR_SUPPORT_WEB_TLS_HOST" 443 "Web certificate"
check_pfsense "Сеть"
check_openvpn "Сеть"
check_remote_service_status "Proxmox" "$DETMIR_SUPPORT_PVE_HOST" "$DETMIR_SUPPORT_PVE_SERVICES"
check_remote_service_status "AW" "$DETMIR_SUPPORT_AW_HOST" "$DETMIR_SUPPORT_AW_SERVICES"
check_remote_service_status "Web" "$DETMIR_SUPPORT_WEB_HOST" "$DETMIR_SUPPORT_WEB_SERVICES"
check_node_load "Виртуализация" "$DETMIR_SUPPORT_PVE_HOST"
check_disk "Диски" "$DETMIR_SUPPORT_PVE_HOST" "/"
check_disk "Диски" "$DETMIR_SUPPORT_AW_HOST" "/"
check_disk "Диски" "$DETMIR_SUPPORT_WEB_HOST" "/"
check_backups "Резервные копии" "$DETMIR_SUPPORT_PVE_HOST" "$DETMIR_SUPPORT_PVE_BACKUP_DIRS"
check_vms "Виртуализация" "$DETMIR_SUPPORT_PVE_HOST" "$DETMIR_SUPPORT_PVE_VM_IDS"
check_task_log "Системные журналы" "$DETMIR_SUPPORT_PVE_HOST"
check_suricata_service "Периметр" "$DETMIR_SUPPORT_SURICATA_HOST"
}
run_weekly() {
run_daily
check_log_growth "Логи" "$DETMIR_SUPPORT_PVE_HOST"
check_log_growth "Логи" "$DETMIR_SUPPORT_AW_HOST"
check_log_growth "Логи" "$DETMIR_SUPPORT_WEB_HOST"
if run_remote "$DETMIR_SUPPORT_AW_HOST" "journalctl -p 3 --no-pager -n 40"; then
local lines
lines="$(printf '%s\n' "$REMOTE_OUTPUT" | wc -l)"
if (( lines > 20 )); then
record "Логи" "journalctl critical" WARN "Found ${lines} critical lines in recent entries" "Review and file ticket if recurring"
else
record "Логи" "journalctl critical" OK "Critical entries in normal range" ""
fi
else
local rc=$?
if (( rc == 125 )); then
record "Логи" "journalctl critical" SKIP "SSH skipped" "Provide SSH access"
else
record "Логи" "journalctl critical" WARN "Unable to read journal" "Inspect journald"
fi
fi
}
run_monthly() {
run_weekly
local update_cmd
# shellcheck disable=SC2016
update_cmd=$(
cat <<'EOF'
if command -v apt >/dev/null 2>&1; then
apt list --upgradable 2>/dev/null | tail -n +2 | wc -l
elif command -v yum >/dev/null 2>&1; then
yum check-update -q >/tmp/yumcheck 2>&1
rc=$?
if [ "$rc" -eq 100 ]; then
wc -l < /tmp/yumcheck
elif [ "$rc" -eq 0 ]; then
echo 0
else
echo FAILED:$rc
fi
else
echo 0
fi
EOF
)
if run_remote "$DETMIR_SUPPORT_AW_HOST" "$update_cmd"; then
if [[ "$REMOTE_OUTPUT" =~ ^[0-9]+$ ]]; then
if (( REMOTE_OUTPUT > 15 )); then
record "Обновления" "Update count" "${REMOTE_OUTPUT} updates available" "Согласуйте окно обслуживания"
else
record "Обновления" "Update count" "${REMOTE_OUTPUT} updates available" ""
fi
else
record "Обновления" "Update check" "Unable parse update count" "Inspect package manager"
fi
else
local rc=$?
if (( rc == 125 )); then
record "Обновления" "Update check" "SSH skipped" "Provide SSH access"
else
record "Обновления" "Update check" "Failed" "Inspect package manager/permissions"
fi
fi
local pve_version
if run_remote "$DETMIR_SUPPORT_PVE_HOST" "pveversion -v | head -n 1"; then
pve_version="$REMOTE_OUTPUT"
record "Контур" "Proxmox version" "${pve_version}" ""
else
local rc=$?
if (( rc == 125 )); then
record "Контур" "Proxmox version" "SSH skipped" "Provide SSH access"
else
record "Контур" "Proxmox version" "Unable to read" "Check pve command"
fi
fi
record "Документация" OK "Access matrix" "Owner-visible matrix and contacts are required to be current" "Create/update support ownership map and include in evidence"
record "DR" OK "Recovery drill" "Plan restore drill in maintenance window" "Schedule controlled restore drill on non-production clone"
}
generate_report() {
local exit_code="$1"
local status_text="OK"
if (( exit_code != 0 )); then
status_text="ATTENTION"
fi
{
echo "# Отчет по выполнению задач поддержки DetMir"
echo
echo "- **Дата:** $(date -u '+%Y-%m-%d %H:%M:%SZ')"
echo "- **Режим:** $SCOPE"
echo "- **Run-ID:** $RUN_ID"
echo "- **Лог:** $LOG_FILE"
echo "- **Итоговый статус:** $status_text"
echo
echo "## Итого"
echo
echo "- OK: $OK_COUNT"
echo "- WARN: $WARN_COUNT"
echo "- FAIL: $FAIL_COUNT"
echo "- SKIP: $SKIP_COUNT"
echo "- TOTAL: $TOTAL"
echo
echo "## Детали"
echo
echo "|Раздел|Проверка|Статус|Результат|Действие|"
echo "|---|---|---|---|---|"
tail -n +2 "$CSV_FILE" | while IFS=';' read -r _ _ section status check result action; do
section="${section//|/\\|}"
check="${check//|/\\|}"
status="${status//|/\\|}"
result="${result//|/\\|}"
action="${action//|/\\|}"
echo "|$section|$check|$status|$result|$action|"
done
echo
} > "$MD_FILE"
cat <<EOF_SUMMARY
==> Отчет сохранен:
- CSV: $CSV_FILE
- Markdown: $MD_FILE
- Log: $LOG_FILE
EOF_SUMMARY
}
main() {
require_command bash || exit 1
require_command awk || exit 1
require_command date || exit 1
require_command timeout || exit 1
require_command curl || exit 1
log "Start DetMir support run, scope=$SCOPE"
log "Run directory: $RUN_DIR"
case "$SCOPE" in
daily) run_daily ;;
weekly) run_weekly ;;
monthly) run_monthly ;;
esac
local exit_code=0
if (( FAIL_COUNT > 0 )); then
exit_code=2
elif (( WARN_COUNT > 0 )); then
exit_code=1
fi
generate_report "$exit_code"
log "Finish DetMir support run, status=$exit_code"
return "$exit_code"
}
main "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$SCRIPT_DIR/detmir-support-run.sh" --scope weekly "$@"
+42
View File
@@ -0,0 +1,42 @@
## Базовые узлы
DETMIR_SUPPORT_PVE_HOST=10.10.10.2
DETMIR_SUPPORT_AW_HOST=10.10.10.13
DETMIR_SUPPORT_WEB_HOST=10.10.10.11
DETMIR_SUPPORT_WINDOWS_HOST=192.168.100.18
## Сетевые компоненты
DETMIR_SUPPORT_PFSENSE_HOST=10.0.0.1
DETMIR_SUPPORT_PFSENSE_URL=https://10.0.0.1
DETMIR_SUPPORT_OPENVPN_HOST=10.0.0.1
DETMIR_SUPPORT_OPENVPN_WEB_URL=https://10.0.0.1:943
DETMIR_SUPPORT_SURICATA_HOST=${DETMIR_SUPPORT_PVE_HOST}
DETMIR_SUPPORT_WEB_TLS_HOST=${DETMIR_SUPPORT_WEB_HOST}
## SSH/доступ
DETMIR_SUPPORT_SSH_USER=root
#DETMIR_SUPPORT_SSH_IDENTITY=/path/to/key
DETMIR_SUPPORT_SKIP_REMOTE=0
DETMIR_SUPPORT_CONNECT_TIMEOUT=8
## Сервисы и параметры
DETMIR_SUPPORT_PVE_SERVICES=pve-cluster pvedaemon pvestatd pveproxy
DETMIR_SUPPORT_AW_SERVICES=activitywatch-server aw-server-rust
DETMIR_SUPPORT_WEB_SERVICES=nginx apache2
DETMIR_SUPPORT_SURICATA_SERVICES=suricata
## VM и резервные копии
DETMIR_SUPPORT_PVE_VM_IDS="101 102 103"
DETMIR_SUPPORT_PVE_BACKUP_DIRS=/var/lib/vz/dump
DETMIR_SUPPORT_BACKUP_MAX_AGE_DAYS=14
## Пороги
DETMIR_SUPPORT_DISK_WARN_PERCENT=85
DETMIR_SUPPORT_DISK_CRIT_PERCENT=93
DETMIR_SUPPORT_LOG_WARNING_LIMIT=5
DETMIR_SUPPORT_LOG_CRIT_LIMIT=20
DETMIR_SUPPORT_TLS_WARN_DAYS=30
DETMIR_SUPPORT_TLS_CRIT_DAYS=14
DETMIR_SUPPORT_HTTP_WARNING_LATENCY_MS=2000
## Вывод
DETMIR_SUPPORT_OUTPUT_DIR=/var/log/detmir-support
+38 -29
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
@@ -175,27 +176,37 @@ function checkRequiredContent() {
return findings;
}
function checkSensitiveStrings(files) {
const patterns = [
{ name: "private_network_10", re: /10\.10\.10\./ },
{ name: "private_network_192", re: /192\.168\./ },
{ name: "private_network_172", re: /172\.(1[6-9]|2\d|3[0-1])\./ },
{ name: "private_operator_domain", re: /dm\.iri/i },
{ name: "customer_codename", re: new RegExp(`${["Det", "Mir"].join("")}|${["SHARKON", "2025"].join("")}`, "i") },
{ name: "local_operator_path", re: /\/home\/igor/i },
{ name: "mts_phishing_context", re: /\b(lk|l)\.mts\.ru/i },
];
const findings = [];
for (const file of files) {
if (!file.endsWith(".md") && !file.endsWith(".json")) continue;
const text = readText(file);
for (const pattern of patterns) {
if (pattern.re.test(text)) {
findings.push({ file, pattern: pattern.name });
}
}
function checkDemoSafety() {
const result = spawnSync("bash", ["scripts/check_demo_safety.sh", "--json"], {
cwd: root,
encoding: "utf8",
});
if (result.error) {
return {
ok: false,
error: result.error.message,
findings: [],
};
}
return findings;
let parsed = null;
try {
parsed = JSON.parse(result.stdout || "{}");
} catch (error) {
return {
ok: false,
error: `invalid_json: ${error.message}`,
stdout: result.stdout,
stderr: result.stderr,
findings: [],
};
}
return {
ok: result.status === 0 && parsed.ok === true,
status: result.status,
stderr: result.stderr,
findings: parsed.findings || [],
scope_files: parsed.scope_files || [],
};
}
function pass(checks, name, ok, details = {}) {
@@ -213,12 +224,6 @@ function main() {
...roadmapDocs,
...reportsAndRunbooks,
];
const sensitiveScanFiles = [
...validationDocs,
"docs/fixtures/pilot-v1-demo/README_RU.md",
"docs/fixtures/pilot-v1-demo/demo-seed-data.json",
];
pass(checks, "validation_docs_exist", checkFiles(validationDocs).length === 0, {
missing: checkFiles(validationDocs),
});
@@ -253,9 +258,13 @@ function main() {
findings: linkFindings,
});
const sensitiveFindings = checkSensitiveStrings(sensitiveScanFiles);
pass(checks, "sensitive_string_scan", sensitiveFindings.length === 0, {
findings: sensitiveFindings,
const demoSafety = checkDemoSafety();
pass(checks, "demo_safety_scan", demoSafety.ok, {
findings: demoSafety.findings,
scope_files: demoSafety.scope_files,
status: demoSafety.status,
stderr: demoSafety.stderr,
error: demoSafety.error,
});
const ok = checks.every((check) => check.ok);
+4 -1
View File
@@ -21,6 +21,9 @@ rust_candidates=()
if [[ -n "$RUST_BIN" ]]; then
rust_candidates+=("$RUST_BIN")
fi
if [[ -n "${AW_RUS_CARGO_TARGET_DIR:-}" ]]; then
rust_candidates+=("$AW_RUS_CARGO_TARGET_DIR/release/quality-gate")
fi
rust_candidates+=(
"$TARGET_ROOT/release/quality-gate"
"$ROOT_DIR/adk-rust/target/release/quality-gate"
@@ -93,7 +96,7 @@ fi
violations=()
for path in "${tracked_py[@]}"; do
case "$path" in
aw-server/dlp-content-analysis/*|clickhouse-1c/ai/*|clickhouse-1c/etl/*|detmir-mcp/main.py|grafana-1c/*|pfsense/*|proxmox/tsj_guardian_bot.py|proxmox/test_tsj_guardian_bot.py)
aw-server/dlp-content-analysis/*|clickhouse-1c/ai/*|clickhouse-1c/etl/*|detmir-mcp/main.py|grafana-1c/*|pfsense/*|proxmox/tsj_guardian_bot.py|proxmox/test_tsj_guardian_bot.py|scripts/package_rust_release_binaries.py)
continue
;;
esac