Compare commits

..
Author SHA1 Message Date
igor04091968 b74d78750a docs: sync Rust-first deployment manual from main 2026-06-13 11:55:15 +03:00
28 changed files with 413 additions and 1048 deletions
-74
View File
@@ -1,74 +0,0 @@
name: rust-binary-build
on:
workflow_dispatch:
pull_request:
branches: [ "main" ]
paths:
- 'rust-toolchain.toml'
- 'adk-rust/**'
- 'scripts/package_rust_release_binaries.py'
- '.github/workflows/rust-binary-build.yml'
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-linux-x86_64:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Build release binaries
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
- name: Package release binaries
run: |
python3 scripts/package_rust_release_binaries.py \
--release-dir adk-rust/target/release \
--out-dir dist/awatch-rus-linux-x86_64 \
--archive dist/awatch-rus-linux-x86_64-release-binaries.tar.gz \
--target linux-x86_64 \
--commit "${GITHUB_SHA}" \
--ref "${GITHUB_REF}" \
--run-id "${GITHUB_RUN_ID}"
- name: Upload release binaries artifact
uses: actions/upload-artifact@v4
with:
name: awatch-rus-linux_x86_64-release-binaries
path: |
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
dist/awatch-rus-linux_x86_64/BINARIES.txt
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
if-no-files-found: error
retention-days: 30
- name: Publish GitHub Release assets
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
dist/awatch-rus-linux_x86_64/BINARIES.txt
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
@@ -1,40 +0,0 @@
name: Rust clippy diagnostic
on:
push:
branches:
- codex/rust-professionalization
workflow_dispatch:
jobs:
detmir-portal-clippy-diagnostic:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.85 with rustfmt and clippy
run: |
rustup toolchain install 1.85.0 --profile minimal --component rustfmt --component clippy
rustup default 1.85.0
- name: Capture detmir-portal clippy output
working-directory: adk-rust
run: |
set +e
cargo clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1
status=$?
echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt
tail -n 240 ../detmir-portal-clippy.log
exit ${status}
- name: Upload detmir-portal clippy log
if: always()
uses: actions/upload-artifact@v4
with:
name: detmir-portal-clippy-log
path: |
detmir-portal-clippy.log
detmir-portal-clippy-status.txt
@@ -1,73 +0,0 @@
name: Rust professionalization check
on:
pull_request:
branches:
- main
paths:
- 'rust-toolchain.toml'
- 'adk-rust/crates/detmir-core/**'
- 'adk-rust/crates/detmir-portal/**'
- 'scripts/check_private_config_guard.sh'
- 'scripts/check_portal_contract_sync.mjs'
- '.github/workflows/rust-professionalization-check.yml'
workflow_dispatch:
jobs:
rust-check:
name: changed Rust crates smoke
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Cargo fmt check
working-directory: adk-rust
run: cargo +1.94.0 fmt --all -- --check
- name: Test detmir-core
working-directory: adk-rust
run: cargo +1.94.0 test -p detmir-core
- name: Test detmir-portal
working-directory: adk-rust
run: cargo +1.94.0 test -p detmir-portal
- name: Clippy detmir-core
working-directory: adk-rust
run: cargo +1.94.0 clippy -p detmir-core --all-targets -- -D warnings
- name: Clippy detmir-portal with captured log
working-directory: adk-rust
run: |
set +e
cargo +1.94.0 clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1
status=$?
echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt
tail -n 80 ../detmir-portal-clippy.log
exit ${status}
- name: Upload detmir-portal clippy log
if: always()
uses: actions/upload-artifact@v4
with:
name: detmir-portal-clippy-log
path: |
detmir-portal-clippy.log
detmir-portal-clippy-status.txt
- name: Private config guard
run: bash scripts/check_private_config_guard.sh
- name: Portal contract sync
run: node scripts/check_portal_contract_sync.mjs
+8 -12
View File
@@ -5,7 +5,6 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
jobs:
rust-workspace:
@@ -14,22 +13,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Install Rust 1.85
uses: dtolnay/rust-toolchain@1.85.0
with:
components: rustfmt, clippy
- name: Format
run: cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check
run: cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check
- name: Test
run: cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace
run: cargo test --manifest-path adk-rust/Cargo.toml --workspace
- name: Clippy
run: cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
run: cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
- name: Release build
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
run: cargo build --manifest-path adk-rust/Cargo.toml --workspace --release
+14 -9
View File
@@ -5,17 +5,21 @@ AWatch-rus - программный комплекс операционного
корпоративной ИТ-инфраструктуры на базе ActivityWatch, Rust-сервисов
автоматизации, Grafana/Prometheus-витрин и модулей расследования инцидентов.
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ,хотя DLP,evidence и Hayabusa используются в проекте.
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ. DLP,
evidence и Hayabusa используются как прикладные модули внутри платформы
операционного контроля и технического аудита.
## Назначение
- AWatch-rus Workforce: активность сотрудников, загрузка, RDP/1C/рабочие
приложения и управленческие отчеты для владельца бизнеса.
- AWatch-rus Security: DLP-сигналы, evidence, очередь кейсов и audit действий оператора без заявления продукта как сертифицированной СЗИ.
- AWatch-rus Forensics: цепочки событий, Hayabusa/offline-разбор и материалы для внутреннего расследования.
- AWatch-rus Security: DLP-сигналы, evidence, очередь кейсов и audit действий
оператора без заявления продукта как сертифицированной СЗИ.
- AWatch-rus Forensics: цепочки событий, Hayabusa/offline-разбор и материалы для
внутреннего расследования.
- Контроль доступности и свежести данных ActivityWatch.
- Учет активного времени, Windows RDP-сессий окон, приложений и рабочих интервалов а также активности пользователей в Linux/Unix системах.
- витрины Grafana для администратора, оператора ИБ и руководителя(dashboards).
- Учет активного времени, RDP-сессий, окон, приложений и рабочих интервалов.
- Витрины Grafana для администратора, оператора ИБ и руководителя.
- Автоматизация runbook-проверок, health-check, SLO и безопасного auto-heal.
- Сбор evidence по инцидентам и аудит действий оператора.
@@ -24,17 +28,18 @@ AWatch-rus - программный комплекс операционного
Основной серверный runtime AWatch-rus переведен на Rust: status/check/auto-heal,
SLO, worktime, DLP server-side helpers, evidence и install-kit tooling.
Python, присутствующий в коде репозитория, остается для вспомогательных направлений: Telegram bot
runtime(для оперативного оповещения), OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти части не являются ядром Rust-first runtime.
Python в репозитории остается для вспомогательных направлений: Telegram bot
runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
части не являются ядром Rust-first runtime.
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible
JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не
рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в
текущий основной UI, но возможна их интеграция в проект.
текущий основной UI.
## Product Evolution
AWatch-rus является рабочей платформой Workforce + Security + Forensics.
AWatch-rus уже является рабочей платформой Workforce + Security + Forensics.
Архитектура предусматривает расширение на агентные и agentless-источники
данных. Planned/Future элементы ниже не являются реализованной функциональностью
и не должны трактоваться как готовые collectors или integrations.
-56
View File
@@ -1,37 +1,19 @@
#![deny(unsafe_op_in_unsafe_fn)]
//! Shared production primitives for AWatch-rus.
//!
//! This crate intentionally stays small and dependency-light. It contains the
//! status, exit-code and runtime-configuration guardrails that are reused by
//! operational binaries and health/check tooling. Keep business-specific portal,
//! DLP or workforce logic out of this crate.
use std::fmt;
use anyhow::{Context, Result};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
/// Normalized health/check status used by CLI tools, probes and JSON payloads.
///
/// CONTRACT: serialized values are uppercase and must remain stable because
/// deployment scripts, smoke checks and dashboards can key off these strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum StatusLevel {
/// Component is healthy and the check passed.
Ok,
/// Component works, but a risk or degraded condition needs attention.
Warn,
/// Component check failed or a required dependency is unavailable.
Fail,
/// Component did not provide enough information for a reliable status.
Unknown,
}
impl StatusLevel {
/// Return the stable uppercase representation used in human and JSON output.
pub fn as_str(self) -> &'static str {
match self {
Self::Ok => "OK",
@@ -41,10 +23,6 @@ impl StatusLevel {
}
}
/// Map status to the process exit code expected by operational checks.
///
/// CONTRACT: `WARN` exits as a failed check rather than success so that
/// automation does not silently ignore degraded production state.
pub fn exit_code(self) -> i32 {
match self {
Self::Ok => exit_codes::OK,
@@ -70,39 +48,23 @@ impl From<&str> for StatusLevel {
}
}
/// Stable process exit codes for AWatch-rus operational binaries.
///
/// CONTRACT: keep these numeric values stable. Shell scripts, systemd units,
/// smoke tests and runbooks can depend on them.
pub mod exit_codes {
/// Successful execution.
pub const OK: i32 = 0;
/// Unexpected runtime or IO error.
pub const ERROR: i32 = 1;
/// Health/check policy failed or returned a degraded status.
pub const CHECK_FAILED: i32 = 2;
/// A safety policy denied a requested action.
pub const POLICY_DENIED: i32 = 3;
}
/// Return the current UTC timestamp in compact RFC3339/Zulu format.
pub fn now_utc_rfc3339() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
/// Parse an RFC3339 timestamp and normalize it to UTC.
pub fn parse_utc_rfc3339(value: &str) -> Result<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.with_context(|| format!("invalid RFC3339 timestamp: {value}"))
.map(|ts| ts.with_timezone(&Utc))
}
/// Runtime configuration guardrails.
///
/// SECURITY: these helpers are deliberately conservative. They reject empty,
/// documentation, TEST-NET and common placeholder values before a component is
/// allowed to run in production mode. This prevents demo-safe examples from
/// accidentally becoming live runtime configuration.
pub mod runtime_guard {
use anyhow::{Result, bail};
@@ -120,11 +82,6 @@ pub mod runtime_guard {
"PASSWORD",
];
/// Return true when a value looks like a public/demo placeholder.
///
/// RATIONALE: AWatch-rus documentation intentionally uses TEST-NET ranges
/// and HOST-EXAMPLE markers. Production binaries should fail closed when
/// such values reach runtime configuration.
pub fn is_runtime_placeholder(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -149,7 +106,6 @@ pub mod runtime_guard {
|| (normalized.starts_with('<') && normalized.ends_with('>'))
}
/// Return true when a value is unsafe for a secret-like configuration field.
pub fn is_secret_placeholder(value: &str) -> bool {
is_runtime_placeholder(value)
|| matches!(
@@ -158,10 +114,6 @@ pub mod runtime_guard {
)
}
/// Ensure a required runtime value is not empty or demo-only.
///
/// SECURITY: callers should invoke this before opening network connections,
/// starting ingestion or enabling exporters in production mode.
pub fn ensure_runtime_value(name: &str, value: &str, context: &str) -> Result<()> {
if is_runtime_placeholder(value) {
bail!("{name} contains an empty/example/TEST-NET value while {context}");
@@ -169,7 +121,6 @@ pub mod runtime_guard {
Ok(())
}
/// Ensure a required secret is not empty or an obvious placeholder.
pub fn ensure_secret_value(name: &str, value: &str, context: &str) -> Result<()> {
if is_secret_placeholder(value) {
bail!("{name} contains an empty/example secret value while {context}");
@@ -177,7 +128,6 @@ pub mod runtime_guard {
Ok(())
}
/// Ensure an iterator of runtime values is non-empty and production-safe.
pub fn ensure_runtime_values<'a>(
name: &str,
values: impl IntoIterator<Item = &'a String>,
@@ -194,12 +144,6 @@ pub mod runtime_guard {
Ok(())
}
/// Validate a complete InfluxDB exporter configuration block.
///
/// CONTRACT: when an exporter is enabled, URL, org, bucket, token and host
/// list must all be real runtime values. A partial/demo exporter config is
/// more dangerous than a disabled exporter because it creates false
/// confidence in monitoring readiness.
pub fn ensure_influx_runtime_config(
prefix: &str,
url: &str,
-4
View File
@@ -21,7 +21,3 @@ tiny_http.workspace = true
[dev-dependencies]
tempfile.workspace = true
[lints.clippy]
comparison_chain = "allow"
search_is_some = "allow"
@@ -1,57 +0,0 @@
//! Portal API contract summary payload.
//!
//! CONTRACT: this module describes stable public API routes exposed by the
//! current Rust HTML/HTMX portal and future clients. Keep changes additive
//! unless the OpenAPI/TypeScript contracts are updated in the same PR.
use serde_json::{Value, json};
pub(crate) fn api_contract_summary() -> Value {
json!({
"ok": true,
"contract_version": "2026-06-06.pilot-v1",
"generated_by": "detmir-portal",
"api_base": "/api",
"compatibility": {
"policy": "additive",
"main_ui": "rust-server-rendered-html-htmx-compatible",
"unknown_fields": "clients must ignore unknown fields",
"nullable_fields": "clients must tolerate null and missing optional fields",
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
},
"targets": ["rust-html", "htmx-compatible"],
"artifacts": {
"openapi": "/api/contracts/openapi.json",
"typescript": "/api/contracts/typescript.d.ts"
},
"stable_endpoints": [
{"method": "GET", "path": "/healthz", "purpose": "process liveness without external dependency checks"},
{"method": "GET", "path": "/readyz", "purpose": "local readiness and contract-only dependency status"},
{"method": "GET", "path": "/version", "purpose": "safe build and schema version metadata"},
{"method": "GET", "path": "/metrics", "purpose": "Prometheus metrics without high-cardinality labels"},
{"method": "GET", "path": "/api/health", "purpose": "light service health"},
{"method": "GET", "path": "/api/contracts", "purpose": "contract index"},
{"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"},
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
{"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"},
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
]
})
}
@@ -1,26 +0,0 @@
//! External command execution helpers for the portal.
//!
//! CONTRACT: these helpers are intentionally small and side-effect explicit.
//! They preserve stdout/stderr error text because readiness verification APIs
//! expose command failure diagnostics to operators.
use std::path::Path;
use std::process::Command;
pub(crate) fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
let output = command
.current_dir(dir)
.output()
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
if output.status.success() {
Ok(())
} else {
Err(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.trim()
.to_string())
}
}
+379 -25
View File
@@ -3,6 +3,7 @@ use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -22,48 +23,33 @@ use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
mod api_contracts;
mod command_runner;
mod executive_actions;
mod path_query;
mod portal_roles;
mod production;
mod readiness_api;
mod risk_narrative;
mod role_access;
mod snapshot_cache;
mod static_assets;
mod workforce_kpi_explain;
use api_contracts::api_contract_summary;
use executive_actions::{
actions_from_center, build_action_center_from_report, filter_actions_for_role,
};
use path_query::{
normalize_path, parse_case_path, parse_case_status_path, parse_investigation_pack_path,
query_flag, query_param,
};
use portal_roles::PortalRole;
use production::{
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
record_ingestion_rejected, 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::{
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
};
use role_access::{portal_role_from_request, respond_forbidden, role_envelope};
use snapshot_cache::{
SnapshotCache, build_fast_health, cached_snapshot, clone_snapshot_cache, new_snapshot_cache,
};
use static_assets::{
API_CONTRACT_OPENAPI, API_CONTRACT_TYPESCRIPT, APP_CSS, APP_JS, ARCHITECTURE_HTML, INDEX_HTML,
};
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
const INDEX_HTML: &str = include_str!("static/index.html");
const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
const APP_CSS: &str = include_str!("static/app.css");
const APP_JS: &str = include_str!("static/app.js");
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению";
const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения";
const PORTAL_SCHEMA_VERSION: &str = "pilot-v1";
@@ -87,6 +73,84 @@ unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum PortalRole {
Executive,
Manager,
Security,
Forensics,
Admin,
}
impl PortalRole {
fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"executive" | "owner" | "rukovoditel" | "руководитель" => {
Some(Self::Executive)
}
"manager" | "workforce" | "руководитель_подразделения" => {
Some(Self::Manager)
}
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
_ => None,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Executive => "executive",
Self::Manager => "manager",
Self::Security => "security",
Self::Forensics => "forensics",
Self::Admin => "admin",
}
}
fn label_ru(self) -> &'static str {
match self {
Self::Executive => "Руководитель",
Self::Manager => "Руководитель подразделения",
Self::Security => "Безопасность",
Self::Forensics => "Расследования",
Self::Admin => "Администратор",
}
}
fn allowed_scopes(self) -> &'static [&'static str] {
match self {
Self::Executive => &["executive", "workforce"],
Self::Manager => &["executive", "workforce"],
Self::Security => &["security", "incidents", "ueba", "pfsense"],
Self::Forensics => &["forensics", "incidents", "ueba"],
Self::Admin => &[
"executive",
"workforce",
"security",
"forensics",
"incidents",
"ueba",
"pfsense",
"admin",
],
}
}
fn can_access(self, scope: &str) -> bool {
self.allowed_scopes().contains(&scope)
}
}
#[derive(Clone, Debug)]
struct CachedSnapshot {
created: Instant,
snapshot: Snapshot,
}
#[derive(Clone, Debug, Parser)]
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
struct Cli {
@@ -1319,11 +1383,11 @@ fn run() -> Result<i32> {
}
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
let snapshot_cache: SnapshotCache = new_snapshot_cache();
let snapshot_cache: SnapshotCache = Arc::new(Mutex::new(None));
eprintln!("detmir-portal listening on http://{}", args.bind);
for request in server.incoming_requests() {
let args = args.clone();
let snapshot_cache = clone_snapshot_cache(&snapshot_cache);
let snapshot_cache = Arc::clone(&snapshot_cache);
thread::spawn(move || {
let result = if args.evidence_only {
handle_evidence_only_request(request, &args)
@@ -1657,6 +1721,296 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
)
}
fn normalize_path(url: &str) -> String {
let path = url.split('?').next().unwrap_or("/");
let path = path.strip_prefix("/portal").unwrap_or(path);
if path.is_empty() {
"/".to_string()
} else {
path.to_string()
}
}
fn api_contract_summary() -> Value {
json!({
"ok": true,
"contract_version": "2026-06-06.pilot-v1",
"generated_by": "detmir-portal",
"api_base": "/api",
"compatibility": {
"policy": "additive",
"main_ui": "rust-server-rendered-html-htmx-compatible",
"unknown_fields": "clients must ignore unknown fields",
"nullable_fields": "clients must tolerate null and missing optional fields",
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
},
"targets": ["rust-html", "htmx-compatible"],
"artifacts": {
"openapi": "/api/contracts/openapi.json",
"typescript": "/api/contracts/typescript.d.ts"
},
"stable_endpoints": [
{"method": "GET", "path": "/healthz", "purpose": "process liveness without external dependency checks"},
{"method": "GET", "path": "/readyz", "purpose": "local readiness and contract-only dependency status"},
{"method": "GET", "path": "/version", "purpose": "safe build and schema version metadata"},
{"method": "GET", "path": "/metrics", "purpose": "Prometheus metrics without high-cardinality labels"},
{"method": "GET", "path": "/api/health", "purpose": "light service health"},
{"method": "GET", "path": "/api/contracts", "purpose": "contract index"},
{"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"},
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
{"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"},
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
]
})
}
fn readiness_latest(args: &Cli) -> Value {
read_json_file(
&args
.readiness_bundle_dir
.join("detmir-readiness-latest.json"),
)
.unwrap_or_else(|err| {
json!({
"ok": false,
"generated_at_utc": now(),
"error": err.to_string(),
})
})
}
fn readiness_bundle(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
json!({
"ok": false,
"error": err.to_string(),
})
});
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
.unwrap_or_default()
.trim()
.to_string();
let artifacts = [
"detmir-readiness-latest.json",
"detmir-readiness-act.md",
"detmir-readiness-act.html",
"sha256sums.txt",
"sha256sums.txt.sig",
"public-key.pem",
"detmir-readiness-status.json",
"detmir-readiness.prom",
]
.into_iter()
.filter_map(|name| {
let path = dir.join(name);
path.metadata().ok().map(|meta| {
json!({
"name": name,
"bytes": meta.len(),
"available": true,
})
})
})
.collect::<Vec<_>>();
json!({
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
"generated_at_utc": now(),
"bundle_dir": dir.display().to_string(),
"latest_archive_dir": latest_dir,
"status": status,
"artifacts": artifacts,
})
}
fn readiness_verify(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let checksum = run_in_dir(
dir,
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
);
let sig_path = dir.join("sha256sums.txt.sig");
let pub_path = dir.join("public-key.pem");
let signature = if sig_path.is_file() && pub_path.is_file() {
run_in_dir(
dir,
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-verify")
.arg("public-key.pem")
.arg("-signature")
.arg("sha256sums.txt.sig")
.arg("sha256sums.txt"),
)
} else {
Err("signature files are not available".to_string())
};
json!({
"ok": checksum.is_ok() && signature.is_ok(),
"generated_at_utc": now(),
"checksum_verified": checksum.is_ok(),
"signature_verified": signature.is_ok(),
"checksum_error": checksum.err(),
"signature_error": signature.err(),
})
}
fn read_json_file(path: &Path) -> Result<Value> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
let output = command
.current_dir(dir)
.output()
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
if output.status.success() {
Ok(())
} else {
Err(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.trim()
.to_string())
}
}
fn query_flag(url: &str, key: &str) -> bool {
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
return false;
};
query.split('&').any(|pair| {
let (name, value) = pair.split_once('=').unwrap_or((pair, "1"));
name == key && matches!(value, "1" | "true" | "yes" | "on")
})
}
fn query_param(url: &str, key: &str) -> Option<String> {
let query = url.split_once('?').map(|(_, query)| query)?;
query.split('&').find_map(|pair| {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
(name == key && !value.is_empty()).then(|| value.to_string())
})
}
fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
query_param(url, "role")
.as_deref()
.and_then(PortalRole::parse)
.or_else(|| {
request
.headers()
.iter()
.find(|header| header.field.equiv("X-AWatch-Role"))
.and_then(|header| PortalRole::parse(header.value.as_str()))
})
.unwrap_or(PortalRole::Executive)
}
fn role_envelope(role: PortalRole, scope: &str) -> Value {
json!({
"role": role.as_str(),
"role_label": role.label_ru(),
"scope": scope,
"allowed_scopes": role.allowed_scopes(),
"server_enforced": true,
})
}
fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
respond_json_status(
request,
StatusCode(403),
&json!({
"ok": false,
"error": "forbidden",
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
"role": role.as_str(),
"scope": scope,
"server_enforced": true,
}),
)
}
fn parse_investigation_pack_path(path: &str) -> Option<String> {
path.strip_prefix("/api/investigation-pack/")
.map(str::trim)
.filter(|value| !value.is_empty() && !value.contains('/'))
.map(ToString::to_string)
}
fn parse_case_path(path: &str) -> Option<String> {
path.strip_prefix("/api/cases/")
.map(str::trim)
.filter(|value| !value.is_empty() && !value.contains('/'))
.map(ToString::to_string)
}
fn parse_case_status_path(path: &str) -> Option<String> {
path.strip_prefix("/api/cases/")
.and_then(|value| value.strip_suffix("/status"))
.map(str::trim)
.filter(|value| !value.is_empty() && !value.contains('/'))
.map(ToString::to_string)
}
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
if let Some(cached) = guard.as_ref() {
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
return cached.snapshot.clone();
}
}
let snapshot = build_snapshot(args);
*guard = Some(CachedSnapshot {
created: Instant::now(),
snapshot: snapshot.clone(),
});
snapshot
}
fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
match cache.try_lock() {
Ok(guard) => guard
.as_ref()
.map(|cached| build_health(&cached.snapshot))
.unwrap_or_else(lightweight_health),
Err(_) => lightweight_health(),
}
}
fn lightweight_health() -> HealthResponse {
let mut sources = BTreeMap::new();
sources.insert("portal".to_string(), true);
HealthResponse {
ok: true,
generated_at_utc: now(),
version: env!("CARGO_PKG_VERSION").to_string(),
sources,
}
}
fn build_snapshot(args: &Cli) -> Snapshot {
let timeout = Duration::from_secs(args.timeout_seconds);
let security_events_config = SecurityEventsConfig {
@@ -10537,7 +10891,7 @@ fn header(name: &str, value: &str) -> Result<Header> {
.map_err(|_| anyhow!("invalid header {name}: {value}"))
}
pub(crate) fn now() -> String {
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
@@ -1,54 +0,0 @@
//! URL path and query parsing helpers for the portal.
//!
//! CONTRACT: these helpers are routing glue. Keep accepted URL shapes stable
//! because API handlers and the HTML portal depend on them.
pub(crate) fn normalize_path(url: &str) -> String {
let path = url.split('?').next().unwrap_or("/");
let path = path.strip_prefix("/portal").unwrap_or(path);
if path.is_empty() {
"/".to_string()
} else {
path.to_string()
}
}
pub(crate) fn query_flag(url: &str, key: &str) -> bool {
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
return false;
};
query.split('&').any(|pair| {
let (name, value) = pair.split_once('=').unwrap_or((pair, "1"));
name == key && matches!(value, "1" | "true" | "yes" | "on")
})
}
pub(crate) fn query_param(url: &str, key: &str) -> Option<String> {
let query = url.split_once('?').map(|(_, query)| query)?;
query.split('&').find_map(|pair| {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
(name == key && !value.is_empty()).then(|| value.to_string())
})
}
pub(crate) fn parse_investigation_pack_path(path: &str) -> Option<String> {
path.strip_prefix("/api/investigation-pack/")
.map(str::trim)
.filter(|value| !value.is_empty() && !value.contains('/'))
.map(ToString::to_string)
}
pub(crate) fn parse_case_path(path: &str) -> Option<String> {
path.strip_prefix("/api/cases/")
.map(str::trim)
.filter(|value| !value.is_empty() && !value.contains('/'))
.map(ToString::to_string)
}
pub(crate) fn parse_case_status_path(path: &str) -> Option<String> {
path.strip_prefix("/api/cases/")
.and_then(|value| value.strip_suffix("/status"))
.map(str::trim)
.filter(|value| !value.is_empty() && !value.contains('/'))
.map(ToString::to_string)
}
@@ -1,77 +0,0 @@
//! Portal role model and access-scope contract.
//!
//! CONTRACT: role aliases, serialized values and allowed scopes are part of
//! the portal API/security boundary. Keep changes explicit and covered by
//! existing role-gate tests in `main.rs`.
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PortalRole {
Executive,
Manager,
Security,
Forensics,
Admin,
}
impl PortalRole {
pub(crate) fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"executive" | "owner" | "rukovoditel" | "руководитель" => {
Some(Self::Executive)
}
"manager" | "workforce" | "руководитель_подразделения" => {
Some(Self::Manager)
}
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
_ => None,
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Executive => "executive",
Self::Manager => "manager",
Self::Security => "security",
Self::Forensics => "forensics",
Self::Admin => "admin",
}
}
pub(crate) fn label_ru(self) -> &'static str {
match self {
Self::Executive => "Руководитель",
Self::Manager => "Руководитель подразделения",
Self::Security => "Безопасность",
Self::Forensics => "Расследования",
Self::Admin => "Администратор",
}
}
pub(crate) fn allowed_scopes(self) -> &'static [&'static str] {
match self {
Self::Executive => &["executive", "workforce"],
Self::Manager => &["executive", "workforce"],
Self::Security => &["security", "incidents", "ueba", "pfsense"],
Self::Forensics => &["forensics", "incidents", "ueba"],
Self::Admin => &[
"executive",
"workforce",
"security",
"forensics",
"incidents",
"ueba",
"pfsense",
"admin",
],
}
}
pub(crate) fn can_access(self, scope: &str) -> bool {
self.allowed_scopes().contains(&scope)
}
}
@@ -1,8 +1,3 @@
//! Liveness probe payload.
//!
//! CONTRACT: `/healthz` is intentionally shallow. It proves that the portal
//! process can answer HTTP, while dependency checks belong to `/readyz`.
use serde_json::{Value, json};
use crate::now;
@@ -1,10 +1,3 @@
//! Configuration and request-bound validation for production portal routes.
//!
//! RATIONALE: the portal can aggregate reports, evidence and external service
//! payloads. Query and body limits keep pilot installations responsive and make
//! expensive report routes fail closed instead of exhausting memory or blocking
//! the single-process runtime.
use std::collections::BTreeSet;
use anyhow::{Result, anyhow};
@@ -37,10 +30,6 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
if port == 0 {
return Err(anyhow!("invalid config port: expected 1..65535"));
}
// RATIONALE: page and date limits protect heavy report endpoints while
// preserving monthly pilot reporting. Hard upper bounds prevent accidental
// production overrides from turning the portal into an unbounded exporter.
if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE {
return Err(anyhow!(
"invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
@@ -77,10 +66,6 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
"invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
));
}
// SECURITY: environment and module names can reach metrics/log labels.
// Restrict them to short ASCII tokens to avoid label injection and runaway
// cardinality from free-form deployment names.
if !is_safe_environment_name(&args.environment) {
return Err(anyhow!(
"invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
@@ -1,10 +1,3 @@
//! Structured HTTP access logging for the portal runtime.
//!
//! CONTRACT: logs are emitted as single-line JSON to stderr so systemd/journald,
//! container runtimes and log forwarders can parse them without scraping free
//! text. Do not log raw request bodies, secrets, evidence bytes or personal
//! payloads here.
use serde_json::{Value, json};
use tiny_http::StatusCode;
@@ -28,10 +21,6 @@ pub(crate) fn log_http_request(
} else {
Value::Null
};
// SECURITY: include routing/correlation fields, but do not include query
// values, request body, headers or tokens. Those can contain employee data,
// screenshots, evidence references or API keys.
eprintln!(
"{}",
json!({
@@ -1,9 +1,3 @@
//! In-process Prometheus-style metrics for the portal.
//!
//! CONTRACT: metric names and label keys are part of the operational contract
//! used by dashboards and smoke checks. Additive metrics are allowed; renaming
//! existing metrics requires synchronized dashboard/documentation changes.
use std::collections::BTreeMap;
use std::fmt::Write as FmtWrite;
use std::sync::{Mutex, OnceLock};
@@ -185,7 +179,5 @@ pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
}
fn prom_escape(value: &str) -> String {
// SECURITY: metric label values are route/module tokens, but escaping keeps
// the endpoint safe if future callers pass proxy-derived values.
value.replace('\\', "\\\\").replace('"', "\\\"")
}
@@ -1,14 +1,3 @@
//! Production-facing portal runtime support.
//!
//! This module groups the cross-cutting concerns that must stay consistent
//! across all portal routes: health/readiness/version contracts, query and
//! configuration limits, structured logging, Prometheus-style metrics and
//! request correlation metadata.
//!
//! CONTRACT: keep this module free from role-specific business rendering. It is
//! the operational boundary around the portal, not the workforce/security report
//! implementation itself.
pub(crate) mod health;
pub(crate) mod limits;
pub(crate) mod logging;
@@ -1,10 +1,3 @@
//! Readiness probe payload.
//!
//! CONTRACT: `/readyz` checks whether the portal is safe to receive normal
//! traffic. It must remain conservative: configuration errors and broken state
//! storage make the process `not_ready`; optional integrations can report
//! `disabled`, `not_required` or `contract_only` without failing the whole probe.
use std::path::Path;
use serde_json::{Value, json};
@@ -1,8 +1,3 @@
//! Request correlation and route classification for portal observability.
//!
//! CONTRACT: generated route names must not expose volatile identifiers such as
//! case IDs, candidate IDs or evidence IDs; use route templates instead.
use std::cell::RefCell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
@@ -78,7 +73,7 @@ fn request_header(request: &Request, name: &str) -> Option<String> {
request
.headers()
.iter()
.find(|header| header.field.as_str().as_str().eq_ignore_ascii_case(name))
.find(|header| header.field.to_string().eq_ignore_ascii_case(name))
.map(|header| header.value.as_str().to_string())
}
@@ -1,8 +1,3 @@
//! Build/version probe payload.
//!
//! CONTRACT: `/version` is used by smoke tests, runbooks and release evidence.
//! Keep field names stable and add new fields only in a backward-compatible way.
use serde_json::{Value, json};
use crate::{Cli, PORTAL_SCHEMA_VERSION};
@@ -1,112 +0,0 @@
//! Readiness API payload helpers for the portal.
//!
//! CONTRACT: these helpers expose existing readiness bundle/status/verify
//! payloads. Keep file names, JSON fields and verification commands stable
//! unless the customer readiness contract is updated in the same PR.
use std::fs;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{Value, json};
use crate::command_runner::run_in_dir;
use crate::{Cli, now};
pub(crate) fn readiness_latest(args: &Cli) -> Value {
read_json_file(
&args
.readiness_bundle_dir
.join("detmir-readiness-latest.json"),
)
.unwrap_or_else(|err| {
json!({
"ok": false,
"generated_at_utc": now(),
"error": err.to_string(),
})
})
}
pub(crate) fn readiness_bundle(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
json!({
"ok": false,
"error": err.to_string(),
})
});
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
.unwrap_or_default()
.trim()
.to_string();
let artifacts = [
"detmir-readiness-latest.json",
"detmir-readiness-act.md",
"detmir-readiness-act.html",
"sha256sums.txt",
"sha256sums.txt.sig",
"public-key.pem",
"detmir-readiness-status.json",
"detmir-readiness.prom",
]
.into_iter()
.filter_map(|name| {
let path = dir.join(name);
path.metadata().ok().map(|meta| {
json!({
"name": name,
"bytes": meta.len(),
"available": true,
})
})
})
.collect::<Vec<_>>();
json!({
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
"generated_at_utc": now(),
"bundle_dir": dir.display().to_string(),
"latest_archive_dir": latest_dir,
"status": status,
"artifacts": artifacts,
})
}
pub(crate) fn readiness_verify(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let checksum = run_in_dir(
dir,
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
);
let sig_path = dir.join("sha256sums.txt.sig");
let pub_path = dir.join("public-key.pem");
let signature = if sig_path.is_file() && pub_path.is_file() {
run_in_dir(
dir,
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-verify")
.arg("public-key.pem")
.arg("-signature")
.arg("sha256sums.txt.sig")
.arg("sha256sums.txt"),
)
} else {
Err("signature files are not available".to_string())
};
json!({
"ok": checksum.is_ok() && signature.is_ok(),
"generated_at_utc": now(),
"checksum_verified": checksum.is_ok(),
"signature_verified": signature.is_ok(),
"checksum_error": checksum.err(),
"signature_error": signature.err(),
})
}
fn read_json_file(path: &Path) -> Result<Value> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
@@ -1,52 +0,0 @@
//! Portal role extraction and access-denial helpers.
//!
//! CONTRACT: role aliases, role envelope fields and forbidden response shape
//! are part of the portal security boundary. Keep changes explicit and covered
//! by role-gate tests.
use anyhow::Result;
use serde_json::{Value, json};
use tiny_http::{Request, StatusCode};
use crate::path_query::query_param;
use crate::portal_roles::PortalRole;
use crate::respond_json_status;
pub(crate) fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
query_param(url, "role")
.as_deref()
.and_then(PortalRole::parse)
.or_else(|| {
request
.headers()
.iter()
.find(|header| header.field.equiv("X-AWatch-Role"))
.and_then(|header| PortalRole::parse(header.value.as_str()))
})
.unwrap_or(PortalRole::Executive)
}
pub(crate) fn role_envelope(role: PortalRole, scope: &str) -> Value {
json!({
"role": role.as_str(),
"role_label": role.label_ru(),
"scope": scope,
"allowed_scopes": role.allowed_scopes(),
"server_enforced": true,
})
}
pub(crate) fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
respond_json_status(
request,
StatusCode(403),
&json!({
"ok": false,
"error": "forbidden",
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
"role": role.as_str(),
"scope": scope,
"server_enforced": true,
}),
)
}
@@ -1,65 +0,0 @@
//! Snapshot cache helpers for the portal request path.
//!
//! CONTRACT: this module only owns short-lived in-process cache behavior.
//! It must not change snapshot payloads, source collection, API routes or
//! business calculations.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::{Cli, HealthResponse, Snapshot, build_health, build_snapshot, now};
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
pub(crate) type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
#[derive(Clone, Debug)]
pub(crate) struct CachedSnapshot {
created: Instant,
snapshot: Snapshot,
}
pub(crate) fn new_snapshot_cache() -> SnapshotCache {
Arc::new(Mutex::new(None))
}
pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache {
Arc::clone(cache)
}
pub(crate) fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
if let Some(cached) = guard.as_ref() {
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
return cached.snapshot.clone();
}
}
let snapshot = build_snapshot(args);
*guard = Some(CachedSnapshot {
created: Instant::now(),
snapshot: snapshot.clone(),
});
snapshot
}
pub(crate) fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
match cache.try_lock() {
Ok(guard) => guard
.as_ref()
.map(|cached| build_health(&cached.snapshot))
.unwrap_or_else(lightweight_health),
Err(_) => lightweight_health(),
}
}
fn lightweight_health() -> HealthResponse {
let mut sources = BTreeMap::new();
sources.insert("portal".to_string(), true);
HealthResponse {
ok: true,
generated_at_utc: now(),
version: env!("CARGO_PKG_VERSION").to_string(),
sources,
}
}
@@ -1,11 +0,0 @@
//! Static portal assets and generated API contract text.
//!
//! CONTRACT: this module only exposes embedded static files. Do not change
//! file contents, MIME handling, routes or API contracts from here.
pub(crate) const INDEX_HTML: &str = include_str!("static/index.html");
pub(crate) const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
pub(crate) const APP_CSS: &str = include_str!("static/app.css");
pub(crate) const APP_JS: &str = include_str!("static/app.js");
pub(crate) const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
pub(crate) const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
+11 -8
View File
@@ -13,22 +13,23 @@ Forensics с прозрачными rule-based объяснениями.
| Продукт | Публичная категория | Сильная сторона | Как позиционировать AWatch-rus рядом |
| --- | --- | --- | --- |
| ActivityWatch | Open-source automated time tracker | Локальный, открытый и понятный сбор активности приложений и сайтов | AWatch-rus развивает этот подход в пилотный корпоративный контур с ролями, отчетами, Risk Narrative и эксплуатационной документацией |
| Стахановец | Контроль сотрудников, мониторинг активности, DLP-возможности | Зрелый классический контроль рабочих мест и политик мониторинга | AWatch-rus не заявляет функциональный паритет; его сильная зона - объяснимый управленческий KPI, Security Analytics и пилотная прозрачность |
| StaffCop | Employee Monitoring, Insider Risk, Workforce Analytics, DLP | Широкий набор функций мониторинга, productivity analytics, расследований и DLP-направления | AWatch-rus это более узкий и прозрачный пилотный контур, без обещания заменить StaffCop по широте функций |
| SearchInform | DLP, Risk Monitor, SIEM, TimeInformer и смежные продукты | Комплексная линейка ИБ-продуктов и мониторинга внутренних рисков | AWatch-rus не конкурирует как полноценный SIEM/DLP; он является легким аналитическим слоем для Workforce-first пилота |
| Стахановец | Контроль сотрудников, мониторинг активности, DLP-возможности | Зрелый классический контроль рабочих мест и политик мониторинга | AWatch-rus не должен заявлять функциональный паритет; его сильная зона - объяснимый управленческий KPI, Security Analytics и пилотная прозрачность |
| StaffCop | Employee Monitoring, Insider Risk, Workforce Analytics, DLP | Широкий набор функций мониторинга, productivity analytics, расследований и DLP-направления | AWatch-rus нужно показывать как более узкий и прозрачный пилотный контур, без обещания заменить StaffCop по широте функций |
| SearchInform | DLP, Risk Monitor, SIEM, TimeInformer и смежные продукты | Комплексная линейка ИБ-продуктов и мониторинга внутренних рисков | AWatch-rus не конкурирует как полноценный SIEM/DLP; он может быть легким аналитическим слоем для Workforce-first пилота |
| InfoWatch | DLP и защита от утечек конфиденциальной информации | Сильное DLP-направление, политики, интеграции и регуляторный контекст | AWatch-rus не заменяет DLP; он показывает операционную активность, объяснимые риски и материалы для внутренней проверки |
## Где AWatch-rus уместен
- Быстрый пилот для руководителя, ИБ и эксплуатации без тяжелого SIEM/DLP
внедрения в организациях,желающих иметь современное программное обеспечение такого типа.
внедрения.
- Workforce-first аналитика с объяснением KPI, coverage и confidence.
- Разделение Executive, Workforce, Security и Forensics сценариев.
- Прозрачная rule-based модель UEBA Score v1 и Risk Narrative без дорогих средств использования Искусственного Интеллекта ML/LLM.
- Прозрачная rule-based модель UEBA Score v1 и Risk Narrative без ML/LLM.
- Подготовка evidence package и Markdown-отчетов для ручной проверки.
- Честная демонстрация границ: planned, future и contract_only не выдаются за implemented.
- Честная демонстрация границ: planned, future и contract_only не выдаются за
implemented.
## Где зрелые тяжелые конкуренты обычно сильнее
## Где зрелые конкуренты обычно сильнее
- Глубокие DLP-политики, контентная фильтрация и блокировки каналов утечки.
- Масштабные SIEM/SOC-процессы и готовые интеграции ИБ.
@@ -38,10 +39,12 @@ Forensics с прозрачными rule-based объяснениями.
- Поддержка сложных enterprise-сценариев с централизованным управлением
агентами и политиками.
## Что не заявляется
## Что нельзя заявлять
- Что AWatch-rus заменяет DLP, SIEM, EDR или XDR.
- Что planned или future providers уже работают в production.
- Что pfSense readiness означает готовый ingestion, если он находится в статусе
`contract_only`.
- Что Risk Narrative является ML-прогнозом.
- Что система автоматически оценивает персонал или принимает кадровые решения.
-78
View File
@@ -1,78 +0,0 @@
# GitHub-сборка Rust-бинарников AWatch-rus
## Принятое решение
Для проекта AWatch-rus каноническая release-сборка Rust-бинарников выполняется в GitHub Actions.
Локальная сборка используется для разработки и предварительной проверки. Официальным источником release-бинарников считаются только artifacts, полученные из GitHub Actions на конкретном commit или tag.
## Toolchain
Версия Rust/Cargo фиксируется в `rust-toolchain.toml`:
```toml
[toolchain]
channel = "1.94.0"
profile = "minimal"
components = ["rustfmt", "clippy"]
```
Workflow должны запускать Cargo явно:
```bash
cargo +1.94.0 --version
rustc +1.94.0 --version
cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check
cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace --no-fail-fast
cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
```
Это исключает ситуацию, когда GitHub runner использует старый системный Cargo.
## Workflow
Основные workflow:
- `.github/workflows/rust-workspace.yml` — fmt, tests, clippy, release build всего workspace.
- `.github/workflows/rust-professionalization-check.yml` — PR smoke для изменяемых Rust-крейтов.
- `.github/workflows/rust-binary-build.yml` — сборка release-бинарников Linux x86_64 и публикация GitHub Actions artifact.
## rust-binary-build
Workflow `rust-binary-build` запускается:
- вручную через GitHub Actions -> rust-binary-build -> Run workflow;
- автоматически при push tag вида `v*`.
Внутри workflow выполняется:
1. checkout repository;
2. установка Rust/Cargo 1.94.0;
3. вывод версий `cargo` и `rustc`;
4. format check;
5. workspace tests;
6. workspace clippy;
7. workspace release build;
8. upload artifact `awatch-rus-linux-x86_64-release-binaries`.
## Правило проекта
Перед передачей бинарников на пилот, демонстрацию или релиз нужно использовать GitHub Actions artifact, а не локально собранный файл.
Минимальные признаки корректного artifact:
- workflow завершился успешно;
- в логах указан Rust/Cargo 1.94.0;
- build выполнен из нужного commit или tag;
- artifact скачан из GitHub Actions.
## Дальнейшие улучшения
Отдельными PR можно добавить:
- SHA256SUMS для каждого бинарника;
- автоматическую публикацию в GitHub Release при tag `v*`;
- Windows x86_64 build для endpoint-компонентов;
- Linux static/musl build при необходимости;
- подпись release artifacts.
-4
View File
@@ -1,4 +0,0 @@
[toolchain]
channel = "1.94.0"
profile = "minimal"
components = ["rustfmt", "clippy"]
-143
View File
@@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""Create a GitHub Actions release package from Rust release binaries."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import stat
import tarfile
from datetime import datetime, timezone
from pathlib import Path
SKIP_DIRS = {"deps", "build", "examples", "incremental"}
SKIP_SUFFIXES = {".d", ".rlib", ".rmeta"}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def is_binary(path: Path) -> bool:
if not path.is_file():
return False
if path.name in SKIP_DIRS:
return False
if path.suffix in SKIP_SUFFIXES:
return False
return bool(path.stat().st_mode & stat.S_IXUSR)
def collect(release_dir: Path) -> list[Path]:
items = [item for item in sorted(release_dir.iterdir()) if is_binary(item)]
if not items:
raise SystemExit(f"No release binaries found in {release_dir}")
return items
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def copy_release_file(src: Path, dst: Path) -> Path:
"""Copy file contents without preserving metadata that some mounts reject."""
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst)
try:
dst.chmod(src.stat().st_mode & 0o777)
except PermissionError:
# Some removable/network filesystems reject chmod/utime metadata changes.
# The package remains valid because the archive manifest/checksums are
# based on file contents, not filesystem timestamps.
pass
return dst
def write_archive_checksum(archive: Path) -> None:
write(archive.with_suffix(archive.suffix + ".sha256"), f"{sha256(archive)} {archive.name}\n")
def create_compatibility_aliases(out_dir: Path, archive: Path) -> None:
"""Create both linux-x86_64 and linux_x86_64 artifact paths."""
out_alias = Path(str(out_dir).replace("linux-x86_64", "linux_x86_64"))
if out_alias != out_dir:
if out_alias.exists():
shutil.rmtree(out_alias)
out_alias.mkdir(parents=True)
for item in out_dir.iterdir():
if item.is_file():
copy_release_file(item, out_alias / item.name)
archive_alias = Path(str(archive).replace("linux-x86_64", "linux_x86_64"))
if archive_alias != archive:
copy_release_file(archive, archive_alias)
write_archive_checksum(archive_alias)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--release-dir", type=Path, required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--archive", type=Path, required=True)
parser.add_argument("--target", default="linux-x86_64")
parser.add_argument("--commit", default="unknown")
parser.add_argument("--ref", default="unknown")
parser.add_argument("--run-id", default="unknown")
args = parser.parse_args()
release_dir = args.release_dir.resolve()
out_dir = args.out_dir.resolve()
archive = args.archive.resolve()
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
binaries = collect(release_dir)
for binary in binaries:
copy_release_file(binary, out_dir / binary.name)
names = [binary.name for binary in binaries]
write(out_dir / "BINARIES.txt", "\n".join(names) + "\n")
checksum_lines = []
manifest_binaries = []
for name in names:
packaged = out_dir / name
digest = sha256(packaged)
checksum_lines.append(f"{digest} {name}")
manifest_binaries.append(
{"name": name, "size_bytes": packaged.stat().st_size, "sha256": digest}
)
write(out_dir / "SHA256SUMS.txt", "\n".join(checksum_lines) + "\n")
manifest = {
"project": "AWatch-rus",
"target": args.target,
"commit": args.commit,
"ref": args.ref,
"run_id": args.run_id,
"build_time_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"binaries": manifest_binaries,
}
write(out_dir / "BUILD_MANIFEST.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
archive.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive, "w:gz") as tar:
tar.add(out_dir, arcname=out_dir.name)
write_archive_checksum(archive)
create_compatibility_aliases(out_dir, archive)
if __name__ == "__main__":
main()