Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58535fa5c6 | ||
|
|
7f1abdb9a7 | ||
|
|
5ca325034f | ||
|
|
5312da175d | ||
|
|
fa1ddf64b4 | ||
|
|
643d5d2d69 | ||
|
|
b629879958 | ||
|
|
78a560dc3e | ||
|
|
803c3169d7 | ||
|
|
03f10435ce | ||
|
|
2f8193e7b3 | ||
|
|
e76fa5a5c2 | ||
|
|
68c0fd1a37 | ||
|
|
d19b3d478f | ||
|
|
0cd6e4f856 | ||
|
|
acf767360f | ||
|
|
9ad5b2fc34 | ||
|
|
8596cd057b | ||
|
|
42b0fdb718 | ||
|
|
452354a8e3 | ||
|
|
5fb37bfbaf | ||
|
|
cdd8c292db | ||
|
|
9cdad90e3a | ||
|
|
da1a21ee47 | ||
|
|
1fdc13aa73 | ||
|
|
374b320ba4 | ||
|
|
4dbb39b8ee | ||
|
|
26de69b6e2 | ||
|
|
f3d5a8161d | ||
|
|
ffaae2b459 | ||
|
|
8b8dec0754 | ||
|
|
cd6d8b5119 | ||
|
|
a303a0d7f5 | ||
|
|
f711e6babb | ||
|
|
00fb35096d | ||
|
|
d33ce4d64c | ||
|
|
61ad96bc1a | ||
|
|
98505ed7b4 | ||
|
|
a961501b6d | ||
|
|
0a54751b7e | ||
|
|
313237f167 | ||
|
|
de1a5c2893 | ||
|
|
e93368fa84 | ||
|
|
067a257b6a | ||
|
|
07b754090a | ||
|
|
5d3b3e96bb | ||
|
|
3742fc63fe | ||
|
|
48121aec0d | ||
|
|
2618fb9e45 | ||
|
|
a747e4c1bb | ||
|
|
51eed69fe4 | ||
|
|
8caedd11d5 |
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
@@ -5,6 +5,7 @@ on:
|
|||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust-workspace:
|
rust-workspace:
|
||||||
@@ -13,19 +14,22 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust 1.85
|
- name: Install pinned Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@1.85.0
|
run: |
|
||||||
with:
|
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
|
||||||
components: rustfmt, clippy
|
rustup override set 1.94.0
|
||||||
|
rustup show active-toolchain
|
||||||
|
cargo +1.94.0 --version
|
||||||
|
rustc +1.94.0 --version
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check
|
run: cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --manifest-path adk-rust/Cargo.toml --workspace
|
run: cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
|
run: cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Release build
|
- name: Release build
|
||||||
run: cargo build --manifest-path adk-rust/Cargo.toml --workspace --release
|
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
|
#![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 std::fmt;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::{DateTime, SecondsFormat, Utc};
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "UPPERCASE")]
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
pub enum StatusLevel {
|
pub enum StatusLevel {
|
||||||
|
/// Component is healthy and the check passed.
|
||||||
Ok,
|
Ok,
|
||||||
|
/// Component works, but a risk or degraded condition needs attention.
|
||||||
Warn,
|
Warn,
|
||||||
|
/// Component check failed or a required dependency is unavailable.
|
||||||
Fail,
|
Fail,
|
||||||
|
/// Component did not provide enough information for a reliable status.
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatusLevel {
|
impl StatusLevel {
|
||||||
|
/// Return the stable uppercase representation used in human and JSON output.
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Ok => "OK",
|
Self::Ok => "OK",
|
||||||
@@ -23,6 +41,10 @@ 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 {
|
pub fn exit_code(self) -> i32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Ok => exit_codes::OK,
|
Self::Ok => exit_codes::OK,
|
||||||
@@ -48,23 +70,39 @@ 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 {
|
pub mod exit_codes {
|
||||||
|
/// Successful execution.
|
||||||
pub const OK: i32 = 0;
|
pub const OK: i32 = 0;
|
||||||
|
/// Unexpected runtime or IO error.
|
||||||
pub const ERROR: i32 = 1;
|
pub const ERROR: i32 = 1;
|
||||||
|
/// Health/check policy failed or returned a degraded status.
|
||||||
pub const CHECK_FAILED: i32 = 2;
|
pub const CHECK_FAILED: i32 = 2;
|
||||||
|
/// A safety policy denied a requested action.
|
||||||
pub const POLICY_DENIED: i32 = 3;
|
pub const POLICY_DENIED: i32 = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the current UTC timestamp in compact RFC3339/Zulu format.
|
||||||
pub fn now_utc_rfc3339() -> String {
|
pub fn now_utc_rfc3339() -> String {
|
||||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
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>> {
|
pub fn parse_utc_rfc3339(value: &str) -> Result<DateTime<Utc>> {
|
||||||
DateTime::parse_from_rfc3339(value)
|
DateTime::parse_from_rfc3339(value)
|
||||||
.with_context(|| format!("invalid RFC3339 timestamp: {value}"))
|
.with_context(|| format!("invalid RFC3339 timestamp: {value}"))
|
||||||
.map(|ts| ts.with_timezone(&Utc))
|
.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 {
|
pub mod runtime_guard {
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
@@ -82,6 +120,11 @@ pub mod runtime_guard {
|
|||||||
"PASSWORD",
|
"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 {
|
pub fn is_runtime_placeholder(value: &str) -> bool {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -106,6 +149,7 @@ pub mod runtime_guard {
|
|||||||
|| (normalized.starts_with('<') && normalized.ends_with('>'))
|
|| (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 {
|
pub fn is_secret_placeholder(value: &str) -> bool {
|
||||||
is_runtime_placeholder(value)
|
is_runtime_placeholder(value)
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -114,6 +158,10 @@ 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<()> {
|
pub fn ensure_runtime_value(name: &str, value: &str, context: &str) -> Result<()> {
|
||||||
if is_runtime_placeholder(value) {
|
if is_runtime_placeholder(value) {
|
||||||
bail!("{name} contains an empty/example/TEST-NET value while {context}");
|
bail!("{name} contains an empty/example/TEST-NET value while {context}");
|
||||||
@@ -121,6 +169,7 @@ pub mod runtime_guard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure a required secret is not empty or an obvious placeholder.
|
||||||
pub fn ensure_secret_value(name: &str, value: &str, context: &str) -> Result<()> {
|
pub fn ensure_secret_value(name: &str, value: &str, context: &str) -> Result<()> {
|
||||||
if is_secret_placeholder(value) {
|
if is_secret_placeholder(value) {
|
||||||
bail!("{name} contains an empty/example secret value while {context}");
|
bail!("{name} contains an empty/example secret value while {context}");
|
||||||
@@ -128,6 +177,7 @@ pub mod runtime_guard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure an iterator of runtime values is non-empty and production-safe.
|
||||||
pub fn ensure_runtime_values<'a>(
|
pub fn ensure_runtime_values<'a>(
|
||||||
name: &str,
|
name: &str,
|
||||||
values: impl IntoIterator<Item = &'a String>,
|
values: impl IntoIterator<Item = &'a String>,
|
||||||
@@ -144,6 +194,12 @@ pub mod runtime_guard {
|
|||||||
Ok(())
|
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(
|
pub fn ensure_influx_runtime_config(
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ tiny_http.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
comparison_chain = "allow"
|
||||||
|
search_is_some = "allow"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! 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"}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ use std::fs::{self, File, OpenOptions};
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -23,33 +22,48 @@ use serde_json::{Value, json};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||||
|
|
||||||
|
mod api_contracts;
|
||||||
|
mod command_runner;
|
||||||
mod executive_actions;
|
mod executive_actions;
|
||||||
|
mod path_query;
|
||||||
|
mod portal_roles;
|
||||||
mod production;
|
mod production;
|
||||||
|
mod readiness_api;
|
||||||
mod risk_narrative;
|
mod risk_narrative;
|
||||||
|
mod role_access;
|
||||||
|
mod snapshot_cache;
|
||||||
|
mod static_assets;
|
||||||
mod workforce_kpi_explain;
|
mod workforce_kpi_explain;
|
||||||
|
|
||||||
|
use api_contracts::api_contract_summary;
|
||||||
use executive_actions::{
|
use executive_actions::{
|
||||||
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
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::{
|
use production::{
|
||||||
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
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,
|
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
||||||
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
|
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
|
||||||
validate_api_query_limits, validate_portal_config,
|
validate_api_query_limits, validate_portal_config,
|
||||||
};
|
};
|
||||||
|
use readiness_api::{readiness_bundle, readiness_latest, readiness_verify};
|
||||||
use risk_narrative::{
|
use risk_narrative::{
|
||||||
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
|
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};
|
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 UEBA_BASELINE_MIN_SAMPLES: usize = 3;
|
||||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
|
|
||||||
const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению";
|
const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению";
|
||||||
const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения";
|
const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения";
|
||||||
const PORTAL_SCHEMA_VERSION: &str = "pilot-v1";
|
const PORTAL_SCHEMA_VERSION: &str = "pilot-v1";
|
||||||
@@ -73,84 +87,6 @@ unsafe extern "C" {
|
|||||||
fn kill(pid: i32, sig: i32) -> i32;
|
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)]
|
#[derive(Clone, Debug, Parser)]
|
||||||
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
|
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
@@ -1383,11 +1319,11 @@ fn run() -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
|
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
|
||||||
let snapshot_cache: SnapshotCache = Arc::new(Mutex::new(None));
|
let snapshot_cache: SnapshotCache = new_snapshot_cache();
|
||||||
eprintln!("detmir-portal listening on http://{}", args.bind);
|
eprintln!("detmir-portal listening on http://{}", args.bind);
|
||||||
for request in server.incoming_requests() {
|
for request in server.incoming_requests() {
|
||||||
let args = args.clone();
|
let args = args.clone();
|
||||||
let snapshot_cache = Arc::clone(&snapshot_cache);
|
let snapshot_cache = clone_snapshot_cache(&snapshot_cache);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let result = if args.evidence_only {
|
let result = if args.evidence_only {
|
||||||
handle_evidence_only_request(request, &args)
|
handle_evidence_only_request(request, &args)
|
||||||
@@ -1721,296 +1657,6 @@ 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 {
|
fn build_snapshot(args: &Cli) -> Snapshot {
|
||||||
let timeout = Duration::from_secs(args.timeout_seconds);
|
let timeout = Duration::from_secs(args.timeout_seconds);
|
||||||
let security_events_config = SecurityEventsConfig {
|
let security_events_config = SecurityEventsConfig {
|
||||||
@@ -10891,7 +10537,7 @@ fn header(name: &str, value: &str) -> Result<Header> {
|
|||||||
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> String {
|
pub(crate) fn now() -> String {
|
||||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! 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,3 +1,8 @@
|
|||||||
|
//! 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 serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::now;
|
use crate::now;
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! 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 std::collections::BTreeSet;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
@@ -30,6 +37,10 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
|
|||||||
if port == 0 {
|
if port == 0 {
|
||||||
return Err(anyhow!("invalid config port: expected 1..65535"));
|
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 {
|
if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
|
"invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
|
||||||
@@ -66,6 +77,10 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
|
|||||||
"invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
|
"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) {
|
if !is_safe_environment_name(&args.environment) {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
|
"invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! 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 serde_json::{Value, json};
|
||||||
use tiny_http::StatusCode;
|
use tiny_http::StatusCode;
|
||||||
|
|
||||||
@@ -21,6 +28,10 @@ pub(crate) fn log_http_request(
|
|||||||
} else {
|
} else {
|
||||||
Value::Null
|
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!(
|
eprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! 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::collections::BTreeMap;
|
||||||
use std::fmt::Write as FmtWrite;
|
use std::fmt::Write as FmtWrite;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
@@ -179,5 +185,7 @@ pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prom_escape(value: &str) -> 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('"', "\\\"")
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
//! 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 health;
|
||||||
pub(crate) mod limits;
|
pub(crate) mod limits;
|
||||||
pub(crate) mod logging;
|
pub(crate) mod logging;
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! 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 std::path::Path;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
//! 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::cell::RefCell;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
@@ -73,7 +78,7 @@ fn request_header(request: &Request, name: &str) -> Option<String> {
|
|||||||
request
|
request
|
||||||
.headers()
|
.headers()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|header| header.field.to_string().eq_ignore_ascii_case(name))
|
.find(|header| header.field.as_str().as_str().eq_ignore_ascii_case(name))
|
||||||
.map(|header| header.value.as_str().to_string())
|
.map(|header| header.value.as_str().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
//! 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 serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{Cli, PORTAL_SCHEMA_VERSION};
|
use crate::{Cli, PORTAL_SCHEMA_VERSION};
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! 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()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! 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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! 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");
|
||||||
+542
-297
@@ -1,422 +1,667 @@
|
|||||||
# Полная инструкция по развёртыванию и поддержке ActivityWatch-Russian
|
Полная инструкция по развёртыванию и поддержке AWatch-rus
|
||||||
|
|
||||||
Документ описывает полный цикл: Proxmox/LXC сервер, установка ActivityWatch Server, RU Web UI patch, развёртывание Windows-клиентов в другом AD-домене, валидация, сопровождение и rollback.
|
Статус документа
|
||||||
|
|
||||||
---
|
Этот документ описывает актуальный **Rust-fiWindows/PowerShell deployment flow больше не считается основным способом развёртывания, патчинга или эксплуатации. Если в репозитории остаются старые ".ps1"-файлы, они рассматриваются как legacy/history или как будущий provider-слой, но не как production runtime.
|
||||||
|
|
||||||
## 0) Структура проекта (полные пути)
|
0. Назначение
|
||||||
|
|
||||||
- `<PROJECT_ROOT>/private-config/deploy.env`
|
AWatch-rus — программный комплекс операционного контроля, технического аудита, оценки трудоотдачи сотрудников и мониторинга корпоративной ИТ-инфраструктуры на базе:
|
||||||
- `<PROJECT_ROOT>/proxmox/create-ct.sh`
|
|
||||||
- `<PROJECT_ROOT>/proxmox/push-aw-artifacts.sh`
|
|
||||||
- `<PROJECT_ROOT>/aw-server/install_aw_server.sh`
|
|
||||||
- `<PROJECT_ROOT>/aw-server/apply_webui_ru_patch.sh`
|
|
||||||
- `<PROJECT_ROOT>/windows/deploy-single-user.ps1`
|
|
||||||
- `<PROJECT_ROOT>/windows/deploy-domain-users.ps1`
|
|
||||||
- `<PROJECT_ROOT>/windows/deploy-ensemble.ps1`
|
|
||||||
- `<PROJECT_ROOT>/windows/hardening-recovery.ps1`
|
|
||||||
- `<PROJECT_ROOT>/windows/validate-deployment.ps1`
|
|
||||||
- `<PROJECT_ROOT>/windows/browser-domains-native-collector.ps1`
|
|
||||||
- `<PROJECT_ROOT>/windows/dlp-endpoint-signals-collector.ps1`
|
|
||||||
- `<PROJECT_ROOT>/ansible/deploy_aw_server.yml`
|
|
||||||
- `<PROJECT_ROOT>/ansible/provision_proxmox_ct_and_deploy_aw.yml`
|
|
||||||
- `<PROJECT_ROOT>/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml`
|
|
||||||
- `<PROJECT_ROOT>/ansible/deploy_aw_windows.yml`
|
|
||||||
|
|
||||||
---
|
- Rust backend/runtime;
|
||||||
|
- Rust Agent;
|
||||||
|
- Rust server-rendered HTML + HTMX-compatible JSON API;
|
||||||
|
- Grafana/Prometheus-витрин;
|
||||||
|
- модулей Workforce, Security и Forensics;
|
||||||
|
- evidence/reporting tooling;
|
||||||
|
- ActivityWatch-compatible источников данных, где это применимо.
|
||||||
|
|
||||||
## 1) Подготовка
|
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ. DLP, evidence, UEBA и расследовательские функции используются как внутренние аналитические и операционные модули.
|
||||||
|
|
||||||
### 1.1 Требования
|
1. Актуальная архитектура
|
||||||
|
|
||||||
- Proxmox VE 8/9, доступ root (или sudo с правами на `pct`).
|
1.1 Основной runtime
|
||||||
- Шаблон Debian 12 LXC на хосте Proxmox.
|
|
||||||
- Windows хост(ы) с PowerShell 5.1+ и правами локального администратора.
|
|
||||||
- Сетевой доступ Windows-клиентов до ActivityWatch Server (`5600/tcp`).
|
|
||||||
|
|
||||||
### 1.2 Подготовка единого файла секретов
|
Основной production runtime AWatch-rus — Rust-first:
|
||||||
|
|
||||||
Скопируйте шаблон:
|
- backend/runtime — Rust;
|
||||||
|
- agent — Rust;
|
||||||
|
- portal — Rust server-rendered HTML + HTMX-compatible JSON API;
|
||||||
|
- operational status/check — Rust;
|
||||||
|
- DLP server-side helpers — Rust;
|
||||||
|
- worktime helpers/exporters/prewarm — Rust;
|
||||||
|
- SLO/health/readiness helpers — Rust;
|
||||||
|
- evidence/install-kit tooling — Rust;
|
||||||
|
- auto-heal helpers — Rust, только в безопасном режиме.
|
||||||
|
|
||||||
```bash
|
1.2 Что не является основным runtime
|
||||||
cp <PROJECT_ROOT>/private-config/deploy.env.example \
|
|
||||||
<PROJECT_ROOT>/private-config/deploy.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Заполните в файле `<PROJECT_ROOT>/private-config/deploy.env`:
|
Не считать основным production deployment flow:
|
||||||
|
|
||||||
- все `CT_*` параметры контейнера;
|
- PowerShell deployment;
|
||||||
- все `AW_SERVER_*` параметры сервера;
|
- старые Windows ".ps1" rollout scripts;
|
||||||
- `CT_PASSWORD` (реальный пароль).
|
- ручное исправление production-файлов без release/backup;
|
||||||
|
- прямое редактирование Web UI в "/opt" без воспроизводимого патча;
|
||||||
|
- Python/shell как основной operational runtime, если для компонента уже есть Rust-аналог.
|
||||||
|
|
||||||
Важно: этот файл подхватывается автоматически скриптами Proxmox.
|
Python, shell, Ansible или PowerShell могут оставаться в проекте только как:
|
||||||
|
|
||||||
---
|
- legacy compatibility;
|
||||||
|
- вспомогательные dev/test tools;
|
||||||
|
- миграционные сценарии;
|
||||||
|
- будущие provider-слои;
|
||||||
|
- Telegram/OCR/AI/ETL/MCP helpers, если они явно не входят в Rust-first core.
|
||||||
|
|
||||||
## 2) Развёртывание сервера в Proxmox
|
2. Типовые роли узлов
|
||||||
|
|
||||||
### 2.0 Ansible full-stack (создание CT + установка AW)
|
2.1 Server node
|
||||||
|
|
||||||
Подготовьте:
|
Серверный узел содержит:
|
||||||
|
|
||||||
- `<PROJECT_ROOT>/ansible/inventory.ini`
|
- AWatch-rus backend/runtime;
|
||||||
- `<PROJECT_ROOT>/ansible/group_vars/all.yml`
|
- portal;
|
||||||
- `<PROJECT_ROOT>/ansible/group_vars/proxmox.yml`
|
- API;
|
||||||
|
- exporters;
|
||||||
|
- health/readiness/status tooling;
|
||||||
|
- systemd units/timers;
|
||||||
|
- Grafana/Prometheus integration;
|
||||||
|
- evidence/reporting storage.
|
||||||
|
|
||||||
Запуск:
|
2.2 Agent node
|
||||||
|
|
||||||
```bash
|
Agent node содержит:
|
||||||
cd <PROJECT_ROOT>/ansible
|
|
||||||
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот сценарий полностью закрывает:
|
- Rust Agent;
|
||||||
|
- локальную конфигурацию агента;
|
||||||
|
- systemd service или другой штатный supervisor;
|
||||||
|
- локальные логи;
|
||||||
|
- буфер/очередь, если предусмотрено конфигурацией;
|
||||||
|
- сетевой доступ до backend/API.
|
||||||
|
|
||||||
- создание CT в Proxmox;
|
2.3 Monitoring node
|
||||||
- bootstrap пакетов в CT;
|
|
||||||
- установку ActivityWatch Server;
|
|
||||||
- применение RU Web UI patch;
|
|
||||||
- проверку API.
|
|
||||||
|
|
||||||
Для массового режима (несколько CT):
|
Monitoring node может содержать:
|
||||||
|
|
||||||
```bash
|
- Prometheus;
|
||||||
cd <PROJECT_ROOT>/ansible
|
- Grafana;
|
||||||
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
|
- dashboards;
|
||||||
```
|
- alerting rules;
|
||||||
|
- external logs/metrics storage.
|
||||||
|
|
||||||
### 2.1 Создать LXC контейнер
|
Monitoring node может совпадать с server node в пилотной установке.
|
||||||
|
|
||||||
На узле Proxmox:
|
3. Требования
|
||||||
|
|
||||||
```bash
|
3.1 Базовые требования
|
||||||
cd <PROJECT_ROOT>
|
|
||||||
<PROJECT_ROOT>/proxmox/create-ct.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
По умолчанию читается:
|
- Linux-сервер или LXC/VM.
|
||||||
|
- Доступ администратора к systemd.
|
||||||
|
- Rust toolchain для сборочного узла.
|
||||||
|
- Сетевой доступ между agent node и server node.
|
||||||
|
- Закрытый доступ к API и порталу через VPN, reverse proxy или внутренний контур.
|
||||||
|
- Backup/snapshot перед любым production patch.
|
||||||
|
|
||||||
- `<PROJECT_ROOT>/private-config/deploy.env`
|
3.2 Рекомендуемый production-подход
|
||||||
|
|
||||||
При необходимости можно передать другой путь:
|
Для production не собирать проект прямо на боевом сервере, если есть отдельный build host.
|
||||||
|
|
||||||
```bash
|
Рекомендуемый поток:
|
||||||
<PROJECT_ROOT>/proxmox/create-ct.sh /absolute/path/to/deploy.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Загрузить bootstrap-артефакты и env внутрь CT
|
git checkout нужного commit/tag
|
||||||
|
→ cargo fmt / clippy / test / build
|
||||||
|
→ упаковка release artifacts
|
||||||
|
→ перенос artifacts на сервер
|
||||||
|
→ backup/snapshot
|
||||||
|
→ остановка/перезапуск нужных services
|
||||||
|
→ smoke tests
|
||||||
|
→ фиксация версии
|
||||||
|
|
||||||
```bash
|
4. Основные пути
|
||||||
cd <PROJECT_ROOT>
|
|
||||||
<PROJECT_ROOT>/proxmox/push-aw-artifacts.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт загружает в CT:
|
Рекомендуемая структура на сервере:
|
||||||
|
|
||||||
- `<CT_BOOTSTRAP_DIR>/install_aw_server.sh`
|
/opt/awatch-rus/
|
||||||
- `<CT_BOOTSTRAP_DIR>/apply_webui_ru_patch.sh`
|
bin/
|
||||||
- `<CT_BOOTSTRAP_DIR>/activitywatch-server.service`
|
etc/
|
||||||
- `<CT_BOOTSTRAP_DIR>/aw-ru-patch.js`
|
portal/
|
||||||
- `<CT_BOOTSTRAP_DIR>/aw-sw-cleanup.js`
|
releases/
|
||||||
- `/etc/activitywatch/aw-server.env` (из `AW_SERVER_*`)
|
evidence/
|
||||||
|
reports/
|
||||||
|
logs/
|
||||||
|
|
||||||
### 2.3 Установить ActivityWatch Server внутри CT
|
/etc/awatch-rus/
|
||||||
|
awatch-rus.env
|
||||||
|
agent.env
|
||||||
|
portal.env
|
||||||
|
|
||||||
```bash
|
/var/lib/awatch-rus/
|
||||||
pct enter <CT_ID>
|
data/
|
||||||
bash <CT_BOOTSTRAP_DIR>/install_aw_server.sh
|
state/
|
||||||
```
|
cache/
|
||||||
|
evidence/
|
||||||
|
reports/
|
||||||
|
|
||||||
### 2.4 Применить RU patch Web UI
|
/var/log/awatch-rus/
|
||||||
|
backend.log
|
||||||
|
agent.log
|
||||||
|
portal.log
|
||||||
|
exporter.log
|
||||||
|
|
||||||
```bash
|
Рекомендуемые runtime binaries:
|
||||||
bash <CT_BOOTSTRAP_DIR>/apply_webui_ru_patch.sh
|
|
||||||
systemctl restart activitywatch-server.service
|
|
||||||
```
|
|
||||||
|
|
||||||
После применения патча доступны:
|
/usr/local/bin/detmir-status
|
||||||
|
/usr/local/bin/detmir-check
|
||||||
|
/usr/local/bin/detmir-dlp
|
||||||
|
/usr/local/bin/detmir-auto
|
||||||
|
/usr/local/bin/detmir-heal-safe
|
||||||
|
/usr/local/bin/aw-rus-healthd
|
||||||
|
|
||||||
- верхнее меню `DLP` в Web UI;
|
Имена конкретных бинарников должны соответствовать текущему "Cargo.toml" и фактически собранным artifacts. Если имя binary изменено, документация и systemd unit должны обновляться в том же commit.
|
||||||
- DLP-страница bucket `aw-dlp-endpoint-signals_<HOST>`;
|
|
||||||
- встроенный центр `DLP review и правила`;
|
|
||||||
- служебные buckets `aw-dlp-review_<HOST>` и `aw-dlp-rules_<HOST>`.
|
|
||||||
|
|
||||||
### 2.5 Проверка сервера
|
5. Конфигурация
|
||||||
|
|
||||||
В CT:
|
5.1 Общие правила
|
||||||
|
|
||||||
```bash
|
- Не хранить production secrets в публичном репозитории.
|
||||||
systemctl status activitywatch-server.service --no-pager
|
- Не коммитить реальные hostnames, IP, логины, ФИО, токены, пароли.
|
||||||
curl -fsS http://127.0.0.1:5600/api/0/info
|
- Для production использовать "/etc/awatch-rus/*.env".
|
||||||
ss -ltnp | grep 5600
|
- Для demo использовать только обезличенные fixtures.
|
||||||
grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
|
- Все параметры, влияющие на runtime, должны быть описаны в документации.
|
||||||
```
|
|
||||||
|
|
||||||
Ожидается:
|
5.2 Пример server env
|
||||||
|
|
||||||
- сервис `active (running)`;
|
AWATCH_ENV=production
|
||||||
- API отвечает JSON;
|
AWATCH_BIND_ADDR=127.0.0.1
|
||||||
- порт 5600 слушается;
|
AWATCH_PORT=5600
|
||||||
- в `index.html` присутствуют оба скрипта.
|
AWATCH_DATA_DIR=/var/lib/awatch-rus/data
|
||||||
|
AWATCH_LOG_DIR=/var/log/awatch-rus
|
||||||
|
AWATCH_EVIDENCE_DIR=/var/lib/awatch-rus/evidence
|
||||||
|
AWATCH_REPORTS_DIR=/var/lib/awatch-rus/reports
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
Дополнительно после первого входа в Web UI:
|
5.3 Пример agent env
|
||||||
|
|
||||||
- `#/home` должен показывать один корректный пункт `DLP`;
|
AWATCH_AGENT_ENV=production
|
||||||
- `#/buckets/aw-dlp-endpoint-signals_<HOST>` должен открываться без ошибок;
|
AWATCH_SERVER_URL=https://awatch.example.local
|
||||||
- сохранение review/rule должно создавать buckets `aw-dlp-review_<HOST>` и `aw-dlp-rules_<HOST>`.
|
AWATCH_AGENT_HOST_ID=HOSTNAME_OR_NODE_ID
|
||||||
|
AWATCH_AGENT_DATA_DIR=/var/lib/awatch-rus/agent
|
||||||
|
AWATCH_AGENT_LOG_DIR=/var/log/awatch-rus
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
---
|
6. Сборка
|
||||||
|
|
||||||
## 3) Развёртывание Windows-клиентов (другой AD-домен)
|
6.1 Проверки перед сборкой
|
||||||
|
|
||||||
### 3.1 Подготовка на Windows-хосте
|
На build host:
|
||||||
|
|
||||||
Скопируйте каталог:
|
cd /path/to/AWatch-rus
|
||||||
|
|
||||||
- `<PROJECT_ROOT>/windows`
|
git status --short
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
например в:
|
Если в репозитории есть проектные quality gates, выполнить их обязательно:
|
||||||
|
|
||||||
- `C:\Program Files\AWatch-rus\windows`
|
bash scripts/check_private_config_guard.sh
|
||||||
|
bash scripts/quality-gate.sh
|
||||||
|
|
||||||
Откройте **elevated PowerShell**:
|
Если какой-то скрипт отсутствует в текущей ветке, не создавать фиктивную замену. Зафиксировать это в release notes.
|
||||||
|
|
||||||
```powershell
|
6.2 Release build
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Массовое доменное развёртывание (рекомендуется)
|
cargo build --release --workspace
|
||||||
|
|
||||||
Если текущий production ещё работает в старых каталогах
|
Проверить artifacts:
|
||||||
`C:\Program Files\ActivityWatch-Phase2` и `C:\ProgramData\ActivityWatch-Phase2`,
|
|
||||||
сначала выполните безопасную миграцию:
|
|
||||||
|
|
||||||
```powershell
|
find target/release -maxdepth 1 -type f -executable -print
|
||||||
C:\Program Files\AWatch-rus\windows\migrate-awatch-rus-paths.ps1 -WhatIf
|
|
||||||
C:\Program Files\AWatch-rus\windows\migrate-awatch-rus-paths.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт остановит `ActivityWatch Recovery`/`ActivityWatch Launch *`, создаст backup в
|
6.3 Упаковка artifacts
|
||||||
`C:\ProgramData\AWatch-rus\migration-backups\...`, перенесёт файлы в единые пути,
|
|
||||||
пересоздаст `deployment-config.json`/scheduled tasks и запустит validation.
|
|
||||||
|
|
||||||
Пример со списком пользователей:
|
Рекомендуемый вариант:
|
||||||
|
|
||||||
```powershell
|
mkdir -p dist/awatch-rus-release/bin
|
||||||
C:\Program Files\AWatch-rus\windows\deploy-domain-users.ps1 `
|
cp target/release/detmir-status dist/awatch-rus-release/bin/ 2>/dev/null || true
|
||||||
-ServerHost aw.example.local `
|
cp target/release/detmir-check dist/awatch-rus-release/bin/ 2>/dev/null || true
|
||||||
-ServerPort 5600 `
|
cp target/release/detmir-dlp dist/awatch-rus-release/bin/ 2>/dev/null || true
|
||||||
-Domain CONTOSO `
|
cp target/release/detmir-auto dist/awatch-rus-release/bin/ 2>/dev/null || true
|
||||||
-UserListPath C:\Deploy\aw-users.txt `
|
cp target/release/detmir-heal-safe dist/awatch-rus-release/bin/ 2>/dev/null || true
|
||||||
-CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json
|
cp target/release/aw-rus-healthd dist/awatch-rus-release/bin/ 2>/dev/null || true
|
||||||
```
|
|
||||||
|
|
||||||
Поддерживаемые варианты:
|
tar -C dist -czf awatch-rus-release.tar.gz awatch-rus-release
|
||||||
|
sha256sum awatch-rus-release.tar.gz > awatch-rus-release.tar.gz.sha256
|
||||||
|
|
||||||
- `-Users user01,user02`
|
Не использовать "cp ... || true" в CI без последующей проверки обязательных binaries. Для ручного production release список обязательных binaries должен быть проверен явно.
|
||||||
- `-Users 'CONTOSO\user01','CONTOSO\user02'`
|
|
||||||
- `-UserListPath <txt|csv>`
|
|
||||||
|
|
||||||
### 3.2.1 Ensemble orchestration (рекомендуется для production)
|
7. Первичное развёртывание server node
|
||||||
|
|
||||||
```powershell
|
7.1 Создание каталогов
|
||||||
C:\Program Files\AWatch-rus\windows\deploy-ensemble.ps1 `
|
|
||||||
-ServerHost aw.example.local `
|
|
||||||
-ServerPort 5600 `
|
|
||||||
-Domain CONTOSO `
|
|
||||||
-Users user1,user2,user3,user4,user5 `
|
|
||||||
-ValidateAfterDeploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Отчёт сохраняется в:
|
sudo mkdir -p /opt/awatch-rus/bin
|
||||||
|
sudo mkdir -p /opt/awatch-rus/releases
|
||||||
|
sudo mkdir -p /etc/awatch-rus
|
||||||
|
sudo mkdir -p /var/lib/awatch-rus/data
|
||||||
|
sudo mkdir -p /var/lib/awatch-rus/state
|
||||||
|
sudo mkdir -p /var/lib/awatch-rus/evidence
|
||||||
|
sudo mkdir -p /var/lib/awatch-rus/reports
|
||||||
|
sudo mkdir -p /var/log/awatch-rus
|
||||||
|
|
||||||
- `C:\ProgramData\AWatch-rus\ensemble-report-YYYYMMDD-HHMMSS.json`
|
7.2 Установка binaries
|
||||||
|
|
||||||
### 3.3 Single-user развёртывание
|
sudo install -m 0755 dist/awatch-rus-release/bin/* /opt/awatch-rus/bin/
|
||||||
|
|
||||||
```powershell
|
Создать symlink для удобства:
|
||||||
C:\Program Files\AWatch-rus\windows\deploy-single-user.ps1 `
|
|
||||||
-ServerHost aw.example.local `
|
|
||||||
-ServerPort 5600 `
|
|
||||||
-TargetUser 'CONTOSO\user01' `
|
|
||||||
-CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 Recovery / hardening
|
sudo ln -sf /opt/awatch-rus/bin/detmir-status /usr/local/bin/detmir-status
|
||||||
|
sudo ln -sf /opt/awatch-rus/bin/detmir-check /usr/local/bin/detmir-check
|
||||||
|
sudo ln -sf /opt/awatch-rus/bin/detmir-dlp /usr/local/bin/detmir-dlp
|
||||||
|
|
||||||
```powershell
|
Если binary отсутствует, не создавать пустой symlink. Сначала проверить фактический состав release artifact.
|
||||||
C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1 `
|
|
||||||
-ConfigPath C:\ProgramData\AWatch-rus\deployment-config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 Валидация deployment-а (PowerShell report)
|
7.3 Конфигурация
|
||||||
|
|
||||||
```powershell
|
sudo install -m 0640 awatch-rus.env /etc/awatch-rus/awatch-rus.env
|
||||||
$report = C:\Program Files\AWatch-rus\windows\validate-deployment.ps1 `
|
|
||||||
-ConfigPath C:\ProgramData\AWatch-rus\deployment-config.json
|
|
||||||
$report | ConvertTo-Json -Depth 12
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
Проверить права:
|
||||||
|
|
||||||
## 4) Что должно появиться на Windows после установки
|
sudo chown root:root /etc/awatch-rus/awatch-rus.env
|
||||||
|
sudo chmod 0640 /etc/awatch-rus/awatch-rus.env
|
||||||
|
|
||||||
- `C:\Program Files\AWatch-rus\bin`
|
8. systemd units
|
||||||
- `C:\ProgramData\AWatch-rus\deployment-config.json`
|
|
||||||
- `C:\ProgramData\AWatch-rus\launch-watchers.ps1`
|
|
||||||
- `C:\ProgramData\AWatch-rus\recovery-loop.ps1`
|
|
||||||
- `C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1`
|
|
||||||
- `C:\ProgramData\AWatch-rus\web-category-rules.json`
|
|
||||||
- `C:\ProgramData\AWatch-rus\logs\`
|
|
||||||
|
|
||||||
Задачи планировщика:
|
8.1 Пример backend service
|
||||||
|
|
||||||
- `ActivityWatch Launch [<user>]` (per-user, при логоне)
|
[Unit]
|
||||||
- `ActivityWatch Recovery` (system-level recovery)
|
Description=AWatch-rus backend/runtime
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
---
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=/etc/awatch-rus/awatch-rus.env
|
||||||
|
ExecStart=/opt/awatch-rus/bin/awatch-rus-backend
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
WorkingDirectory=/opt/awatch-rus
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/awatch-rus /var/log/awatch-rus
|
||||||
|
|
||||||
## 5) Полная валидация потока данных
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
### 5.1 На Windows-хосте
|
Если фактическое имя backend binary отличается, заменить "awatch-rus-backend" на актуальное имя из release artifact.
|
||||||
|
|
||||||
Проверить процессы:
|
8.2 Пример health service
|
||||||
|
|
||||||
```powershell
|
[Unit]
|
||||||
Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue
|
Description=AWatch-rus health daemon
|
||||||
Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*browser-domains-native-collector.ps1*' } | select ProcessId,SessionId,CommandLine
|
After=network-online.target
|
||||||
```
|
Wants=network-online.target
|
||||||
|
|
||||||
Проверить задачи:
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=/etc/awatch-rus/awatch-rus.env
|
||||||
|
ExecStart=/opt/awatch-rus/bin/aw-rus-healthd
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
WorkingDirectory=/opt/awatch-rus
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/awatch-rus /var/log/awatch-rus
|
||||||
|
|
||||||
```powershell
|
[Install]
|
||||||
Get-ScheduledTask | ? { $_.TaskName -like 'ActivityWatch*' } | select TaskName,State
|
WantedBy=multi-user.target
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 На сервере ActivityWatch API
|
8.3 Применение unit files
|
||||||
|
|
||||||
```bash
|
sudo systemctl daemon-reload
|
||||||
curl -sS http://127.0.0.1:5600/api/0/buckets | jq 'keys'
|
sudo systemctl enable --now awatch-rus-backend.service
|
||||||
```
|
sudo systemctl enable --now aw-rus-healthd.service
|
||||||
|
|
||||||
Ожидаемые bucket'ы:
|
Если конкретный unit не используется в текущей инсталляции, не создавать фиктивный сервис. Документировать фактический набор services.
|
||||||
|
|
||||||
- `aw-watcher-afk_<HOST>`
|
9. Развёртывание Rust Agent
|
||||||
- `aw-watcher-window_<HOST>`
|
|
||||||
- `aw-watcher-web-<browser>_<HOST>`
|
|
||||||
- `aw-detmir-web-category_<HOST>` (категоризованный поток)
|
|
||||||
- `aw-dlp-endpoint-signals_<HOST>` (endpoint сигналы)
|
|
||||||
- `aw-dlp-review_<HOST>` (ручная классификация через UI)
|
|
||||||
- `aw-dlp-rules_<HOST>` (suppress/rule записи через UI)
|
|
||||||
|
|
||||||
Проверка событий браузера:
|
9.1 Установка agent binary
|
||||||
|
|
||||||
```bash
|
sudo mkdir -p /opt/awatch-rus/bin
|
||||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-watcher-web-edge_<HOST>/events?limit=5" | jq
|
sudo mkdir -p /etc/awatch-rus
|
||||||
```
|
sudo mkdir -p /var/lib/awatch-rus/agent
|
||||||
|
sudo mkdir -p /var/log/awatch-rus
|
||||||
|
|
||||||
Проверка категоризации:
|
sudo install -m 0755 awatch-rus-agent /opt/awatch-rus/bin/awatch-rus-agent
|
||||||
|
sudo install -m 0640 agent.env /etc/awatch-rus/agent.env
|
||||||
|
|
||||||
```bash
|
9.2 Пример agent service
|
||||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-detmir-web-category_<HOST>/events?limit=5" | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка DLP review/rules:
|
[Unit]
|
||||||
|
Description=AWatch-rus Rust Agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
```bash
|
[Service]
|
||||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-dlp-review_<HOST>/events?limit=20" | jq
|
Type=simple
|
||||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-dlp-rules_<HOST>/events?limit=20" | jq
|
EnvironmentFile=/etc/awatch-rus/agent.env
|
||||||
```
|
ExecStart=/opt/awatch-rus/bin/awatch-rus-agent
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
WorkingDirectory=/opt/awatch-rus
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/awatch-rus /var/log/awatch-rus
|
||||||
|
|
||||||
Ожидаемые поля review:
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
- `reviewId`
|
9.3 Запуск agent
|
||||||
- `signalType`
|
|
||||||
- `verdict`
|
|
||||||
- `category`
|
|
||||||
- `comment`
|
|
||||||
- `archived`
|
|
||||||
|
|
||||||
Ожидаемые поля rules:
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now awatch-rus-agent.service
|
||||||
|
sudo systemctl status awatch-rus-agent.service --no-pager
|
||||||
|
|
||||||
- `ruleId`
|
10. Развёртывание портала
|
||||||
- `signalType`
|
|
||||||
- `match`
|
|
||||||
- `category`
|
|
||||||
- `comment`
|
|
||||||
- `enabled`
|
|
||||||
|
|
||||||
---
|
Портальный слой AWatch-rus зафиксирован как Rust server-rendered HTML + HTMX-compatible JSON API.
|
||||||
|
|
||||||
## 6) Сопровождение (обязательно)
|
10.1 Общий порядок
|
||||||
|
|
||||||
### 6.1 Backup перед любыми изменениями
|
build portal/backend binary
|
||||||
|
→ install binary
|
||||||
|
→ install templates/static assets, если они выделены отдельно
|
||||||
|
→ update portal env
|
||||||
|
→ restart portal service
|
||||||
|
→ smoke check HTTP/API routes
|
||||||
|
|
||||||
На Proxmox:
|
10.2 Проверка портала
|
||||||
|
|
||||||
|
curl -fsS http://127.0.0.1:5600/healthz
|
||||||
|
curl -fsS http://127.0.0.1:5600/readyz
|
||||||
|
curl -fsS http://127.0.0.1:5600/version
|
||||||
|
|
||||||
|
Если конкретные endpoints в текущей версии отличаются, использовать фактически реализованные health/readiness/version endpoints и обновить этот документ в том же commit.
|
||||||
|
|
||||||
|
11. Патчи в развернутой среде
|
||||||
|
|
||||||
|
11.1 Правило
|
||||||
|
|
||||||
|
Любой production patch применяется только через контролируемый цикл:
|
||||||
|
|
||||||
|
определить commit/tag
|
||||||
|
→ собрать release artifact
|
||||||
|
→ выполнить локальные проверки
|
||||||
|
→ сделать backup/snapshot
|
||||||
|
→ установить новые binaries/configs
|
||||||
|
→ restart/reload services
|
||||||
|
→ smoke tests
|
||||||
|
→ зафиксировать результат
|
||||||
|
→ сохранить rollback path
|
||||||
|
|
||||||
|
11.2 Перед патчем
|
||||||
|
|
||||||
|
git rev-parse HEAD
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
Сохранить:
|
||||||
|
|
||||||
|
дата/время
|
||||||
|
commit/tag
|
||||||
|
кто применяет
|
||||||
|
какие services затрагиваются
|
||||||
|
какой rollback path
|
||||||
|
|
||||||
|
11.3 Backup перед патчем
|
||||||
|
|
||||||
|
Если используется Proxmox/LXC:
|
||||||
|
|
||||||
```bash
|
|
||||||
vzdump <CT_ID> --mode snapshot --compress zstd --storage <BACKUP_STORAGE>
|
vzdump <CT_ID> --mode snapshot --compress zstd --storage <BACKUP_STORAGE>
|
||||||
```
|
|
||||||
|
|
||||||
Конфиги внутри CT:
|
Внутри сервера:
|
||||||
|
|
||||||
```bash
|
sudo tar -C / -czf /root/awatch-rus-backup-$(date +%Y%m%d-%H%M%S).tgz \
|
||||||
pct exec <CT_ID> -- tar -C / -czf <PRIVATE_BACKUP_DIR>/activitywatch-config-backup.tgz \
|
etc/awatch-rus \
|
||||||
etc/activitywatch \
|
opt/awatch-rus \
|
||||||
etc/systemd/system/activitywatch-server.service \
|
var/lib/awatch-rus \
|
||||||
opt/activitywatch/webui-ru \
|
var/log/awatch-rus
|
||||||
opt/activitywatch/releases
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Обновление сервера
|
Если данные большие, backup "/var/lib/awatch-rus" выполнять отдельной процедурой согласно backup policy.
|
||||||
|
|
||||||
1. Обновить `AW_SERVER_VERSION` и `AW_SERVER_DOWNLOAD_URL` в
|
11.4 Установка нового binary
|
||||||
`<PROJECT_ROOT>/private-config/deploy.env`
|
|
||||||
2. Выполнить:
|
|
||||||
|
|
||||||
```bash
|
Сохранить предыдущую версию:
|
||||||
<PROJECT_ROOT>/proxmox/push-aw-artifacts.sh
|
|
||||||
pct enter <CT_ID>
|
|
||||||
bash <CT_BOOTSTRAP_DIR>/install_aw_server.sh
|
|
||||||
bash <CT_BOOTSTRAP_DIR>/apply_webui_ru_patch.sh
|
|
||||||
systemctl restart activitywatch-server.service
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Повторить валидацию API/UI.
|
sudo mkdir -p /opt/awatch-rus/releases/previous
|
||||||
|
sudo cp -a /opt/awatch-rus/bin /opt/awatch-rus/releases/previous/bin-$(date +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
### 6.3 Rollback
|
Установить новый artifact:
|
||||||
|
|
||||||
RU patch rollback:
|
sudo install -m 0755 dist/awatch-rus-release/bin/* /opt/awatch-rus/bin/
|
||||||
|
|
||||||
```bash
|
11.5 Restart services
|
||||||
cp /opt/activitywatch/webui-ru/index.html.bak.<timestamp> /opt/activitywatch/webui-ru/index.html
|
|
||||||
systemctl restart activitywatch-server.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Полный rollback:
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart awatch-rus-backend.service
|
||||||
|
sudo systemctl restart aw-rus-healthd.service
|
||||||
|
|
||||||
- восстановить CT из snapshot/backup;
|
Если патч касается только agent:
|
||||||
- проверить API и Web UI;
|
|
||||||
- проверить доступность для Windows-клиентов.
|
|
||||||
|
|
||||||
---
|
sudo systemctl restart awatch-rus-agent.service
|
||||||
|
|
||||||
## 7) Безопасность
|
Если сервис в текущем контуре называется иначе, использовать фактическое имя systemd unit.
|
||||||
|
|
||||||
- Не хранить реальные приватные параметры вне `<PROJECT_ROOT>/private-config/deploy.env`.
|
12. Smoke-тесты после патча
|
||||||
- Не открывать `5600/tcp` в интернет напрямую.
|
|
||||||
- Публиковать через VPN или reverse proxy с ограничением доступа.
|
|
||||||
- Перед изменениями всегда делать backup.
|
|
||||||
|
|
||||||
---
|
12.1 Systemd
|
||||||
|
|
||||||
## 8) Короткий чек-лист ввода в эксплуатацию
|
systemctl --failed --no-pager
|
||||||
|
systemctl status awatch-rus-backend.service --no-pager
|
||||||
|
systemctl status aw-rus-healthd.service --no-pager
|
||||||
|
|
||||||
1. Заполнен `<PROJECT_ROOT>/private-config/deploy.env`.
|
12.2 Rust operational checks
|
||||||
2. Выполнен `<PROJECT_ROOT>/proxmox/create-ct.sh`.
|
|
||||||
3. Выполнен `<PROJECT_ROOT>/proxmox/push-aw-artifacts.sh`.
|
detmir-status --json
|
||||||
4. В CT выполнены `<CT_BOOTSTRAP_DIR>/install_aw_server.sh` и `<CT_BOOTSTRAP_DIR>/apply_webui_ru_patch.sh`.
|
detmir-check --json
|
||||||
5. Сервер API/порт/UI проверены.
|
detmir-dlp --json
|
||||||
6. На Windows выполнен `deploy-domain-users.ps1`.
|
|
||||||
7. Проверены процессы, задачи и bucket'ы.
|
Если отдельная команда не установлена в данном контуре, это не считается ошибкой только при наличии документированного исключения.
|
||||||
8. Зафиксированы параметры и дата ввода.
|
|
||||||
|
12.3 HTTP/API
|
||||||
|
|
||||||
|
curl -fsS http://127.0.0.1:5600/healthz
|
||||||
|
curl -fsS http://127.0.0.1:5600/readyz
|
||||||
|
curl -fsS http://127.0.0.1:5600/version
|
||||||
|
|
||||||
|
12.4 Portal smoke
|
||||||
|
|
||||||
|
Проверить в браузере:
|
||||||
|
|
||||||
|
/portal
|
||||||
|
/portal/reports
|
||||||
|
/portal/architecture
|
||||||
|
|
||||||
|
Для Pilot v1 проверить роли:
|
||||||
|
|
||||||
|
executive
|
||||||
|
manager
|
||||||
|
security
|
||||||
|
forensics
|
||||||
|
admin
|
||||||
|
|
||||||
|
12.5 Data freshness
|
||||||
|
|
||||||
|
Проверить, что витрины и отчёты не пустые из-за сбоя сбора:
|
||||||
|
|
||||||
|
последние события поступают
|
||||||
|
worktime reports обновляются
|
||||||
|
DLP/security events отображаются, если включены
|
||||||
|
evidence/reporting не падает
|
||||||
|
Grafana dashboards открываются
|
||||||
|
|
||||||
|
13. Rollback
|
||||||
|
|
||||||
|
13.1 Быстрый rollback binary
|
||||||
|
|
||||||
|
Найти предыдущий backup:
|
||||||
|
|
||||||
|
ls -lah /opt/awatch-rus/releases/previous/
|
||||||
|
|
||||||
|
Восстановить:
|
||||||
|
|
||||||
|
sudo rsync -a --delete /opt/awatch-rus/releases/previous/bin-YYYYMMDD-HHMMSS/ /opt/awatch-rus/bin/
|
||||||
|
sudo systemctl restart awatch-rus-backend.service
|
||||||
|
sudo systemctl restart aw-rus-healthd.service
|
||||||
|
|
||||||
|
13.2 Rollback конфигурации
|
||||||
|
|
||||||
|
sudo cp /etc/awatch-rus/awatch-rus.env.bak /etc/awatch-rus/awatch-rus.env
|
||||||
|
sudo systemctl restart awatch-rus-backend.service
|
||||||
|
|
||||||
|
13.3 Rollback CT/VM
|
||||||
|
|
||||||
|
Если повреждение затрагивает runtime, данные или systemd-конфигурацию:
|
||||||
|
|
||||||
|
остановить сервисы
|
||||||
|
восстановить snapshot/backup
|
||||||
|
проверить health/readiness/version
|
||||||
|
проверить портал
|
||||||
|
проверить поступление данных
|
||||||
|
зафиксировать incident note
|
||||||
|
|
||||||
|
14. Monitoring
|
||||||
|
|
||||||
|
14.1 Что должно контролироваться
|
||||||
|
|
||||||
|
- service status;
|
||||||
|
- process uptime;
|
||||||
|
- API health/readiness;
|
||||||
|
- latency;
|
||||||
|
- error rate;
|
||||||
|
- freshness данных;
|
||||||
|
- заполненность диска;
|
||||||
|
- размер логов;
|
||||||
|
- успешность exporters;
|
||||||
|
- SLO status;
|
||||||
|
- agent coverage;
|
||||||
|
- отсутствие failed systemd units.
|
||||||
|
|
||||||
|
14.2 Grafana
|
||||||
|
|
||||||
|
В Grafana должны быть разделены витрины:
|
||||||
|
|
||||||
|
- executive dashboard;
|
||||||
|
- security dashboard;
|
||||||
|
- operations dashboard;
|
||||||
|
- RDP/user activity dashboard;
|
||||||
|
- data quality/freshness dashboard;
|
||||||
|
- DLP/evidence dashboard, если модуль включён.
|
||||||
|
|
||||||
|
14.3 Prometheus
|
||||||
|
|
||||||
|
Prometheus scrape должен быть доступен только из внутреннего контура мониторинга. Не открывать metrics endpoints наружу.
|
||||||
|
|
||||||
|
15. Security hardening
|
||||||
|
|
||||||
|
Обязательные правила:
|
||||||
|
|
||||||
|
- не публиковать API напрямую в интернет;
|
||||||
|
- использовать VPN/reverse proxy/access control;
|
||||||
|
- закрыть лишние порты;
|
||||||
|
- хранить secrets вне git;
|
||||||
|
- ограничить права systemd services;
|
||||||
|
- использовать отдельного service user, если это поддерживается текущей установкой;
|
||||||
|
- включить backup;
|
||||||
|
- проверять логи после каждого патча;
|
||||||
|
- не использовать demo fixtures как production data;
|
||||||
|
- не смешивать реальные ФИО/IP/hostname с публичными demo screenshots.
|
||||||
|
|
||||||
|
16. Проверка перед вводом в эксплуатацию
|
||||||
|
|
||||||
|
Минимальный checklist:
|
||||||
|
|
||||||
|
[ ] выбран commit/tag release
|
||||||
|
[ ] cargo fmt прошёл
|
||||||
|
[ ] cargo clippy прошёл
|
||||||
|
[ ] cargo test прошёл
|
||||||
|
[ ] cargo build --release прошёл
|
||||||
|
[ ] private config guard прошёл
|
||||||
|
[ ] backup/snapshot создан
|
||||||
|
[ ] binaries установлены
|
||||||
|
[ ] systemd services запущены
|
||||||
|
[ ] health/readiness/version отвечают
|
||||||
|
[ ] detmir-status/check/dlp работают
|
||||||
|
[ ] portal открывается
|
||||||
|
[ ] роли Pilot v1 проверены
|
||||||
|
[ ] Grafana dashboards открываются
|
||||||
|
[ ] данные поступают
|
||||||
|
[ ] rollback path известен
|
||||||
|
[ ] дата/commit/оператор зафиксированы
|
||||||
|
|
||||||
|
17. Что больше не использовать как основной путь
|
||||||
|
|
||||||
|
Не использовать как основной production flow:
|
||||||
|
|
||||||
|
windows/deploy-single-user.ps1
|
||||||
|
windows/deploy-domain-users.ps1
|
||||||
|
windows/deploy-ensemble.ps1
|
||||||
|
windows/validate-deployment.ps1
|
||||||
|
windows/hardening-recovery.ps1
|
||||||
|
windows/browser-domains-native-collector.ps1
|
||||||
|
windows/dlp-endpoint-signals-collector.ps1
|
||||||
|
|
||||||
|
Если эти файлы физически остаются в репозитории, они должны быть явно помечены как:
|
||||||
|
|
||||||
|
legacy
|
||||||
|
planned provider
|
||||||
|
migration-only
|
||||||
|
dev/test helper
|
||||||
|
|
||||||
|
Они не должны описываться в основном deployment manual как обязательный production-путь.
|
||||||
|
|
||||||
|
18. Короткий production runbook
|
||||||
|
|
||||||
|
18.1 Развернуть
|
||||||
|
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cargo build --release --workspace
|
||||||
|
|
||||||
|
sudo install -m 0755 target/release/<binary> /opt/awatch-rus/bin/<binary>
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart <service>.service
|
||||||
|
|
||||||
|
18.2 Проверить
|
||||||
|
|
||||||
|
systemctl --failed --no-pager
|
||||||
|
detmir-status --json
|
||||||
|
detmir-check --json
|
||||||
|
curl -fsS http://127.0.0.1:5600/healthz
|
||||||
|
curl -fsS http://127.0.0.1:5600/readyz
|
||||||
|
curl -fsS http://127.0.0.1:5600/version
|
||||||
|
|
||||||
|
18.3 Откатить
|
||||||
|
|
||||||
|
sudo rsync -a --delete /opt/awatch-rus/releases/previous/bin-YYYYMMDD-HHMMSS/ /opt/awatch-rus/bin/
|
||||||
|
sudo systemctl restart <service>.service
|
||||||
|
|
||||||
|
19. Правило актуализации этого документа
|
||||||
|
|
||||||
|
Если меняется:
|
||||||
|
|
||||||
|
- имя binary;
|
||||||
|
- имя systemd unit;
|
||||||
|
- порт;
|
||||||
|
- endpoint;
|
||||||
|
- путь хранения данных;
|
||||||
|
- способ сборки;
|
||||||
|
- способ доставки artifacts;
|
||||||
|
- smoke-test;
|
||||||
|
- rollback procedure;
|
||||||
|
|
||||||
|
то этот файл должен обновляться в том же commit, что и изменение кода или deployment-конфигурации.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.94.0"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user