Compare commits

..
952 changed files with 58761 additions and 182750 deletions
-1
View File
@@ -1 +0,0 @@
windows/installkit/innosetup/AWatch-rus-InstallKit.exe filter=lfs diff=lfs merge=lfs -text
+1 -48
View File
@@ -1,48 +1 @@
# AWatch-rus CODEOWNERS
#
# Ownership is used for review routing and engineering accountability.
# It does not certify security, legal readiness, performance or fitness for
# production use. Contributors remain responsible for the safety of their
# submissions.
# Repository default owner.
* @igor04091968 @rachkovii68-bisness
# Rust workspace and crates.
/adk-rust/ @igor04091968 @rachkovii68-bisness
**/Cargo.toml @igor04091968 @rachkovii68-bisness
**/Cargo.lock @igor04091968 @rachkovii68-bisness
**/*.rs @igor04091968 @rachkovii68-bisness
# Operational scripts and automation.
/scripts/ @igor04091968 @rachkovii68-bisness
# Product documentation, demo material and screenshots.
/docs/ @igor04091968 @rachkovii68-bisness
/docs/demo/ @igor04091968 @rachkovii68-bisness
/docs/screenshots/ @igor04091968 @rachkovii68-bisness
/docs/assets/screenshots/ @igor04091968 @rachkovii68-bisness
# Registry-readiness evidence and policy documents.
/docs/registry/ @igor04091968 @rachkovii68-bisness
# GitHub public mirror validation metadata and governance controls.
/.github/workflows/ @igor04091968 @rachkovii68-bisness
/.github/workflows/ci.yml @igor04091968 @rachkovii68-bisness
/.github/workflows/security.yml @igor04091968 @rachkovii68-bisness
/.github/workflows/coverage.yml @igor04091968 @rachkovii68-bisness
/.github/pull_request_template.md @igor04091968 @rachkovii68-bisness
/.github/ISSUE_TEMPLATE/ @igor04091968 @rachkovii68-bisness
/.github/CODEOWNERS @igor04091968 @rachkovii68-bisness
/docs/BRANCH_PROTECTION_POLICY_RU.md @igor04091968 @rachkovii68-bisness
/docs/BRANCH_PROTECTION_EVIDENCE_RU.md @igor04091968 @rachkovii68-bisness
/docs/PR_REVIEW_WORKFLOW_RU.md @igor04091968 @rachkovii68-bisness
/docs/PR_REVIEW_EVIDENCE_RU.md @igor04091968 @rachkovii68-bisness
# Ansible deployment automation.
/ansible/ @igor04091968 @rachkovii68-bisness
# Security and contribution governance.
/SECURITY.md @igor04091968 @rachkovii68-bisness
/CONTRIBUTING.md @igor04091968 @rachkovii68-bisness
/deny.toml @igor04091968 @rachkovii68-bisness
* @igor04091968
-42
View File
@@ -1,42 +0,0 @@
name: Bug report
description: Report a reproducible defect with sanitized evidence.
title: "fix: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Do not publish secrets, tokens, private keys, personal data, real employee logs or customer evidence. Use demo/anonymized evidence.
- type: textarea
id: summary
attributes:
label: Summary
description: What is broken?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Minimal steps using sanitized data.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Sanitized evidence
description: Logs, screenshots or output with secrets and personal data removed.
validations:
required: false
@@ -1,42 +0,0 @@
name: Feature request
description: Request a capability without using customer or employee data.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Do not publish secrets, personal data, real employee logs or non-anonymized customer evidence.
- type: textarea
id: problem
attributes:
label: Problem
description: What user or operator problem should this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the desired behavior conservatively.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- Workforce
- Security analytics
- Forensics
- Registry-readiness
- Install/deployment
- Documentation
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Demo/anonymized evidence
validations:
required: false
@@ -1,29 +0,0 @@
name: Registry-readiness task
description: Track registry-readiness documentation, evidence or process gaps.
title: "docs(registry): "
labels: ["registry-readiness", "documentation"]
body:
- type: markdown
attributes:
value: |
Do not claim legal completion of registry registration. Do not publish secrets, personal data, real employee logs or private infrastructure credentials.
- type: textarea
id: scope
attributes:
label: Scope
description: What registry-readiness gap is being tracked?
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Required evidence
description: List sanitized evidence or documents needed.
validations:
required: true
- type: textarea
id: remaining
attributes:
label: Remaining gaps
validations:
required: false
@@ -1,30 +0,0 @@
name: Security hardening task
description: Track hardening without exposing sensitive data.
title: "chore(security): "
labels: ["security", "hardening"]
body:
- type: markdown
attributes:
value: |
Do not publish exploits, secrets, private keys, tokens, personal data, real employee logs or customer evidence.
- type: textarea
id: hardening
attributes:
label: Hardening target
description: What should be hardened?
validations:
required: true
- type: textarea
id: risk
attributes:
label: Risk addressed
description: Explain the risk without sensitive details.
validations:
required: true
- type: textarea
id: validation
attributes:
label: Validation
description: Checks or evidence required.
validations:
required: true
+9 -54
View File
@@ -1,58 +1,13 @@
## Summary
Describe what changed and why.
- what changed
- why it changed
- risk and rollback notes
## Impact
## Checklist
- Runtime impact: `none / changed / not applicable`
- API impact: `none / changed / not applicable`
- UI impact: `none / changed / not applicable`
- Documentation impact: `none / changed / not applicable`
- Rollback impact: `none / documented / not applicable`
- Evidence impact: `none / registry docs updated / release evidence required`
## Validation
List commands executed. Use `skipped: <reason>` when a check requires a live
stand or unavailable tool.
## Review Checklist
- [ ] Linked issue is provided, or the PR explains why no issue is applicable.
- [ ] Runtime/API/UI impact is stated.
- [ ] Registry claims are checked and remain conservative.
- [ ] Secrets, PII, employee logs and customer identifiers are absent.
- [ ] Tests/checks executed are listed, or skipped checks have reasons.
- [ ] Evidence docs are updated when the change affects governance, registry
readiness or release evidence.
- [ ] GitHub Actions are public validation only, not registry release evidence.
- [ ] I checked that this PR does not publish secrets, tokens, passwords,
private keys, recovery codes or live credentials.
- [ ] I checked that this PR does not publish personal data, real employee data,
customer logs or customer infrastructure identifiers.
- [ ] I checked registry claims: no completed registry submission, no
FSTEC/FSB certification claim, no SIEM/DLP replacement claim.
- [ ] I ran relevant checks or documented why a check was skipped.
- [ ] I stated runtime/API/UI impact.
- [ ] I stated documentation impact.
- [ ] I stated smoke-test result or why smoke testing is not applicable.
- [ ] I stated rollback and evidence impact.
- [ ] I checked that GitHub Actions remains public mirror validation only.
- [ ] I checked that registry release evidence still requires the Russian
build-runner.
## Registry / Public Mirror Scope
- GitHub is public mirror validation only.
- Primary registry release evidence must be produced on the Russian
build-runner.
- Update `docs/registry/` when registry-readiness behavior or evidence changes.
## Safety
- No secrets, tokens, passwords or private keys.
- No personal data.
- No real employee logs.
- No customer evidence unless anonymized.
- No unsupported claims about certification, DLP/SIEM replacement or legal
registry completion.
- [ ] No real secrets or credentials committed
- [ ] Server-side scripts validated (`bash -n`)
- [ ] PowerShell scripts validated (`Invoke-ScriptAnalyzer`)
- [ ] Docs updated (full paths and runbook steps)
- [ ] Rollback steps documented
+28 -93
View File
@@ -1,114 +1,49 @@
name: CI
# GitHub Actions is public mirror validation only.
# Primary registry release evidence must be produced on Russian build-runner.
name: shell-and-powershell-ci
on:
push:
branches: [ "main" ]
pull_request:
workflow_dispatch:
permissions:
contents: read
branches: [ "main" ]
jobs:
rust-checks:
name: Rust checks
shell-check:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: cargo fmt
run: cargo fmt --all --check
- name: Run shellcheck
run: |
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
- name: cargo test
run: cargo test --workspace
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: cargo build
run: cargo build --workspace
docs-registry-checks:
name: Docs and registry checks
powershell-analyzer:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Registry script syntax
run: bash -n scripts/registry_readiness_check.sh
- name: Registry readiness
run: bash scripts/registry_readiness_check.sh
- name: Release evidence script syntax
- name: Install PSScriptAnalyzer
shell: pwsh
run: |
if [[ -f scripts/build_release_evidence.sh ]]; then
bash -n scripts/build_release_evidence.sh
fi
if [[ -f scripts/check_release_evidence.sh ]]; then
bash -n scripts/check_release_evidence.sh
fi
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force
- name: Whitespace diff check
run: git diff --check
smoke-checks:
name: Smoke checks
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Deployment readiness smoke
- name: Analyze PowerShell scripts
shell: pwsh
run: |
if command -v node >/dev/null 2>&1 && [[ -f scripts/deployment-readiness-smoke.mjs ]]; then
node scripts/deployment-readiness-smoke.mjs
else
echo "skipped: node or scripts/deployment-readiness-smoke.mjs missing"
fi
- name: Pilot validation smoke
run: |
if command -v node >/dev/null 2>&1 && [[ -f scripts/pilot-validation-smoke.mjs ]]; then
node scripts/pilot-validation-smoke.mjs
else
echo "skipped: node or scripts/pilot-validation-smoke.mjs missing"
fi
- name: Browser conformance smoke
run: |
if [[ -f scripts/browser-conformance-smoke.mjs ]]; then
echo "skipped: requires live stand unless explicitly run by operator"
else
echo "skipped: scripts/browser-conformance-smoke.mjs missing"
fi
$targets = @(
"windows/*.ps1",
"windows/*.psm1",
"windows/*.psd1"
)
$issues = $targets | ForEach-Object {
Invoke-ScriptAnalyzer -Path $_ -Recurse -Severity Error
}
if ($issues) {
$issues | Format-Table -AutoSize
throw "PSScriptAnalyzer detected issues."
}
-47
View File
@@ -1,47 +0,0 @@
name: Coverage
# GitHub Actions is public mirror validation only.
# Primary registry release evidence must be produced on Russian build-runner.
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
coverage-baseline:
name: Coverage baseline
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage summary
run: |
mkdir -p ../coverage
cargo llvm-cov --workspace --summary-only | tee ../coverage/coverage-summary.txt
- name: Upload coverage summary
uses: actions/upload-artifact@v4
with:
name: coverage-summary
path: coverage/coverage-summary.txt
- name: Future threshold placeholder
run: |
echo "Coverage threshold is not enforced yet; this workflow tracks baseline and regressions."
-149
View File
@@ -1,149 +0,0 @@
name: Dependency hygiene
# GitHub Actions is public mirror validation only.
# Primary registry release evidence must be produced on Russian build-runner.
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "17 2 * * 1"
permissions:
contents: read
pull-requests: read
jobs:
unused-dependencies:
name: Unused dependency check
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-machete
run: cargo install cargo-machete --locked
- name: cargo metadata
run: cargo metadata --locked --format-version 1 > /tmp/aw-rus-cargo-metadata.json
- name: cargo machete
run: cargo machete --with-metadata
- name: Ensure cargo-machete metadata did not rewrite manifests
working-directory: .
run: git diff --exit-code -- adk-rust/Cargo.lock adk-rust/Cargo.toml adk-rust/crates
- name: Require explicit justification for cargo-machete ignores
working-directory: .
run: |
python3 - <<'PY'
from pathlib import Path
failures = []
for path in Path("adk-rust").rglob("Cargo.toml"):
lines = path.read_text(encoding="utf-8").splitlines()
in_machete = False
for idx, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
in_machete = stripped == "[package.metadata.cargo-machete]"
continue
if not in_machete or not stripped.startswith("ignored"):
continue
same_line_comment = "#" in line and line.split("#", 1)[1].strip()
prev_comment = idx > 0 and lines[idx - 1].strip().startswith("#")
if not same_line_comment and not prev_comment:
failures.append(f"{path}:{idx + 1}")
if failures:
print("cargo-machete ignored entries require an adjacent TOML comment explaining why the dependency is intentionally kept:")
for item in failures:
print(f" {item}")
raise SystemExit(1)
PY
dependency-tree:
name: Dependency duplicate report
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: cargo tree duplicates
run: cargo tree --duplicates --locked
dependency-security:
name: Dependency security policy
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
uses: taiki-e/install-action@cargo-audit
- name: Install cargo-deny
uses: taiki-e/install-action@cargo-deny
- name: cargo audit
working-directory: adk-rust
run: cargo audit --deny warnings
- name: cargo deny
run: |
cargo deny --manifest-path adk-rust/Cargo.toml check \
--config deny.toml \
--hide-inclusion-graph \
--show-stats
cargo-udeps-nightly:
name: Cargo udeps nightly advisory
runs-on: ubuntu-latest
continue-on-error: true
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install nightly Rust
uses: dtolnay/rust-toolchain@nightly
- name: Install cargo-udeps
run: cargo install cargo-udeps --locked
- name: cargo udeps
run: cargo +nightly udeps --workspace --all-targets
@@ -1,73 +0,0 @@
name: Operational maturity
# GitHub Actions is public mirror validation only.
# Live DetMir production checks must be run explicitly by an operator with
# private network access and must not enable heavy DLP, Loki or always-on
# Velociraptor.
on:
push:
pull_request:
workflow_dispatch:
inputs:
live:
description: "Run live endpoint contract if AWATCH_OPS_LIVE_URL is configured"
required: false
default: "false"
schedule:
- cron: "41 3 * * 2"
permissions:
contents: read
jobs:
offline-operational-maturity:
name: Offline operational maturity
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Script syntax
run: node --check scripts/operational-maturity-check.mjs
- name: Operational maturity harness
run: node scripts/operational-maturity-check.mjs --json
live-operational-contract:
name: Live operational contract
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.live == 'true'
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Run live contract when URL is available
env:
AWATCH_OPS_LIVE_URL: ${{ secrets.AWATCH_OPS_LIVE_URL }}
run: |
if [[ -z "${AWATCH_OPS_LIVE_URL}" ]]; then
echo "skipped: AWATCH_OPS_LIVE_URL secret is not configured"
exit 0
fi
node scripts/operational-maturity-check.mjs --json --live
-29
View File
@@ -1,29 +0,0 @@
name: release-assets
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
sbom-and-release-assets:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Generate machine SBOM artifacts
run: bash scripts/generate_release_sbom_v0_2.sh dist/release-v0.2
- name: Validate generated JSON artifacts
run: |
python3 -m json.tool dist/release-v0.2/sbom/cyclonedx-rust-v0.2.json >/dev/null
python3 -m json.tool dist/release-v0.2/sbom/spdx-rust-v0.2.json >/dev/null
python3 -m json.tool dist/release-v0.2/RELEASE_ASSETS_MANIFEST-v0.2.json >/dev/null
- name: Verify release asset checksum/signature script
run: bash scripts/verify_release_assets.sh --self-test
-74
View File
@@ -1,74 +0,0 @@
name: rust-binary-build
on:
workflow_dispatch:
pull_request:
branches: [ "main" ]
paths:
- 'rust-toolchain.toml'
- 'adk-rust/**'
- 'scripts/package_rust_release_binaries.py'
- '.github/workflows/rust-binary-build.yml'
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-linux-x86_64:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Build release binaries
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
- name: Package release binaries
run: |
python3 scripts/package_rust_release_binaries.py \
--release-dir adk-rust/target/release \
--out-dir dist/awatch-rus-linux-x86_64 \
--archive dist/awatch-rus-linux-x86_64-release-binaries.tar.gz \
--target linux-x86_64 \
--commit "${GITHUB_SHA}" \
--ref "${GITHUB_REF}" \
--run-id "${GITHUB_RUN_ID}"
- name: Upload release binaries artifact
uses: actions/upload-artifact@v4
with:
name: awatch-rus-linux_x86_64-release-binaries
path: |
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
dist/awatch-rus-linux_x86_64/BINARIES.txt
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
if-no-files-found: error
retention-days: 30
- name: Publish GitHub Release assets
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
dist/awatch-rus-linux_x86_64/BINARIES.txt
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
@@ -1,40 +0,0 @@
name: Rust clippy diagnostic
on:
push:
branches:
- codex/rust-professionalization
workflow_dispatch:
jobs:
detmir-portal-clippy-diagnostic:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.85 with rustfmt and clippy
run: |
rustup toolchain install 1.85.0 --profile minimal --component rustfmt --component clippy
rustup default 1.85.0
- name: Capture detmir-portal clippy output
working-directory: adk-rust
run: |
set +e
cargo clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1
status=$?
echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt
tail -n 240 ../detmir-portal-clippy.log
exit ${status}
- name: Upload detmir-portal clippy log
if: always()
uses: actions/upload-artifact@v4
with:
name: detmir-portal-clippy-log
path: |
detmir-portal-clippy.log
detmir-portal-clippy-status.txt
@@ -1,73 +0,0 @@
name: Rust professionalization check
on:
pull_request:
branches:
- main
paths:
- 'rust-toolchain.toml'
- 'adk-rust/crates/detmir-core/**'
- 'adk-rust/crates/detmir-portal/**'
- 'scripts/check_private_config_guard.sh'
- 'scripts/check_portal_contract_sync.mjs'
- '.github/workflows/rust-professionalization-check.yml'
workflow_dispatch:
jobs:
rust-check:
name: changed Rust crates smoke
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Cargo fmt check
working-directory: adk-rust
run: cargo +1.94.0 fmt --all -- --check
- name: Test detmir-core
working-directory: adk-rust
run: cargo +1.94.0 test -p detmir-core
- name: Test detmir-portal
working-directory: adk-rust
run: cargo +1.94.0 test -p detmir-portal
- name: Clippy detmir-core
working-directory: adk-rust
run: cargo +1.94.0 clippy -p detmir-core --all-targets -- -D warnings
- name: Clippy detmir-portal with captured log
working-directory: adk-rust
run: |
set +e
cargo +1.94.0 clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1
status=$?
echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt
tail -n 80 ../detmir-portal-clippy.log
exit ${status}
- name: Upload detmir-portal clippy log
if: always()
uses: actions/upload-artifact@v4
with:
name: detmir-portal-clippy-log
path: |
detmir-portal-clippy.log
detmir-portal-clippy-status.txt
- name: Private config guard
run: bash scripts/check_private_config_guard.sh
- name: Portal contract sync
run: node scripts/check_portal_contract_sync.mjs
-35
View File
@@ -1,35 +0,0 @@
name: rust-workspace
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
jobs:
rust-workspace:
runs-on: ubuntu-latest
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: Format
run: cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check
- name: Test
run: cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace
- name: Clippy
run: cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
- name: Release build
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
-86
View File
@@ -1,86 +0,0 @@
name: Security
# GitHub Actions is public mirror validation only.
# Primary registry release security evidence must be produced on Russian build-runner.
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
pull-requests: read
jobs:
cargo-audit:
name: Cargo audit
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
uses: taiki-e/install-action@cargo-audit
- name: cargo audit
run: cargo audit
cargo-deny:
name: Cargo deny
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deny
uses: taiki-e/install-action@cargo-deny
- name: cargo deny
run: cargo deny check advisories licenses sources --config ../deny.toml
working-directory: adk-rust
secret-pattern-check:
name: Secret pattern check
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Scan for obvious secret patterns
run: python3 scripts/public_secret_pattern_check.py
dependency-review:
name: Dependency review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Dependency Review
uses: actions/dependency-review-action@v4
+2 -37
View File
@@ -1,13 +1,6 @@
# Local secrets
/secrets/
/private-config/*
!/private-config/
!/private-config/README.md
!/private-config/.gitkeep
!/private-config/*.example
!/private-config/*.template
/ansible/inventory.ini
/codex_history.txt
secrets/deploy.secrets.env
secrets/runtime.env
# Runtime / reports
*.log
@@ -15,37 +8,9 @@
*.bak
windows/*.report.json
.rollout-logs/
output/
artifacts/browser-smoke/
dist/
.ai/
.autonomous/
.ops/
.playwright-cli/
# IDE
.idea/
.vscode/
__pycache__/
# Graphify analysis artifacts
.graphify_*.json
.graphify_*.txt
.graphify_python/
graphify-out/cache/
.planning/
.pssa_run.ps1
data/
# Generated analysis artifacts
/graphify-out/
/.graphify_*.json
/.graphify_*.txt
/.graphify_python/
# Release assets kept outside git
/install-kit-awindows-*.zip
/install-kit-awindows-*.tar.gz
/install-kit-awindows-*/
/scripts/configure_pfsense_gateway_nat.py
+11236
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-246
View File
@@ -1,246 +0,0 @@
# AGENTS.md
Operational rules for OpenCode/Codex agents in AWatch-rus.
## Defaults
- Rust is the primary runtime: use `adk-rust/`, build with `cargo build --release -p <crate>`, test with `cargo test -p <crate>`.
- Root scripts (`check-aw-data.sh`, `check-aw-full.sh`, `scripts/prod_rollout.sh`, install-kit helpers) are Rust-first wrappers with legacy fallback.
- Python is allowed only in `aw-server/dlp-content-analysis/`, `clickhouse-1c/ai/`, `clickhouse-1c/etl/`, `detmir-mcp/main.py`, `grafana-1c/`, `pfsense/`, `proxmox/tsj_guardian_bot.py`.
- Never add real secrets from `secrets/`, private `.env`, or host credentials.
- When auditing private/ignored files, report only path, secret type, and remediation. Never copy secret values into docs, logs, markdown, terminal summaries, commits, or handoff reports.
## Required Checks
- General: `scripts/quality-gate.sh`.
- Rust: targeted `cargo test -p <crate>`.
- Windows: parse PowerShell; CI also runs PSScriptAnalyzer on `windows/*.ps1`, `.psm1`, `.psd1`.
- Ansible: affected `ansible-playbook --syntax-check ...`.
## Map
- `adk-rust/`: operational crates.
- `aw-server/`: server install, env examples, RU WebUI patch, systemd.
- `windows/`: RDP deployment, collectors, recovery, validation.
- `ansible/`: deployment playbooks.
- `proxmox/`: CT/gateway/bot automation.
- `clickhouse-1c/`, `grafana-1c/`, `pfsense/`: integration stacks.
- `grafana/`: flat version-controlled dashboard JSON; use Ansible to import/check it.
## Entrypoints
Use `proxmox/create-ct.sh`, `proxmox/push-aw-artifacts.sh`, `aw-server/install_aw_server.sh`, `aw-server/apply_webui_ru_patch.sh`, `windows/deploy-ensemble.ps1`, and docs in `docs/preparation.md`, `docs/deployment.md`, `docs/runbook.md`, `docs/operations.md`.
## Incident Handling
OpenCode must handle AWatch-rus incidents as evidence-based operational triage,
not as guesswork from one red dashboard card.
### Assessment Basis
Assess every incident from these signals, in this order:
- **User impact:** portal/report/dashboard unavailable, stale, slow, or wrong;
which role is affected: executive, manager, security, forensics, admin.
- **Data freshness:** ActivityWatch bucket `metadata.end`, collector heartbeats,
Windows scheduled task recency, queue depth, and upload/send failure counters.
- **Service health:** systemd failed units, active timers, bounded HTTP checks,
`/health` or `/api/health` responses, container health where relevant.
- **Pipeline layer:** identify the first broken layer in the chain
`Windows/RDP collectors -> ActivityWatch buckets -> Rust services -> exporters
-> Grafana/Portal -> ClickHouse/1C where configured`.
- **Risk/evidence:** DLP endpoint signals, incident candidates, evidence
artifacts, UEBA/risk narrative inputs, coverage gaps, and security
correlation indicators.
- **Blast radius:** one user/session/collector, one host, one service, one
dashboard, or the full contour.
- **Recoverability:** known rollback, stale-cache availability, safe restart
boundary, and whether a human approval is required.
Risk Narrative is only decision support. It can raise priority and explain
why a manual check is needed, but it does not prove a policy violation, DLP
incident, or SIEM finding by itself.
### Severity
Use this practical severity model:
- `P0`: data loss risk, auth/security boundary broken, raw private service
exposed, production report chain unavailable with no stale fallback, or
repeated collector process storms/memory pressure.
- `P1`: executive/security workflows degraded, fresh data missing for a critical
host, DLP evidence sync broken, ClickHouse/1C ingest stopped, or portal health
degraded with user-visible effect.
- `P2`: one collector stale, one dashboard/panel wrong, delayed timer, bad label
normalization, missing noncritical evidence, or recoverable stale report.
- `P3`: documentation drift, cosmetic UI issue, non-production demo fixture,
or a warning with fresh data still confirmed.
Escalate severity when the same symptom repeats after recovery, when coverage
is unknown, or when evidence contradicts dashboard status.
### Mechanisms To Use
Start with the repo wrappers before ad hoc probing:
```bash
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian
./check-aw-data.sh
./check-aw-full.sh
```
Then narrow by layer:
- ActivityWatch API: `/api/0/info`, `/api/0/buckets`, bucket metadata and recent
events with explicit `no_proxy` and short `curl --max-time`.
- Worktime: `aw-worktime-api` `/health`, `/reports/worktime/today`,
`/reports/worktime/management?allow_stale=1`, prewarm logs, stale-cache
fields, `AW_WORKTIME_EVENTS_LIMIT`, and `aw_query_timeout_count`.
- Windows/RDP: `validate-deployment.ps1`, exact `ActivityWatch Launch [...]`
scheduled tasks, `ActivityWatch Recovery`, collector guard state, session
collectors, local queue depth, and send failure counters.
- DLP: `aw-dlp-policy-engine`, `aw-dlp-case-management`, `dlp-health-check`,
`aw-dlp-endpoint-signals_<HOST>`, evidence artifact sync, policy audit, and
case/compliance services.
- Portal/Gateway/Grafana: `/portal/api/health`, `/api/reports`, gateway
`/healthz`, protected `/d/...` Grafana routes, role gates, and browser smoke
scripts.
- ClickHouse/1C: only for file-1C/analytics incidents. Do not blame ClickHouse
for worktime report failures unless the affected path explicitly uses it.
Use existing guards and bounded mechanisms before broad restarts:
- stale-cache and fail-closed worktime behavior;
- `aw-worktime-autoheal`, `aw-worktime-prewarm`, `aw-worktime-ui-bridge`,
`aw-rus-healthd` timers;
- Windows collector guard and exact localized scheduled tasks;
- DLP evidence sync and health timers;
- targeted service restart only after evidence identifies the layer.
### DLP Rule Update System
Do not describe AWatch-rus DLP rules as manual local JSON entry, and do not
collapse all DLP updates into one mechanism. There are two related but separate
contours:
1. policy lifecycle and endpoint synchronization through the DLP Policy Engine;
2. automatic IOC/signature replenishment from the open-source Hayabusa/Sigma
ruleset.
The centralized policy update contour is:
- Server service: `aw-dlp-policy-engine.service`, Rust binary
`/usr/local/bin/aw-dlp-policy-engine-rust`, default API port `5601`.
- Storage: SQLite DB from `AW_DLP_POLICY_ENGINE_DB_PATH`, with policy records,
policy versions, active policy pointer, rollback versions, and `policy_audit`.
- API contract:
- `GET /healthz`;
- CRUD: `/api/0/dlp/policies`;
- active bundle: `GET /api/0/dlp/policies/active`;
- active version/checksum: `GET /api/0/dlp/policies/active/version`;
- approval lifecycle:
`draft -> pending_approval -> approved -> deployed`;
- workflow calls:
`POST /submit`, `POST /approve`, `POST /draft`, `POST /activate`;
- rollback: `POST /api/0/dlp/policies/rollback`;
- audit:
`GET /api/0/dlp/policies/audit?limit=N` and
`GET /api/0/dlp/policies/{id}/audit?limit=N`;
- endpoint sync:
`POST /api/0/dlp/policies/agents/{agent_id}/heartbeat` and
`GET /api/0/dlp/policies/agents/{agent_id}/desired`.
- Windows side is configured for server-driven policy mode:
`aw_windows_policy_mode: "server"`,
`aw_windows_policy_engine_enabled: true`,
`aw_windows_policy_refresh_seconds: 300`, and policy engine host/port from
Ansible group vars.
- Agents report their current policy version/checksum by heartbeat. The server
compares it with the active deployed policy and returns `desired` with
`refreshNow=true` when the endpoint must update.
- `dlp-admin-cli` is the operator CLI for read-side checks such as
`policies list`, `policies active`, incident/case listing, and combined DLP
health checks. It is not a replacement for the lifecycle API when changing
policy state.
Automatic IOC/signature replenishment:
- Name it precisely as `DLP IOC Enrichment from Hayabusa/Sigma` or
`Hayabusa Sigma IOC refresh pipeline`.
- Source rules come from the open-source GitHub ruleset
`Yamato-Security/hayabusa-rules`, configured by
`aw_dlp_ioc_rules_zip_url`.
- Deployment is controlled by `ansible/deploy_aw_server.yml` when
`aw_dlp_ioc_enabled=true`.
- The refresh wrapper `/usr/local/bin/aw-dlp-ioc-refresh.sh` downloads the
latest `hayabusa-rules` ZIP, unpacks Sigma YAML rules, and runs the Rust
extractor `/usr/local/bin/aw-extract-ioc-from-sigma`.
- The Rust extractor is built from
`adk-rust/crates/extract-ioc-from-sigma`; local/manual builds use
`scripts/build_dlp_ioc_from_hayabusa.sh`.
- Extracted IOC-like values include process image suffixes, command-line
substrings, original filenames, and SHA256 hashes. They are de-duplicated and
emitted as `ioc_blacklist.json`, `ioc_blacklist.csv`, and
`ioc_blacklist.sql`.
- Production artifacts live under `/opt/activitywatch/dlp-ioc/output` and are
served by `aw-worktime-api` on `/dlp-ioc/ioc_blacklist.json`,
`/dlp-ioc/ioc_blacklist.csv`, and `/dlp-ioc/ioc_blacklist.sql`.
- Windows DLP policy can consume this feed through the `ioc.source` field with
format `hayabusa_sigma_v1`; endpoint health/heartbeat should expose loaded
IOC state such as `iocRulesLoaded`.
- Runtime automation is `aw-dlp-ioc-refresh.service` plus
`aw-dlp-ioc-refresh.timer` with interval `aw_dlp_ioc_refresh_interval`
(default `6h`). Health/diagnostics should check this timer before assuming
signatures are static or manually maintained.
- This Hayabusa/Sigma IOC pipeline enriches the DLP rule base automatically; it
is not the same thing as hand-editing endpoint JSON and is also distinct from
the server-side Hayabusa EVTX forensics runner.
Operational meaning:
1. To update rules, create or update a policy draft through the policy engine.
2. Submit it for approval, approve it, then activate/deploy it. Activation is
allowed only from `approved`.
3. For policy changes, verify `active/version`, audit entries, Windows agent
heartbeat/desired, and downstream DLP signals after endpoints refresh.
4. For automatic signature replenishment, verify
`aw-dlp-ioc-refresh.timer`, the last `aw-dlp-ioc-refresh.service` run,
non-empty `ioc_blacklist.json/csv/sql`, Worktime API `/dlp-ioc/...`
exports, and Windows IOC load counters.
5. If a policy causes noise or misses, use policy rollback through the API; do
not hand-edit endpoint policy files as the normal rollback path.
Manual edits of `C:\Program Files\AWatch-rus\windows\dlp-policy.example.json`
or `C:\ProgramData\AWatch-rus\dlp-policy.json` are diagnostic or emergency
fallback only. If such an edit is unavoidable, document it as configuration
drift and bring the rule back into the central policy engine.
### Response Workflow
1. Capture current state first: command, timestamp, host, service, and exact
failing endpoint. Do not restart before collecting evidence unless the
system is in active resource exhaustion.
2. Find the first broken layer. If buckets are stale, fix collectors before
Grafana. If `aw-worktime-api` is degraded, fix/report that before portal.
3. Separate real outage from presentation drift: dashboards can be stale or
mislabeled while buckets and services are healthy.
4. Apply the narrowest safe recovery: restart a collector/task/service, reduce
unsafe limits, clear process storms, or restore a known-good binary/config.
Back up config/binaries before replacement.
5. Verify with the same failing check plus one upstream and one downstream
check. For collector incidents, require bucket freshness and guard/healthd
consistency, not just one green command.
6. Record closure evidence: root cause, affected layer, action taken, commands
run, post-check results, remaining risk, and rollback path.
### Safety Rules
- Old snapshots, memory, dashboards, and handoff notes are hints; live runtime
evidence wins.
- Never expose passwords, tokens, private host credentials, private URLs, raw
security events, or customer identifiers in incident writeups.
- Do not run broad deploys, full restarts, or `cargo build --workspace` during
incident triage unless the scope demands it and rollback is clear.
- Do not treat `status=ok` as sufficient when freshness, queue depth, or
coverage evidence says otherwise.
- For owner-facing reports, publish only protected gateway/Grafana routes, not
raw `:5600`, `:5610`, `:8720`, or ClickHouse endpoints.
-130
View File
@@ -1,130 +0,0 @@
# Журнал изменений
## release-readiness-v0.3 - 2026-06-03
Назначение этапа: audit package для реестра российского ПО и коммерческого
пилота.
### Добавлено
- `docs/THIRD_PARTY_LICENSES_RU.md` - audit-facing таблица компонент,
версий, лицензий, назначения и рисков.
- `docs/SECURITY_MODEL_RU.md` - роли, trust boundaries, собираемые данные,
хранение и доступ.
- `docs/REGISTRY_RUSSIAN_SO_POSITIONING_RU.md` - безопасное позиционирование
по классам: не SIEM/DLP/EDR как основной класс, а операционный контроль,
техаудит, мониторинг активности и аналитика событий.
- `docs/PILOT_DEPLOYMENT_CHECKLIST_RU.md` - чек-лист внедрения у заказчика.
- `docs/RELEASE_READINESS_V0.3_RU.md` - состав audit package v0.3.
## release-readiness-v0.2 - 2026-06-03
Назначение этапа: усилить пакет для коммерческого релиза и реестра российского
ПО за счет машинного SBOM, проверки release assets, пилотного акта приемки и
корректного позиционирования pfSense.
### Добавлено
- `scripts/generate_release_sbom_v0_2.sh` - генерация CycloneDX/SPDX SBOM
artifacts в `dist/release-v0.2/`.
- `scripts/verify_release_assets.sh` - проверка `SHA256SUMS*.txt` и detached
signature release assets.
- `.github/workflows/release-assets.yml` - CI self-test checksum/signature
verifier и генерации SBOM.
- `docs/RELEASE_READINESS_V0.2_RU.md` - контрольная карта v0.2.
- `docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md` - шаблон акта приемки пилота.
- `docs/NETWORK_PERIMETER_PFSENSE_RU.md` - pfSense как опциональный
интеграционный слой, не обязательная часть продукта.
## release-readiness-v0.1 - 2026-06-03
Назначение этапа: довести AWatch-rus до проверяемого release-readiness
пакета для пилота, экспертной оценки и последующей публикации релиза без
раскрытия приватного коммерческого контура.
### Добавлено
- UI-блок портала `Готовность системы`: статус `OK/WARN/FAIL`, дата bundle,
статус checksum, статус detached signature, fingerprint публичного ключа и
ручная кнопка проверки bundle.
- Prometheus/Grafana alert rules:
`detmir_readiness_ok == 0` и
`detmir_readiness_signature_verified == 0`.
- Подпись readiness bundle через detached signature `sha256sums.txt.sig`.
- Retention для readiness archives и unit-тесты на подпись/retention.
- `docs/RELEASE_READINESS_V0.1_RU.md` - сводный акт готовности релиза v0.1.
- `docs/SBOM_V0.1_RU.md` - human-readable SBOM profile и команды генерации
машинных SBOM artifacts.
- `docs/PORTAL_SCREENSHOTS_RU.md` - перечень обезличенных screenshots портала.
- `docs/diagrams/release-readiness-v0.1.md` - схема release-readiness path.
### Проверено
- `cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check`.
- `cargo test --manifest-path adk-rust/Cargo.toml -p detmir-readiness -p detmir-portal`.
- `cargo clippy --manifest-path adk-rust/Cargo.toml -p detmir-readiness -p detmir-portal --all-targets -- -D warnings`.
- `node --check adk-rust/crates/detmir-portal/src/static/app.js`.
- Ansible syntax-check для AW server и AWatch-rus portal deploy playbooks.
- Runtime deployment на проектные сервисы AWatch-rus без изменения pfSense или
Proxmox platform layer.
## v1.0.1-public-review - 2026-06-03
Назначение релиза: публичный пакет для экспертной оценки AWatch-rus и
подготовки к реестровой проверке. Релиз не меняет работающий коммерческий
runtime AWatch-rus; изменения относятся к source/release package, документации,
обезличиванию и проверяемости поставки.
### Добавлено
- `docs/INSTALL_FOR_EXPERT_RU.md` - воспроизводимая установка: чистая VM,
сборка, установка, проверка, ожидаемый результат.
- `docs/EXPERT_TEST_SCENARIO_RU.md` - ручной сценарий экспертной проверки:
вход в web UI, status, clipboard/USB/print, DLP incident, case/evidence,
export report.
- `docs/SBOM_RELEASE_CHECKLIST_RU.md` - checklist подготовки SBOM/release.
- `docs/RELEASE_AUDIT_2026-06.md` - audit приватных маркеров и секретов.
- `docs/RELEASE_MANIFEST_2026-06.md` - manifest release artifacts, checksums,
SBOM inputs и выполненных gates.
- `docs/RELEASE_NOTES_2026-06.md` - release notes для GitHub release.
### Изменено
- Публичные docs, examples, defaults и test fixtures обезличены: live hostnames,
private IPs, operator domains, local operator home paths, private root paths,
live case IDs и forensic paths заменены на placeholders/TEST-NET значения.
- README и register docs теперь ведут эксперта по полному маршруту:
описание продукта -> установка -> ручной сценарий -> audit -> SBOM/release
checklist.
- Install-kit пересобирается из sanitized source files и валидируется через
Rust tooling.
### Удалено из tracked source
- `.planning` generated artifacts.
- Распакованный `install-kit-awindows-20260427-211240/` как tracked source.
Install-kit archives публикуются как GitHub Release assets.
### Проверено
- `cargo build --release --workspace` в отдельном target-dir.
- `scripts/check_detmir_rust_release_artifacts.sh`: все Rust release binaries
найдены.
- `scripts/rebuild_install_kit.sh` и `scripts/validate_install_kit.sh`:
install-kit пересобран и валиден.
- `scripts/quality-gate.sh`: `OK`.
- Public hygiene grep по tracked release surface: старые приватные маркеры
отсутствуют; оставшиеся root-word совпадения классифицированы как
ложноположительные technical path terms.
### Известные ограничения
- `v1.0.1-public-review` является source/review release. Коммерческий runtime
AWatch-rus продолжает использовать private runtime config вне Git.
- Python остается для Telegram runtime, OCR/content-analysis, 1C/AI/ETL и MCP
helpers. Это отражено в registry docs как допустимое исключение.
- pfSense/infrastructure runtime не менялся в рамках этого релиза.
## v1.0.0 - 2026-04-25
Базовый professional baseline с install-kit artifacts.
+2 -29
View File
@@ -1,9 +1,5 @@
# Contributing
GitHub is public mirror validation only. Primary registry release evidence is
produced separately on the Russian build-runner and documented under
`docs/registry/`.
## Branching
- Работайте в feature-ветке, не пушьте напрямую в `main`.
@@ -19,36 +15,13 @@ produced separately on the Russian build-runner and documented under
## Required checks before PR
- `cargo fmt --all --check` from `adk-rust/`.
- `cargo test --workspace` from `adk-rust/`, unless the PR is documentation-only
and the skip is documented.
- `cargo clippy --workspace --all-targets -- -D warnings` from `adk-rust/`.
- `bash -n` для всех changed `*.sh`.
- `bash scripts/registry_readiness_check.sh` when registry docs/process changes.
- `node scripts/deployment-readiness-smoke.mjs` when Node.js is available.
- `node scripts/pilot-validation-smoke.mjs` when Node.js is available.
- `bash -n` для всех `*.sh`.
- `Invoke-ScriptAnalyzer` для `windows/*.ps1`, `windows/*.psm1`, `windows/*.psd1`.
- Проверка, что нет секретов (`secrets/deploy.secrets.env` не должен быть в
индексе git).
- Проверка, что нет секретов (`secrets/deploy.secrets.env` не должен быть в индексе git).
- Обновлены инструкции и runbook при изменении поведения.
## Registry-readiness docs
- Registry-readiness documents live in `docs/registry/`.
- Public GitHub CI is not registry release evidence.
- Registry release evidence must be generated on the Russian build-runner.
- GitHub remains public mirror validation only.
## Secrets and personal data
- Do not commit secrets, tokens, passwords, cookies or private keys.
- Do not commit personal data.
- Do not commit real employee logs.
- Use demo/anonymized evidence for issues, PRs, docs and screenshots.
## PR content
- Изменения и обоснование.
- Риск и rollback.
- Какие команды валидации были выполнены.
- Какие проверки были пропущены и почему, если пропуск был необходим.
-8
View File
@@ -1,8 +0,0 @@
# Установка экземпляра для эксперта
Основной пошаговый документ находится здесь:
- [`docs/INSTALL_FOR_EXPERT_RU.md`](docs/INSTALL_FOR_EXPERT_RU.md)
Файл в корне оставлен как совместимая ссылка для внешних проверок и старых
закладок.
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-40
View File
@@ -1,40 +0,0 @@
# Описание программного продукта AWatch-rus
`AWatch-rus` - программный комплекс операционного контроля,
технического аудита, оценки трудоотдачи сотрудников и мониторинга
ИТ-инфраструктуры.
## Назначение
Продукт предназначен для централизованного контроля состояния рабочих мест,
оценки активности сотрудников, анализа загрузки подразделений,
контроля серверных сервисов, ActivityWatch telemetry, рабочих интервалов,
операторских runbook-проверок и прикладных событий расследования.
## Коммерческие модули
- `AWatch-rus Workforce` - ежедневный управленческий слой: активность,
активное время, простои, RDP/1C/рабочие приложения, загрузка сотрудников и
отчеты для руководителя.
- `AWatch-rus Security` - прикладной слой ИБ: DLP-сигналы, evidence metadata,
доступные скриншоты, review/case queue и audit действий оператора.
- `AWatch-rus Forensics` - расследовательский слой: цепочки событий,
Hayabusa/offline-разбор, кейсы и материалы для внутреннего расследования.
## Основные функции
- сбор и нормализация ActivityWatch telemetry;
- оценка полезной активности и загрузки сотрудников;
- управленческие отчеты по worktime, RDP, 1C и рабочим приложениям;
- контроль свежести данных и состояния сборщиков;
- учет активности пользователей и удаленных сессий;
- витрины Grafana и портал оператора;
- runbook automation, health-check, SLO и безопасный auto-heal;
- evidence workflow для прикладных инцидентов;
- аудит действий оператора.
## Позиционирование
Продукт относится к классу средств управления ИТ-службой,
ИТ-инфраструктурой и ИТ-активами. Продукт не заявляется как
сертифицированная DLP, SIEM, EDR/XDR или средство защиты информации.
+78 -811
View File
@@ -1,839 +1,106 @@
# AWatch-rus
[![CI](https://github.com/igor04091968/AWatch-rus/actions/workflows/ci.yml/badge.svg)](https://github.com/igor04091968/AWatch-rus/actions/workflows/ci.yml)
[![Security](https://github.com/igor04091968/AWatch-rus/actions/workflows/security.yml/badge.svg)](https://github.com/igor04091968/AWatch-rus/actions/workflows/security.yml)
[![Coverage](https://github.com/igor04091968/AWatch-rus/actions/workflows/coverage.yml/badge.svg)](https://github.com/igor04091968/AWatch-rus/actions/workflows/coverage.yml)
Практический каркас проекта для повторного развёртывания ActivityWatch Server в новом окружении с LXC-контейнером на Proxmox, русифицированным Web UI, systemd-юнитами, шаблонными скриптами деплоя и эксплуатационной документацией.
AWatch-rus - программный комплекс операционного контроля,
технического аудита, оценки трудоотдачи сотрудников и мониторинга
корпоративной ИТ-инфраструктуры на базе ActivityWatch, Rust-сервисов
автоматизации, Grafana/Prometheus-витрин и модулей расследования инцидентов.
## Что входит
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ,
не заявляет ML/LLM UEBA и не подменяет штатные средства защиты, хотя
DLP-сигналы, evidence и Hayabusa используются как аналитические и
расследовательские слои.
- `docs/preparation.md` — подготовка инфраструктуры и входных параметров.
- `docs/codebase-onboarding.md` — обзор структуры репозитория и маршрут изучения для новичка.
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
- `docs/runbook.md` — быстрый runbook для оператора.
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
- `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`.
- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`.
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
- `docs/dlp-aggregator.md` — прототип централизованной агрегации DLP/file-operation событий.
- `docs/dlp-reliability-roadmap.md` — roadmap повышения надёжности DLP-коллекторов.
- `docs/powershell-analysis.md` — статический анализ работоспособности DLP PowerShell-скриптов.
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, Windows/RDP DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
- `scripts/aggregate_dlp_events.py` — сбор `aw-file-operations_*` и `aw-dlp-incidents_*` в SQLite/PostgreSQL.
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
- `scripts/install_aw_linux_remote_worker.sh` — полный Linux remote-worker installer.
## Назначение
## Базовый сценарий
- AWatch-rus Workforce: активность сотрудников, загрузка, RDP/1C/рабочие
приложения и управленческие отчеты для владельца бизнеса.
- AWatch-rus Security: DLP-сигналы, evidence, очередь кейсов и audit действий оператора без заявления продукта как сертифицированной СЗИ.
- AWatch-rus Forensics: цепочки событий, Hayabusa/offline-разбор и материалы для внутреннего расследования.
- Контроль доступности и свежести данных ActivityWatch.
- Учет активного времени, Windows RDP-сессий окон, приложений и рабочих интервалов а также активности пользователей в Linux/Unix системах.
- витрины Grafana для администратора, оператора ИБ и руководителя(dashboards).
- Автоматизация runbook-проверок, health-check, SLO и безопасного auto-heal.
- Сбор evidence по инцидентам и аудит действий оператора.
1. Подготовить параметры окружения по `docs/preparation.md`.
2. Заполнить единый файл секретов `secrets/deploy.secrets.env` (автоподключение).
3. На узле Proxmox создать контейнер через `proxmox/create-ct.sh`.
4. Загрузить артефакты и серверный env в CT через `proxmox/push-aw-artifacts.sh`.
5. Внутри контейнера выполнить `aw-server/install_aw_server.sh`.
6. Применить русификацию Web UI через `aw-server/apply_webui_ru_patch.sh`.
7. Проверить API, Web UI и состояние systemd по `docs/runbook.md`.
8. Развернуть Windows-клиентов через `windows/deploy-ensemble.ps1`.
9. Проверить итог через `windows/validate-deployment.ps1`.
## Rust-first runtime
Для полного Ansible-сценария “с нуля” в Proxmox используйте:
Основной серверный runtime AWatch-rus переведен на Rust(ранее использовался инструментарий powershell):status/check/auto-heal,
SLO, worktime, DLP server-side helpers, evidence и install-kit tooling.
- `ansible/provision_proxmox_ct_and_deploy_aw.yml`
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` (массово по матрице CT)
Оставшиеся PowerShell runtime/fallback/installer/repair scripts сохраняются
как документированный слой отката, установки и поддержки до отдельной задачи
удаления с burn-in периодом, canary test, rollback plan и acceptance gate.
Для централизованного деплоя Windows/RDP-клиентов через WinRM:
Python, присутствующий в коде репозитория, остается для вспомогательных направлений: Telegram bot
runtime(для оперативного оповещения), OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти части не являются ядром Rust-first runtime.
- `ansible/deploy_aw_windows.yml`
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible
JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не
рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в
текущий основной UI, но возможна их интеграция в проект.
Для внешнего pfSense poller'а:
## Product Evolution
- `ansible/deploy_aw_pfsense_poller.yml`
AWatch-rus является рабочей платформой Workforce + Security + Forensics.
Архитектура предусматривает расширение на агентные и agentless-источники
данных. Planned/Future элементы ниже не являются реализованной функциональностью
и не должны трактоваться как готовые collectors или integrations.
Для Linux desktop/admin host, который должен слать watcher'ы на удалённый AW server:
Implemented:
- `docs/linux-client.md`
- `scripts/install_aw_linux_client.sh`
- Rust Backend.
- Rust Agent.
- HTML/HTMX Portal.
- Role-based Pilot v1 contracts.
- Product architecture page `/portal/architecture`.
- Workforce reports.
- UEBA v1.
- Forensics reporting.
- pfSense contract/readiness layer со статусом `contract_only`.
Для полного Linux remote-worker сценария:
Planned:
- `docs/linux-remote-worker.md`
- `scripts/install_aw_linux_remote_worker.sh`
- Provider detail expansion under `/portal/architecture`.
- PowerShell Provider как planned/agentless direction, не как возврат новых
runtime-функций на PowerShell.
- SSH Provider.
- Syslog Provider.
- 1C Provider как формализация текущего file-based 1C analytics направления.
- Russian OS support validation.
Для режима “только консоль/ssh” без GUI watcher'ов:
Future:
- `docs/console-ssh-logger.md`
- `scripts/install_aw_console_ssh_logger.sh`
- Extended Enterprise connectors.
- SCUD/VPN integrations.
- React/TypeScript Enterprise UI.
- Tauri Desktop Forensics.
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
## Pilot v1 demo
- `secrets/deploy.secrets.env`
Pilot v1 demo показывает AWatch-rus как рабочую платформу Workforce Analytics +
Security Analytics + Forensics для ролей `executive`, `manager`, `security`,
`forensics` и `admin`.
## Принципы
Демо-материалы:
- Никаких реальных секретов, токенов и боевых IP в репозитории.
- Все переменные вынесены в `.example` / `.env` шаблоны.
- Документация ориентирована на повторяемое развёртывание, а не на одноразовую ручную установку.
- Rollback и backup описаны как обязательная часть каждой операции.
- [сценарий Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md);
- [сценарий руководителя](docs/demo/DEMO_SCENARIO_EXECUTIVE_RU.md);
- [сценарий ИБ](docs/demo/DEMO_SCENARIO_SECURITY_RU.md);
- [сценарий расследований](docs/demo/DEMO_SCENARIO_FORENSICS_RU.md);
- [demo seed data](docs/fixtures/pilot-v1-demo/demo-seed-data.json);
- [demo evidence pack](docs/fixtures/pilot-v1-demo/evidence-pack/);
- [пример итогового demo-отчета](docs/DEMO_REPORT_EXAMPLE_RU.md);
- [ценность пилота для заказчика](docs/PILOT_VALUE_PROPOSITION_RU.md);
- [преддемо-runbook](docs/DEMO_RUNBOOK_RU.md).
## Минимальная структура
Pilot validation:
- CT/LXC на Debian 12
- ActivityWatch Server Rust release
- Web UI override в `/opt/activitywatch/webui-ru`
- systemd unit `activitywatch-server.service`
- bind/listen через переменные окружения
- [чеклист проверки пилота](docs/PILOT_VALIDATION_CHECKLIST_RU.md);
- [pilot freeze readiness](docs/PILOT_FREEZE_READINESS_RU.md);
- [gap analysis пилота](docs/PILOT_GAP_ANALYSIS_RU.md);
- [вопросы для discovery с заказчиком](docs/CUSTOMER_DISCOVERY_QUESTIONS_RU.md);
- [критерии успеха пилота](docs/PILOT_SUCCESS_CRITERIA_RU.md);
- [конкурентное позиционирование](docs/COMPETITIVE_POSITIONING_RU.md).
## Ограничения
Границы показа:
- Интеграции с InfluxDB/Grafana/LDAP оставлены как следующий слой, не как обязательная база.
- pfSense показывается только как `contract_only/readiness`, без заявления
production ingestion или SIEM;
- pfSense в текущем пилоте допускается только как contract/readiness/optional
integration layer;
- UEBA Score v1 является прозрачной rule-based моделью, без ML/LLM;
- demo fixtures не содержат реальных IP-адресов, hostname, логинов, ФИО,
подразделений заказчика или событий безопасности;
- planned/future providers не являются реализованными collectors.
## Быстрые ссылки
## Что видит оператор
- Работал ли пользователь за компьютером или в удаленной сессии.
- Когда была активность, простой и переключение окон.
- Какие приложения, сайты и процессы чаще всего были в работе.
- Есть ли события, важные для ИБ: копирование, печать, USB, подозрительные сайты.
- Не пропали ли данные с рабочих компьютеров и RDP-сессий.
## Кому это полезно в работе
- Владельцу и руководителю - видеть активность, загрузку команды,
простои, перегрузки и рабочие приложения.
- ИБ - заметить DLP-сигналы и подозрительную активность, а при отсутствии специалистов по ИБ - дать оповещение бизнесу о проблемах с информационной безопасностью, для привлечения специалистов по ИБ.
- Администратору - проверить, что сервер и все узлы информационных потоков работают стабильно, оценить состояние внутренней сети с точки зрения ИБ.
## Интерфейс
Скриншоты ниже подготовлены на демонстрационных данных: без реальных IP-адресов,
hostname, логинов, сотрудников, подразделений заказчика и событий безопасности.
Все демонстрационные скриншоты от 2026-06-06 лежат в
[docs/screenshots/](docs/screenshots/):
[главный вывод](docs/screenshots/01-executive-overview.png),
[карта рисков](docs/screenshots/02-risk-heatmap.png),
[безопасность](docs/screenshots/03-security-view.png),
[эксплуатация](docs/screenshots/04-operations-view.png),
[пакет расследования](docs/screenshots/05-investigation-pack.png),
[итоговый отчет](docs/screenshots/06-markdown-report.png),
[архитектура продукта](docs/screenshots/07-product-architecture.png).
Сводный список и правила публикации: [docs/PORTAL_SCREENSHOTS_RU.md](docs/PORTAL_SCREENSHOTS_RU.md).
### Главный вывод
![Главный вывод AWatch-rus](docs/screenshots/01-executive-overview.png)
Руководитель видит главный риск первым, затем сводку по достоверности
показателей, полноте данных, кандидатам на проверку и рискам подразделений.
### Карта рисков подразделений
![Карта рисков подразделений](docs/screenshots/02-risk-heatmap.png)
Карта рисков показывает, где одновременно проседают активность, покрытие
агентов, доверие к показателям и количество ситуаций для проверки.
### Представление безопасности
![Представление безопасности](docs/screenshots/03-security-view.png)
ИБ получает очередь кандидатов на проверку, связанные расследования и материалы
без просмотра сырых логов и без автоматического принятия решений.
### Представление эксплуатации
![Представление эксплуатации](docs/screenshots/04-operations-view.png)
Эксплуатация видит полноту данных, качество агентского сбора, ошибки сбора и
понятный статус событий безопасности через ClickHouse.
### Пакет расследования
![Пакет расследования](docs/screenshots/05-investigation-pack.png)
Пакет расследования связывает материалы, историю проверки и итоговый вывод,
который ответственный сотрудник может подтвердить вручную.
### Итоговый отчет
![Итоговый отчет](docs/screenshots/06-markdown-report.png)
Markdown-отчет собирает главный вывод, риски подразделений, материалы
расследований и рекомендации в формате, удобном для передачи руководителю.
### Архитектура продукта
![Архитектура продукта](docs/screenshots/07-product-architecture.png)
Страница `/portal/architecture` показывает текущие компоненты, planned
extensions и future-направления без создания новых API или фиктивных
collectors.
## Если дашборд пустой
Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже.
## Поставка и регистрация
- Ежедневная/еженедельная проверка эксплуатационного контура:
[матрица проверки контура](docs/CONTOUR_CHECK_MATRIX_RU.md).
- Проверка после инженерных изменений: cargo/security gates, browser smoke и
production smoke:
[эксплуатационный validation runbook](docs/OPERATIONS_VALIDATION_RUNBOOK_RU.md).
- Enterprise deployment documentation:
[deployment guide](docs/ENTERPRISE_DEPLOYMENT_GUIDE_RU.md),
[topologies](docs/DEPLOYMENT_TOPOLOGIES_RU.md),
[sizing](docs/SIZING_GUIDE_RU.md),
[backup and recovery](docs/BACKUP_AND_RECOVERY_RU.md),
[operations runbook](docs/OPERATIONS_RUNBOOK_RU.md),
[security hardening](docs/SECURITY_HARDENING_RU.md),
[acceptance checklist](docs/ENTERPRISE_ACCEPTANCE_CHECKLIST_RU.md).
- Registry readiness documentation:
[product passport](docs/REGISTRY_PRODUCT_PASSPORT_RU.md),
[architecture](docs/REGISTRY_ARCHITECTURE_RU.md),
[functional scope](docs/REGISTRY_FUNCTIONAL_SCOPE_RU.md),
[dependency statement](docs/REGISTRY_DEPENDENCY_STATEMENT_RU.md),
[deployment model](docs/REGISTRY_DEPLOYMENT_MODEL_RU.md),
[commercial positioning](docs/REGISTRY_COMMERCIAL_POSITIONING_RU.md),
[readiness checklist](docs/REGISTRY_READINESS_CHECKLIST_RU.md).
### Подготовка к реестру российского ПО
- Основной российский Git-контур / Gitea-дубликат GitHub-репозитория:
`https://git.iri1968.dpdns.org/awatch-rus/AWatch-rus`.
- GitHub используется как публичное зеркало и public validation surface.
- Gitea operator account: `igor`; пароль/токены не хранятся в репозитории.
- Доказательная документация:
[docs/registry/](docs/registry/REGISTER_RU_SOFTWARE_READINESS_RU.md).
- Gitea Wiki используется только как навигация, не как единственный источник
документов.
- Российский build-runner и release evidence описаны в
[RU_BUILD_RUNNER_READINESS_RU.md](docs/registry/RU_BUILD_RUNNER_READINESS_RU.md).
- Текущий status freeze проекта:
[docs/PROJECT_STATUS_RU.md](docs/PROJECT_STATUS_RU.md).
- Остаточные риски:
[docs/RESIDUAL_RISKS_RU.md](docs/RESIDUAL_RISKS_RU.md).
- План публичных GitHub issues:
[docs/PUBLIC_ISSUES_PLAN_RU.md](docs/PUBLIC_ISSUES_PLAN_RU.md).
- GitHub remains public mirror only.
### Public engineering transparency
- Public CI, coverage baseline and security scanning are enabled on GitHub.
- Issue templates, PR template and public roadmap are maintained for process
visibility.
- Public secret scanning policy:
[docs/SECURITY_SCANNING_POLICY_RU.md](docs/SECURITY_SCANNING_POLICY_RU.md).
- GitHub remains public mirror validation only.
- Primary registry contour remains Gitea plus the Russian build-runner.
- Quality status:
[docs/QUALITY_STATUS_RU.md](docs/QUALITY_STATUS_RU.md).
- Residual risks:
[docs/RESIDUAL_RISKS_RU.md](docs/RESIDUAL_RISKS_RU.md).
- Public issues plan:
[docs/PUBLIC_ISSUES_PLAN_RU.md](docs/PUBLIC_ISSUES_PLAN_RU.md).
- Public issue templates are prepared and real GitHub issue URLs are recorded
in the manifest; this improves roadmap visibility but does not claim
community adoption:
[creation runbook](docs/PUBLIC_ISSUES_CREATION_RUNBOOK_RU.md),
[manifest](docs/public-issues/public-issues-manifest.json).
### Engineering governance and residual risks
- Review checklist:
[docs/REVIEW_CHECKLIST_RU.md](docs/REVIEW_CHECKLIST_RU.md).
- Residual risks register:
[docs/RESIDUAL_RISKS_RU.md](docs/RESIDUAL_RISKS_RU.md).
- Public issues plan:
[docs/PUBLIC_ISSUES_PLAN_RU.md](docs/PUBLIC_ISSUES_PLAN_RU.md).
- Public issues creation runbook:
[docs/PUBLIC_ISSUES_CREATION_RUNBOOK_RU.md](docs/PUBLIC_ISSUES_CREATION_RUNBOOK_RU.md).
- Public issues manifest:
[docs/public-issues/public-issues-manifest.json](docs/public-issues/public-issues-manifest.json).
- Advisory branch protection policy:
[docs/BRANCH_PROTECTION_POLICY_RU.md](docs/BRANCH_PROTECTION_POLICY_RU.md).
- Branch protection evidence template:
[docs/BRANCH_PROTECTION_EVIDENCE_RU.md](docs/BRANCH_PROTECTION_EVIDENCE_RU.md).
- PR-based review workflow:
[docs/PR_REVIEW_WORKFLOW_RU.md](docs/PR_REVIEW_WORKFLOW_RU.md).
- PR review evidence template:
[docs/PR_REVIEW_EVIDENCE_RU.md](docs/PR_REVIEW_EVIDENCE_RU.md).
- CODEOWNERS and PR template are maintained for review routing and public
change-control visibility.
- Visible external code review is still pending until public reviewed PRs exist.
- Branch protection policy is documented as advisory; it is not claimed as
enabled here.
- [Позиционирование для реестра российского ПО](docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md)
- [Сведения для подачи в реестр](REGISTER_RU_SOFTWARE.md)
- [Registry product passport](docs/REGISTRY_PRODUCT_PASSPORT_RU.md)
- [Registry architecture](docs/REGISTRY_ARCHITECTURE_RU.md)
- [Registry functional scope](docs/REGISTRY_FUNCTIONAL_SCOPE_RU.md)
- [Registry dependency statement](docs/REGISTRY_DEPENDENCY_STATEMENT_RU.md)
- [Registry deployment model](docs/REGISTRY_DEPLOYMENT_MODEL_RU.md)
- [Registry commercial positioning](docs/REGISTRY_COMMERCIAL_POSITIONING_RU.md)
- [Registry readiness checklist](docs/REGISTRY_READINESS_CHECKLIST_RU.md)
- [Остаточные риски](docs/RESIDUAL_RISKS_RU.md)
- [План публичных issues](docs/PUBLIC_ISSUES_PLAN_RU.md)
- [Описание продукта](PRODUCT_DESCRIPTION_RU.md)
- [Журнал изменений](CHANGELOG_RU.md)
- [Установка для эксперта](INSTALL_FOR_EXPERT_RU.md)
- [Сценарий экспертной проверки](docs/EXPERT_TEST_SCENARIO_RU.md)
- [Release manifest 2026-06](docs/RELEASE_MANIFEST_2026-06.md)
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
- [Коммерческие модули AWatch-rus](docs/COMMERCIAL_MODULES_RU.md)
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
- [Пакет пилота для заказчика](docs/CUSTOMER_PILOT_PACK_RU.md)
- [Enterprise deployment guide](docs/ENTERPRISE_DEPLOYMENT_GUIDE_RU.md)
- [Deployment topologies](docs/DEPLOYMENT_TOPOLOGIES_RU.md)
- [Sizing guide](docs/SIZING_GUIDE_RU.md)
- [Backup and recovery](docs/BACKUP_AND_RECOVERY_RU.md)
- [Operations runbook](docs/OPERATIONS_RUNBOOK_RU.md)
- [Security hardening](docs/SECURITY_HARDENING_RU.md)
- [Enterprise acceptance checklist](docs/ENTERPRISE_ACCEPTANCE_CHECKLIST_RU.md)
- [Pilot v1.0](docs/PILOT_V1_RU.md)
- [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md)
- [Demo scenario: руководитель](docs/demo/DEMO_SCENARIO_EXECUTIVE_RU.md)
- [Demo scenario: ИБ](docs/demo/DEMO_SCENARIO_SECURITY_RU.md)
- [Demo scenario: расследования](docs/demo/DEMO_SCENARIO_FORENSICS_RU.md)
- [Demo report example](docs/DEMO_REPORT_EXAMPLE_RU.md)
- [Pilot value proposition](docs/PILOT_VALUE_PROPOSITION_RU.md)
- [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md)
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
- [Pilot validation checklist](docs/PILOT_VALIDATION_CHECKLIST_RU.md)
- [Pilot gap analysis](docs/PILOT_GAP_ANALYSIS_RU.md)
- [Customer discovery questions](docs/CUSTOMER_DISCOVERY_QUESTIONS_RU.md)
- [Pilot success criteria](docs/PILOT_SUCCESS_CRITERIA_RU.md)
- [Competitive positioning](docs/COMPETITIVE_POSITIONING_RU.md)
- [Roadmap conformance audit](docs/ROADMAP_CONFORMANCE_AUDIT_RU.md)
- [Browser conformance smoke](docs/BROWSER_CONFORMANCE_RU.md)
- [Production readiness портала](docs/PRODUCTION_READINESS_RU.md)
- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md)
- [Risk Narrative](docs/RISK_NARRATIVE_RU.md)
- [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md)
- [Rust Agent baseline](docs/RUST_AGENT_BASELINE_RU.md)
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
- [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md)
- [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md)
- [Экосистема сборщиков](docs/COLLECTOR_ECOSYSTEM_RU.md)
- [Стратегия внедрения](docs/DEPLOYMENT_STRATEGY_RU.md)
- [Стратегия платформ](docs/PLATFORM_STRATEGY_RU.md)
- [Ролевая модель портала](docs/ROLES_RU.md)
- [UEBA Score v1](docs/UEBA_SCORE_RU.md)
- [pfSense integration readiness](docs/PFSENSE_INTEGRATION_RU.md)
- [Сценарий демонстрации заказчику](docs/CUSTOMER_DEMO_SCENARIO_RU.md)
- [Аудит готовности к пилоту](docs/PILOT_READINESS_AUDIT_RU.md)
- [Позиционирование для первой встречи](docs/SALES_POSITIONING_RU.md)
- [Преддемо-сценарий](docs/DEMO_RUNBOOK_RU.md)
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
- [Архитектура](docs/ARCHITECTURE_RU.md)
- [Установка](docs/INSTALL_RU.md)
- [Руководство администратора](docs/ADMIN_GUIDE_RU.md)
- [Руководство оператора](docs/OPERATOR_GUIDE_RU.md)
- [Лицензия](LICENSE)
## Техническая документация
Для эксплуатации и настройки:
- [Wiki home](docs/wiki/Home.md)
- [Getting Started and Prerequisites](docs/wiki/Getting-Started-and-Prerequisites.md)
- [Server Infrastructure](docs/wiki/Server-Infrastructure.md)
- [Operations, CI/CD, and Quality Assurance](docs/wiki/Operations-CI-CD-and-Quality-Assurance.md)
- [Full deployment manual](docs/FULL_DEPLOYMENT_MANUAL_RU.md)
Для мониторинга:
- [Grafana and Prometheus Monitoring Stack](docs/wiki/Grafana-and-Prometheus-Monitoring-Stack.md)
- [Grafana dashboards guide](docs/GRAFANA_DASHBOARDS_RU.md)
- [План внедрения ClickHouse Dictionaries для DetMir](docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md)
- [ClickHouse Workforce scaffold](clickhouse-workforce/README.md)
- [Prometheus Exporter](docs/wiki/Prometheus-Exporter.md)
Для сборщиков и интерфейса:
- [Windows Collector Suite](docs/wiki/Windows-Collector-Suite.md)
- [Worktime API and UI Bridge](docs/wiki/Worktime-API-and-UI-Bridge.md)
- [Russian WebUI Patch and Localization](docs/wiki/Russian-WebUI-Patch-and-Localization.md)
- Актуальные ссылки по этой тематике: https://www.securitylab.ru/analytics/573771.php (Как собрать ролевую модель доступа при хаосе в инфраструктуре)
---
## 📊 **ОЦЕНКА ЗРЕЛОСТИ И КАЧЕСТВА ПРОЕКТА** (обновлено 22 июня 2026)
### **1️⃣ ОБЩИЕ МЕТРИКИ ПРОЕКТА**
| Метрика | Значение | Тренд | Оценка |
|---------|----------|-------|--------|
| **Возраст проекта** | 58 дней | ✅ Active | Молодой, но стабильный |
| **Размер репо** | ~11 MB | ✅ Compact | Хорошо структурирован |
| **Основной язык** | Rust | ✅ Production | Правильный выбор |
| **Лицензия** | Apache 2.0 | ✅ Open-friendly | Коммерчески дружелюбно |
| **Звезды** | 3 ⭐ | ⚠️ Нишевой продукт | Целевая аудитория |
| **Форки** | 2 | ⚠️ Низко | Early-stage / pilot-stage OSS |
| **Open Issues** | 1 | ⚠️ Низкая публичная активность | Issue templates уже есть |
| **Последний коммит** | 22 июня 2026 | ✅ **СЕГОДНЯ** | **АКТИВНО РАЗРАБАТЫВАЕТСЯ** |
| **Проектный статус** | main branch | ✅ Единая стратегия | Production-ready focus |
| **Public CI** | passed | ✅ Visible | GitHub Actions mirror validation |
| **Coverage workflow** | passed | ✅ Visible | Baseline workflow, threshold позже |
| **Security workflow** | passed | ✅ Visible | cargo audit/deny + secret scan |
| **Secret scan** | hardened + passed | ✅ Conservative | Fail-closed public scanner |
---
### **2️⃣ АРХИТЕКТУРНАЯ ЗРЕЛОСТЬ: 9.2/10** 🏗️
#### ✅ **Rust-first Migration (ПОЛНОСТЬЮ ЗАВЕРШЕНА)**
```
Миграция на Rust: 32+ фазы, ВСЕ ЗАВЕРШЕНЫ ✅
Phase 0-7: Foundation & Read-only [DONE ✅]
Phase 8-17: State orchestration & Telegram [DONE ✅]
Phase 18-26: DLP & Hayabusa services [DONE ✅]
Phase 27-32: AW health & maintenance [DONE ✅]
Текущий статус: 30+ Rust crates в production
- detmir-auto ✅
- detmir-status ✅
- detmir-check ✅
- dlp-policy-engine ✅
- dlp-case-management ✅
- dlp-compliance ✅
- aw-db-maintenance ✅ (НОВОЕ: vacuum с integrity check!)
- aw-hayabusa-tools ✅
```
#### 🆕 **НОВОЕ: SQLite VACUUM & MAINTENANCE**
```rust
adk-rust/crates/aw-db-maintenance:
- Trim mode: удаление старых allowlisted rows (по умолчанию dry-run)
- VACUUM mode: компактирование DB с PRAGMA integrity_check
- Lock-based concurrency protection
- Service stop/start guards
- Backup-before-delete policy
- Rollback из /var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db
```
**Это серьёзное, enterprise-grade решение для production DB maintenance.**
---
### **3️⃣ ДОКУМЕНТАЦИЯ: EXCEPTIONAL (10/10)** 📚
#### 🎯 **Полнота документации**
```
КЛАССИФИКАЦИЯ ДОКУМЕНТОВ:
DEPLOYMENT:
✅ ENTERPRISE_DEPLOYMENT_GUIDE_RU.md
✅ DEPLOYMENT_TOPOLOGIES_RU.md
✅ SIZING_GUIDE_RU.md
✅ BACKUP_AND_RECOVERY_RU.md
✅ SECURITY_HARDENING_RU.md
✅ FULL_DEPLOYMENT_MANUAL_RU.md
REGISTRY (для реестра РПО):
✅ REGISTRY_PRODUCT_PASSPORT_RU.md
✅ REGISTRY_ARCHITECTURE_RU.md
✅ REGISTRY_FUNCTIONAL_SCOPE_RU.md
✅ REGISTRY_DEPENDENCY_STATEMENT_RU.md
✅ REGISTRY_DEPLOYMENT_MODEL_RU.md
✅ REGISTRY_COMMERCIAL_POSITIONING_RU.md
PILOT & VALIDATION:
✅ PILOT_V1_RU.md
✅ PILOT_DEMO_SCENARIO_RU.md
✅ PILOT_FREEZE_READINESS_RU.md (НОВОЕ!)
✅ PILOT_VALIDATION_CHECKLIST_RU.md
✅ PILOT_SUCCESS_CRITERIA_RU.md
OPERATIONAL:
✅ OPERATIONS_RUNBOOK_RU.md
✅ OPERATIONS_RUNBOOK_WORKTIME_RU.md
✅ ADMIN_GUIDE_RU.md
✅ OPERATOR_GUIDE_RU.md
✅ ARCHITECTURE_RU.md
RISK & SECURITY:
✅ THREAT_MODEL_RU.md
✅ SECURITY_HARDENING_RU.md
✅ RISK_NARRATIVE_RU.md
✅ PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md
TECHNICAL:
✅ Wiki (Getting Started, Infrastructure, CI/CD, QA)
✅ Grafana dashboards guide
✅ Windows Collector Suite
✅ adk-rust/RUNBOOK.md (32 фазы миграции!)
SALES & POSITIONING:
✅ COMPETITIVE_POSITIONING_RU.md
✅ SALES_POSITIONING_RU.md
✅ CUSTOMER_PILOT_PACK_RU.md
✅ CUSTOMER_DEMO_SCENARIO_RU.md
TOTAL: 60+ документов НА РУССКОМ ЯЗЫКЕ
```
**Это НЕ типичный уровень документации. Это КОРПОРАТИВНЫЙ СТАНДАРТ.**
---
### **4️⃣ КАЧЕСТВО КОДА: 8.5/10** 💎
#### ✅ Сильные стороны:
```rust
// 1. Правильная обработка ошибок
// Все Rust crates используют Result<T, Error> с context
cargo clippy --workspace --all-targets -- -D warnings
// 2. Structured JSON output для всех операций
detmir-status --json
detmir-check --json
detmir-dlp --json
// Машинечитаемые контракты везде!
// 3. Safety gates и guardrails
// - dry-run по умолчанию для mutation команд
// - allowlist для systemd restart
// - lock files для concurrent protection
// - audit logging для всех действий
// 4. Idempotent Ansible playbooks
// - deploy_aw_server.yml идемпотентен
// - WinRM retry с exponential backoff
// - Syntax checks перед apply
// 5. Production-grade operational patterns
// - systemd drop-ins для переключения binaries
// - Rollback scripts задокументированы
// - Shadow-mode validation перед switch
```
#### ⚠️ Оставшиеся слабые стороны:
```
⚠️ Низкая публичная активность в issue tracker
- issue templates есть
- public roadmap есть
- открытых публичных задач пока мало
⚠️ Низкая community adoption
- мало forks/stars
- проект пока выглядит как early-stage / pilot-stage OSS
- это нормально для нового специализированного продукта
⚠️ Restore test еще не выполнен
- backup Gitea работает
- SHA256 verification работает
- daily timer работает
- restore_tested пока false
⚠️ Российский build-runner пока planned
- release evidence scripts есть
- первый настоящий release build на awatch-build-01 еще не выполнен
⚠️ Юридический пакет правообладателя еще pending
- техническая readiness сильная
- юридическая часть для реестра еще требует отдельной подготовки
```
#### ✅ Уже закрыто после последних коммитов:
```
✅ Public CI/CD visibility
✅ Public coverage workflow
✅ Public security scanning
✅ Secret scan policy
✅ SECURITY.md
✅ CONTRIBUTING.md
✅ ROADMAP.md
✅ Issue templates
✅ PR template
✅ CODEOWNERS
✅ Review checklist
✅ Branch protection policy documented
✅ Registry docs
✅ Russian Gitea contour
✅ GitHub public mirror validation
✅ Gitea backup
✅ Status freeze
```
---
### **5️⃣ PRODUCTION READINESS: 9/10** 🚀
#### ✅ Enterprise Features
```
✅ Multi-role RBAC (executive, manager, security, forensics, admin)
✅ DLP incident management с evidence хранилищем
✅ SLO monitoring и автоматический heal
✅ Ansible-powered deployment с idempotency
✅ Backup/restore procedures
✅ Grafana dashboards version-controlled
✅ Hayabusa forensics integration
✅ Telegram bot уведомления
✅ ClickHouse data warehouse
✅ Prometheus/Influx exporters
✅ SAFETY PATTERNS:
- read-only smoke tests перед production
- --dry-run по умолчанию для risky operations
- Rollback procedures documented
- Production incident report существует (2026-06-07)
- Lock-based concurrency protection
```
#### ⚠️ Production Risks
```
⚠️ Один разработчик (igor04091968) — BUS FACTOR ⚠️
- Все коммиты от одного человека
- Нет code reviews видно
- Нет pull request culture
⚠️ Молодой проект (56 дней)
- Нет долгосрочной production history
- Нет documented post-mortems (кроме одного)
⚠️ Limited public activity / community adoption
- 2 форка, 3 звезды
- Issue templates и roadmap есть, но публичных задач пока мало
- Community adoption низкая, это не технический blocker
⚠️ Registry release evidence еще не завершен
- GitHub Actions зеленые, но это только public mirror validation
- Первый release evidence build должен быть выполнен на awatch-build-01
- Gitea restore_tested пока false
```
---
### **6️⃣ РОССИЙСКИЙ РЫНОК READY: 9.5/10** 🇷🇺
#### ✅ Идеальная позиция для РФ
```
✅ ЛОКАЛИЗАЦИЯ:
- Полностью на русском (все документы)
- Russian UI patch для ActivityWatch
- Поддержка русских Windows локализаций
- Cyrillic-aware logging
✅ РЕЕСТР РПО / REGISTRY-READINESS:
- Подготовлен registry-readiness пакет документов
- Product passport и architecture documents описаны
- Dependency statement зафиксирован
- Российский Gitea-контур поднят
- GitHub Actions используется только как public mirror validation
- Release evidence требует российского build-runner
✅ ТЕХНОЛОГИЧЕСКИЙ STACK:
- Rust (не зависит от США)
- Debian/Ubuntu Linux
- Grafana/Prometheus (open-source)
- ClickHouse (российская компания!)
- Hayabusa (DFIR forensics)
- Ansible (open infrastructure)
✅ NO CLOUD-DEPENDENCY:
- Полностью on-prem
- Нет телеметрии в облако
- Нет SaaS lock-in
- Может быть air-gapped
✅ HONESTY POSITIONING:
- НЕ претендует на ФСТЕК/ФСБ сертификацию
- НЕ использует ML/LLM (transparent rule-based UEBA)
- Явно указывает границы показа (contract_only для pfSense)
- Не маскирует ограничения
```
---
### **7️⃣ PILOT v1 FREEZE READINESS (НОВОЕ!)** 🎯
Заметил в README новый документ:
```
✅ docs/PILOT_FREEZE_READINESS_RU.md (добавлен недавно)
```
Это указывает на:
- **Проект готовится к Pilot freeze** (закрытию features)
- **Feature complete для Pilot v1.0**
- **Production readiness gates активны**
```
PILOT V1 SCOPE (ГОТОВО):
- Workforce Analytics ✅
- Security DLP Analytics ✅
- Forensics Reporting ✅
- Evidence Management ✅
- UEBA v1 (rule-based) ✅
- Telegram notifications ✅
- Grafana dashboards ✅
- Role-based access ✅
```
---
### **8️⃣ ИСТОРИЧЕСКАЯ ВЕХИ РАЗРАБОТКИ** 📅
```
2026-06-01: Миграция на Rust начинается (фазы 0-7)
2026-06-07: Production incident (подробный postmortem)
2026-06-09: Grafana panels development
2026-06-11: Security hardening improvements
2026-06-12: Release candidate preflight
2026-06-12-19: Intensive hardening phase
2026-06-20: Pilot freeze readiness doc добавлен
2026-06-21: Public CI/Coverage/Security workflows добавлены
2026-06-22: GitHub Actions validation прошел после hardening secret scan
ВЫВОД: Проект в PRODUCTION HARDENING фазе перед Pilot release
```
---
### **9️⃣ КОНКУРЕНТНЫЙ АНАЛИЗ** 🏆
Проект позиционирует себя против:
```
КОНКУРЕНТЫ (по docs/COMPETITIVE_POSITIONING_RU.md):
- Splunk (слишком дорого, облако)
- Okta (не для локального ИБ)
- ArcSight (legacy, дорого)
- ELK Stack (требует экспертизы)
- Grafana Loki (только logs, не worktime)
УНИКАЛЬНОСТЬ AWatch-rus:
✅ Workforce + Security + Forensics в одном
✅ Русский язык & локализация
✅ Без облака & без ML-черного ящика
✅ Open-source компоненты (ActivityWatch)
✅ Прозрачность (rule-based UEBA)
✅ РФ registry ready
```
---
### **🔟 FINAL ASSESSMENT: ПЕРЕОЦЕНКА**
| Категория | Была | Сейчас | Изменение | Комментарий |
|-----------|------|--------|-----------|------------|
| **Полнота** | 8.5 | **9.2** | ⬆️ +0.7 | DB maintenance added |
| **Качество** | 8.0 | **8.5** | ⬆️ +0.5 | Production incident handled professionally |
| **Профессионализм** | 9.0 | **9.3** | ⬆️ +0.3 | Pilot freeze readiness shows maturity |
| **Российский рынок** | 9.0 | **9.5** | ⬆️ +0.5 | Registry docs enhanced, freeze ready |
| **Production Ready** | 8.5 | **9.0** | ⬆️ +0.5 | Safety gates, rollback procedures validated |
| **Public Validation** | 6.5 | **8.8** | ⬆️ +2.3 | CI/Coverage/Security workflows green |
| **ИТОГО** | **8.6** | **9.1** | ⬆️ **+0.5** | **PRODUCTION GRADE** |
---
### **🎯 КЛЮЧЕВЫЕ ВЫВОДЫ**
```
1. ✅ ПРОЕКТ ГОТОВ К PRODUCTION PILOTING
- Rust-first migration полностью завершена
- Safety gates реализованы
- Documentation на уровне enterprise
- DB maintenance добавлено (новое)
2. ✅ ИДЕАЛЕН ДЛЯ РОССИЙСКОГО РЫНКА
- Полностью локализован
- Registry documents готовы
- Технологический stack без зависимостей
3. ✅ PUBLIC VALIDATION VISIBILITY УЖЕ ЗАКРЫТА
- Public CI/CD visibility ✅
- Public coverage workflow ✅
- Public security scanning ✅
- Secret scan policy hardened ✅
- GitHub public mirror validation ✅
4. ⚠️ ОСТАВШИЕСЯ РИСКИ
- Один разработчик
- Нет visible code review
- Низкая публичная активность issue tracker
- Низкая community adoption
- Gitea restore test еще не выполнен
- Российский build-runner пока planned
- Branch protection policy documented, but enablement not yet verified
5. 🚀 TIMELINE К PRODUCTION:
- Pilot v1 freeze: готовится (freeze readiness doc)
- Beta release: Q3 2026 (est.)
- GA production: Q4 2026 (est.)
6. 📊 QUALITY METRICS:
- Code: Rust clippy strict mode ✅
- Testing: Cargo test suite ✅
- Public coverage workflow ✅
- Public security workflow ✅
- Deployment: Ansible idempotent ✅
- Operations: Runbook-driven ✅
- Documentation: 60+ doc pages ✅
```
---
## 💡 **РЕКОМЕНДАЦИИ**
### Для потенциального инвестора/партнера:
```
✅ ИНВЕСТИРОВАТЬ: Проект достаточно зрелый для pilot
✅ ТРЕБОВАТЬ: Bus factor mitigation (второй разработчик)
✅ ТРЕБОВАТЬ: Community code review process (GitHub PRs)
✅ ТРЕБОВАТЬ: Первый release evidence build на российском build-runner
✅ ТРЕБОВАТЬ: Restore test Gitea backup на отдельном сервере
⚠️ НАБЛЮДАТЬ: Feedback из first customers на Pilot v1
```
### Для Russian enterprises:
```
✅ ИСПОЛЬЗОВАТЬ: Как operational intelligence platform
✅ НЕ ИСПОЛЬЗОВАТЬ: Как certified DLP/SIEM (не позиционируется)
✅ ТРЕБОВАТЬ: Support contract перед production
✅ ПЛАНИРОВАТЬ: Intern training на Rust maintenance
```
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/docs/FULL_DEPLOYMENT_MANUAL_RU.md`
- `/home/igor/tmp/AWatch-rus/docs/windows/ensemble.md`
- `docs/preparation.md`
- `docs/deployment.md`
- `docs/runbook.md`
- `docs/operations.md`
- `proxmox/create-ct.sh`
- `aw-server/install_aw_server.sh`
- `windows/deploy-ensemble.ps1`
- `windows/validate-deployment.ps1`
-309
View File
@@ -1,309 +0,0 @@
# Сведения для подачи в реестр российского ПО
Статус документа: рабочий пакет для подготовки продукта `AWatch-rus` к
экспертной проверке и возможной подаче в реестр российского ПО.
Документ намеренно описывает продукт как программный комплекс операционного
контроля, технического аудита и управления ИТ-инфраструктурой. Продукт не
заявляется как сертифицированная DLP, SIEM, EDR/XDR или средство защиты
информации.
## 1. Наименование продукта
Публичное наименование:
- `AWatch-rus`.
Техническая база и репозиторий:
- `AWatch-rus`.
Рекомендуемая формула для документов:
```text
Программный продукт AWatch-rus.
```
Для публичных материалов использовать единую формулу: `Программный продукт
AWatch-rus`. Это не создает второго бренда и не отделяет продукт от
репозитория.
## 2. Назначение ПО
`AWatch-rus` предназначен для централизованного операционного контроля,
технического аудита и мониторинга ИТ-инфраструктуры организации.
Основные задачи:
- контроль состояния серверных сервисов, endpoint-сборщиков и витрин данных;
- учет пользовательской активности, рабочих интервалов и удаленных сессий;
- мониторинг свежести данных ActivityWatch и связанных buckets;
- контроль выполнения эксплуатационных регламентов и runbook-проверок;
- SLO/health мониторинг и безопасная автоматизация восстановления;
- отображение управленческих и технических dashboards;
- фиксация evidence по прикладным инцидентам;
- аудит действий оператора и техническая трассировка расследований.
Продукт закрывает задачу эксплуатационной видимости: администратор,
оператор ИБ или руководитель видит, что сбор данных идет, инфраструктурные
компоненты доступны, данные обновляются, а прикладные инциденты имеют
прослеживаемую evidence-цепочку.
## 3. Класс ПО
Основной целевой класс для реестра:
```text
09.10 Средства управления ИТ-службой, ИТ-инфраструктурой и ИТ-активами
```
Обоснование:
- продукт контролирует состояние ИТ-сервисов и инфраструктурных компонентов;
- содержит operational dashboards, health-check и SLO-мониторинг;
- автоматизирует эксплуатационные проверки и безопасные recovery-действия;
- хранит технические состояния, отчеты, evidence и audit trail;
- применяется для контроля работоспособности и наблюдаемости корпоративного
контура.
Дополнительный контекст, который можно использовать в описании:
- технический аудит;
- интеллектуальный мониторинг инфраструктуры;
- автоматизация runbook-процессов;
- контроль регламентов эксплуатации.
Не рекомендуется заявлять продукт как:
- сертифицированную DLP;
- SIEM;
- EDR/XDR;
- средство защиты информации;
- продукт с формальной ФСТЭК-моделью угроз.
Модули DLP/evidence/Hayabusa описываются как прикладные модули операционного
контроля и расследования событий, а не как самостоятельная сертифицированная
система защиты информации.
## 4. Правообладатель
Правообладатель: владелец репозитория и программного продукта `AWatch-rus`.
Перед подачей в реестр рекомендуется подготовить отдельный
правообладательский пакет:
- сведения о правообладателе;
- описание прав на собственные модули;
- подтверждение авторства или передачи прав на разработанные компоненты;
- перечень сторонних компонентов и лицензий;
- описание модели распространения;
- при необходимости - свидетельство Роспатента о регистрации программы для ЭВМ.
Собственными компонентами считаются:
- Rust helpers и runtime-модули AWatch-rus;
- портал оператора;
- Ansible deployment automation;
- Windows collectors/deployment scripts;
- ActivityWatch RU customization;
- Grafana dashboards проекта;
- документация, runbooks и install-kit packaging.
Сторонние компоненты перечислены отдельно в `THIRD_PARTY_LICENSES_RU.md` и
`docs/THIRD_PARTY_LICENSES_RU.md`.
## 5. Состав поставки
Публичная поставка состоит из исходного кода, документации и шаблонов
конфигурации. Индивидуальные параметры конкретного стенда не входят в
публичную поставку.
В состав входят:
- `adk-rust/` - Rust workspace с основными runtime helpers;
- `ansible/` - playbooks и examples для установки серверных и endpoint
компонентов;
- `aw-server/` - ActivityWatch server customization, service files,
RU WebUI patches и server-side helpers;
- `windows/` - Windows collectors, scheduled task deployment и common module;
- `grafana/` - dashboards для технического и управленческого мониторинга;
- `proxmox/` - операторские helpers, включая Telegram runtime, если он
используется в конкретном экземпляре;
- `docs/` - руководства администратора, оператора, архитектура, threat model,
registry positioning и runbooks;
- `private-config/*.example` - шаблоны приватной конфигурации;
- release assets - install-kit archives для проверяемых сборок.
Не входят в публичный репозиторий:
- production inventory;
- пароли;
- токены;
- реальные IP-адреса и домены экземпляра;
- runtime базы данных и evidence;
- customer deployment snapshots;
- локальная история работы операторских ИИ-агентов.
## 6. Функциональный состав
### 6.1. Контроль ActivityWatch telemetry
- проверка доступности AW API;
- контроль свежести buckets;
- учет event-driven buckets без ложного dead/stale статуса;
- health summary для оператора;
- SLO sampling и summary.
### 6.2. Учет активности и рабочего времени
- обработка window/AFK/session данных;
- отчеты по активному времени;
- поддержка RDP/Windows collector flow;
- InfluxDB/Grafana витрины;
- heartbeat freshness для контроля работы exporter-а.
### 6.3. Операционный контроль и auto-heal
- `detmir-check`;
- `detmir-status`;
- `detmir-auto`;
- безопасные recovery paths;
- контроль systemd timers/services;
- исключение опасных destructive actions из автоматического режима.
### 6.4. Evidence и расследования
- хранение evidence metadata;
- screenshot/evidence viewer в портале оператора;
- audit записи просмотра evidence;
- Hayabusa/offline DFIR flow как прикладной модуль расследования.
### 6.5. Визуализация
- Grafana dashboards;
- портал оператора;
- management views для руководителя;
- technical views для администратора и оператора ИБ.
## 7. Архитектура
Типовая архитектура экземпляра:
```text
Windows/Linux endpoints
|
v
ActivityWatch collectors / endpoint helpers
|
v
AW server + AWatch-rus Rust helpers
|
+--> SQLite state/cases/policy/evidence metadata
+--> InfluxDB/metrics storage
+--> Grafana dashboards
+--> AWatch-rus operator portal
+--> Telegram/operator runtime, если включен
```
Ядро AWatch-rus реализовано как Rust-first runtime:
- health/status/check helpers;
- worktime exporters/API/bridges;
- DLP server-side processing helpers;
- evidence API/portal helpers;
- install-kit validation tools;
- operational quality gates.
Python в составе проекта не является основным ядром продукта. Он остается для:
- Telegram bot runtime, если используется заказчиком;
- OCR/content-analysis path, где нужны Python OCR/ML библиотеки;
- 1C/AI/ETL интеграций;
- отдельных MCP/dev helper сценариев.
Такое разделение фиксируется как архитектурное: критичные серверные проверки,
status path, SLO, worktime, DLP server-side helpers и install-kit tooling
переведены на Rust-first модель.
## 8. Зависимости
Основные runtime dependencies:
- Linux/systemd;
- ActivityWatch;
- Rust runtime artifacts, собранные из `adk-rust`;
- SQLite;
- Grafana;
- InfluxDB или совместимое хранилище временных рядов, если включены metrics;
- Ansible для установки;
- PowerShell/Windows Task Scheduler для Windows collectors;
- Hayabusa для offline DFIR workflow, если включен;
- Python только для согласованных вспомогательных модулей.
Сторонние лицензии и риски AGPL/GPL/weak copyleft описаны в
`THIRD_PARTY_LICENSES_RU.md`.
## 9. Установка экземпляра
Короткий порядок для эксперта:
1. Склонировать репозиторий.
2. Подготовить приватную конфигурацию:
```bash
cp private-config/deploy.env.example private-config/deploy.env
cp ansible/inventory.example.ini ansible/inventory.ini
```
3. Заполнить параметры конкретного тестового экземпляра.
4. Собрать Rust artifacts:
```bash
cd adk-rust
cargo build --release --workspace
```
5. Выполнить syntax и quality checks:
```bash
scripts/quality-gate.sh
ansible-playbook --syntax-check -i ansible/inventory.ini ansible/deploy_aw_server.yml
```
6. Установить серверные компоненты и collectors по `docs/INSTALL_RU.md`.
7. Проверить работоспособность:
```bash
detmir-check
detmir-status
```
Ожидаемый результат: статус `OK`, отсутствуют критичные service failures и
stale/dead buckets для обязательных источников.
## 10. Ограничения
- Продукт не заменяет формально сертифицированные средства защиты информации
без отдельной сертификации.
- Реальные сетевые адреса, домены, токены и inventory являются параметрами
экземпляра и не публикуются.
- Для endpoint deployment нужны административные права.
- Для некоторых прикладных модулей нужны внешние сервисы: Grafana, InfluxDB,
Hayabusa или Python OCR stack.
- License compatibility сторонних компонентов должна проверяться перед
коммерческой поставкой.
## 11. Документы пакета
- `PRODUCT_DESCRIPTION_RU.md` - краткое описание продукта.
- `CHANGELOG_RU.md` - журнал изменений и статус публичных release-пакетов.
- `INSTALL_FOR_EXPERT_RU.md` - короткая инструкция установки экземпляра.
- `docs/EXPERT_TEST_SCENARIO_RU.md` - ручной сценарий экспертной проверки после установки.
- `docs/RELEASE_MANIFEST_2026-06.md` - manifest release artifacts, checksums и gates.
- `THIRD_PARTY_COMPONENTS.md` - обзор сторонних компонентов.
- `THIRD_PARTY_LICENSES_RU.md` - лицензии и license-audit checklist.
- `docs/ARCHITECTURE_RU.md` - архитектура.
- `docs/ADMIN_GUIDE_RU.md` - руководство администратора.
- `docs/OPERATOR_GUIDE_RU.md` - руководство оператора.
- `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` - стратегия
позиционирования.
-67
View File
@@ -1,67 +0,0 @@
# AWatch-rus Roadmap
This roadmap is public planning. It does not claim completion of unverified
work and does not replace `docs/registry/` evidence for registry-readiness.
## Registry-readiness
- Maintain `docs/registry/` as the authoritative registry-readiness
documentation package.
- Keep conservative product claims and explicit remaining gaps.
- Prepare final rightsholder confirmation and legal review separately.
## Russian Git/build contour
- Keep self-hosted Gitea as the target Russian Git contour for
registry-readiness.
- Keep GitHub as public mirror validation only.
- Provision `awatch-build-01` as a separate Russian build-runner.
## Release evidence
- Run release candidate checks on the Russian build-runner.
- Generate source archive, binary archive, SBOM, SHA256SUMS, smoke logs and
release evidence manifest.
- Keep public GitHub Actions separate from registry release evidence.
## Backup/restore test
- Complete a test restore of Gitea backup on a separate server.
- Keep `restore_tested=false` until evidence exists.
- Document offsite backup in RF before registry submission.
## Coverage and CI
- Use public CI for engineering transparency.
- Track coverage baseline without enforcing a threshold at first.
- Add coverage threshold after baseline review.
## Security scanning
- Maintain cargo audit, cargo deny, dependency review and secret-pattern checks.
- Treat public security checks as advisory validation.
- Produce registry release security evidence in the Russian build contour.
## Russian OS compatibility
- Validate deployment and agent behavior on target Russian OS variants.
- Document unsupported combinations explicitly.
## Pilot hardening
- Keep demo data anonymized.
- Improve smoke coverage for install kit and operational reports.
- Preserve clear rollback and backup-first operational procedures.
## Future UI
- Future UI work remains planned unless backed by implemented code and tests.
- Public roadmap entries are not product claims.
## Not claimed / out of scope
- No claim of FSTEC/FSB certification.
- No claim of replacing DLP or SIEM.
- No claim of ML/LLM-based detection.
- No claim of automatic remediation.
- No claim of legal completion of Russian software registry registration.
-430
View File
@@ -1,430 +0,0 @@
# AWatch-rus: обзор решения для продажи и коммерческого представления
## Executive Summary
`AWatch-rus` — это корпоративная система мониторинга активности сотрудников с DLP-функциями, управленческим слоем и интеграцией в существующий ИТ/ИБ-контур компании.
По сути это практичный средний слой между простыми time-tracker решениями и тяжелыми enterprise DLP-платформами:
- есть контроль действий пользователей и DLP-сигналы;
- есть реальный management layer, а не только сырые события;
- есть интеграции с `1С`, Grafana, Linux-инфраструктурой и forensic follow-up;
- при этом стоимость входа и сопровождения обычно ниже, чем у классических enterprise-комплексов.
Решение особенно уместно там, где:
- есть `Windows` и `RDP`-сценарии;
- важен контроль активности и дисциплины данных;
- уже используется `1С`;
- нужен open-source контур без жесткой привязки к одному вендору.
## Основные возможности
### DLP мониторинг
Система уже собирает и обрабатывает:
- события `clipboard`;
- печать;
- `USB`;
- браузерные домены и web-категории;
- исходящую почту;
- файловые операции;
- DLP-инциденты и review workflow.
### Enforcement
`AWatch-rus` умеет не только наблюдать, но и ограничивать:
- `clipboard block`;
- `USB write-block`;
- отмену печати;
- block path для email в поддерживаемом Outlook-сценарии.
Это дает возможность внедрять контур поэтапно: сначала `monitor`, затем `enforce`.
### Браузеры
Поддерживается:
- сбор доменов и web-контекста;
- категоризация активности;
- связка браузерной телеметрии с DLP и worktime;
- использование данных в dashboard и incident path.
### Email
Поддерживается:
- мониторинг исходящей почты;
- правила `endpoint.email[]`;
- сигналы по теме, адресатам и вложениям;
- блокирующий сценарий в Outlook mode.
### Worktime
Система дает:
- фактический worktime по RDP-сессиям;
- ежедневные отчеты;
- `HTML/CSV/JSON` выдачу;
- server-side management reporting на `:5610`.
### Management Report Layer
Это одна из самых сильных частей решения. Поверх телеметрии строится:
- управленческий отчет;
- очередь действий по сотрудникам и подразделениям;
- source freshness;
- executive summary;
- trend-анализ.
### Интеграция с 1С
Есть отдельный file-based `1С` analytics contour:
- telemetry по файловым базам;
- `ClickHouse`-модели;
- company intelligence;
- manager brief и management actions;
- Grafana boards для руководителя и операционного контура.
### Linux поддержка
Решение не замкнуто только на Windows:
- Linux server-side runtime;
- Linux operational integrations;
- SSH/console logging;
- смешанный Windows/Linux operational model.
### pfSense
Система может быть включена в perimeter/security contour компании через:
- внешний `pfSense` poller;
- передачу network telemetry в общий контур;
- единый operator visibility path.
### Forensic анализ
`Hayabusa` интегрирован как bounded DFIR layer:
- EVTX export с Windows;
- server-side processing;
- case linkage;
- Telegram alert path для follow-up.
Это усиливает ценность решения для ИБ без превращения продукта в отдельную SIEM/DFIR-платформу.
## Управленческие функции
### Management Report Layer
Руководитель получает не просто технические bucket-данные, а:
- картину по активности сотрудников;
- сводку по owner/department;
- проблемные зоны и приоритеты;
- понятную очередь действий.
### Actions с приоритетами
Система умеет формировать:
- `critical/high` actions;
- рекомендации, кого проверять первым;
- причины для escalation;
- управленческий список действий без ручного разбора сырых событий.
### Executive summary
Management API и `1С` management brief формируют human-readable summary уровня:
- что сломалось;
- где данные stale;
- кто не показывает активность;
- какие пользователи и предприятия требуют внимания в первую очередь.
### Trend-анализ
В продукт уже встроены:
- несколько дней тренда по worktime;
- trend и weekly views в `1С` intelligence contour;
- сравнительный анализ текущего и исторического состояния.
### Source freshness
Это критически важная функция для менеджмента и ИБ:
- система показывает, где проблема в поведении пользователя, а где в деградации источника;
- решения не принимаются вслепую по сломанной телеметрии.
### Алиасы пользователей
Поддерживаются:
- normalized user aliases;
- owner/department mapping;
- manager-facing каталоги ответственных.
За счет этого отчеты пригодны для бизнеса, а не только для инженеров.
## Архитектура и компоненты
Архитектура строится как цепочка:
- `Windows Clients / RDP host`;
- `Linux Server`;
- `Integration Layer`;
- `Monitoring Stack`;
- выделенный `Forensic Layer`.
Практически это означает:
- Windows PowerShell collectors;
- Linux `AW-rus` server;
- DLP Policy API и Case API;
- Grafana/Prometheus/ClickHouse analytics;
- Proxmox/operator gateway;
- `Hayabusa` follow-up path.
### Windows коллекторы
В состав входят:
- endpoint DLP collector;
- browser domains collector;
- file operations collector;
- email outbound collector;
- worktime session collector;
- deploy/hardening/validation toolkit.
### Linux сервер
Серверный слой включает:
- `ActivityWatch` API и WebUI;
- RU patch и DLP overlay;
- `aw-worktime-api` на `:5610`;
- policy engine;
- case management;
- health/autoheal path.
### Monitoring стек
Визуализация и наблюдаемость строятся через:
- Grafana;
- Prometheus-compatible monitoring path;
- `1С` analytics dashboards;
- Proxmox Web Gateway как operator entrypoint.
### Proxmox Web Gateway
Gateway дает:
- одну точку входа для операторов и руководства;
- маршруты на Proxmox GUI, AW-rus UI, management pages, Grafana;
- HTTPS access path для внутреннего management contour.
## Преимущества перед конкурентами
### Open-source
- нет vendor lock-in;
- прозрачный код и архитектура;
- можно дорабатывать под процессы заказчика;
- проще аудитировать и сопровождать.
### Легкий агент
- PowerShell collector model;
- нет обязательного тяжелого kernel-level агента;
- легче пилот и проще сопровождение.
### Гибкая DLP политика
- JSON-based policy;
- server-side policy API;
- monitor/enforce режимы;
- адаптация под реальные каналы утечки и корпоративные правила.
### Русификация
- русифицированный WebUI;
- русские Grafana dashboards;
- русская эксплуатационная документация;
- нормальная operator terminology без англоязычного vendor-noise.
### Linux поддержка
- Linux server-side runtime;
- Linux operational integrations;
- гибридный Windows/Linux контур.
### Management Layer
Это сильная дифференциация относительно простых time-tracker решений:
- actions;
- executive summary;
- source freshness;
- owner/department rollups;
- trend и management pages.
### Низкая стоимость владения
По сравнению с классическими enterprise DLP-платформами заказчик получает шанс:
- снизить лицензионную нагрузку;
- не переплачивать за лишний функционал;
- дешевле входить в пилот;
- лучше контролировать стоимость масштабирования.
Корректная подача здесь простая: это не “бесплатная замена любому enterprise DLP”, а прагматичный контур с сильным TCO-профилем.
## Сценарии использования
### Защита от утечек
Подходит, если нужно:
- видеть рискованные действия по `clipboard`, `USB`, печати, email, browser и files;
- фиксировать инциденты;
- в нужных каналах включать block/restrict path.
### Мониторинг продуктивности
Подходит, если компании нужно:
- учитывать активность в RDP;
- получать реальные worktime-данные;
- понимать, кто неактивен по факту, а не по формальному входу в систему.
### Комплаенс 152-ФЗ
Система полезна как practical control/evidence layer:
- DLP incidents;
- compliance reports;
- operator review;
- контроль работы с чувствительными данными.
Это не “автоматическая сертификация”, а инструмент реального operational compliance support.
### Интеграция с 1С
Подходит для компаний, где важно:
- видеть состояние файловых баз;
- понимать активность и риски по предприятиям;
- связывать ИТ, ИБ и управленческий слой.
### Управленческий контроль
Подходит для:
- руководителей подразделений;
- операционных менеджеров;
- ИБ и ИТ, которым нужны единые summary и actions;
- сменных и распределенных управленческих контуров.
### Forensic анализ
Полезен для заказчиков, которым нужен:
- bounded forensic follow-up;
- EVTX-based post-incident path;
- связка инцидента, кейса и расследования в одном operational контуре.
## Технические требования
Базовый practical profile:
- `Windows 10/11` для рабочих станций;
- `Windows Server` / RDP-host сценарии, включая текущий production-target `Windows Server 2025`;
- `Linux` серверный контур на `Debian/Ubuntu`;
- `Docker` для части monitoring/analytics stack;
- `PostgreSQL` и/или другие аналитические БД в интеграционных сценариях;
- `ClickHouse` для file-based `1С` analytics;
- Grafana для визуализации.
Иными словами, продукт не требует exotic stack и нормально ложится в типовую инфраструктуру компании.
## Уровни зрелости продукта
Состояние продукта корректно описывать так:
- operational phases `1-3` по production health, operator path и Windows hardening уже закрыты;
- server-side DLP chain, content-analysis base и docs/release sync уже реализованы;
- maturity по DLP roadmap сейчас выглядит так:
- `Phase 1` — сделано;
- `Phase 2` — внедрено частично;
- `Phase 2.5` enforcement и email outbound — внедрены;
- `Phase 3+` — дальнейшее развитие policy/correlation/SIEM/advanced analytics.
Roadmap дальше идет в сторону:
- deeper DLP runtime;
- дополнительных regression guards;
- усиления management и integration layer.
То есть продукт уже production-usable, но остается пространством для целевых enterprise-усилений под конкретного заказчика.
## Стоимость и ROI
### Сравнение с enterprise решениями
Типовой enterprise DLP-проект часто означает:
- дорогое лицензирование;
- тяжелый агент;
- длительный rollout;
- дорогое сопровождение изменений.
`AWatch-rus` выигрывает там, где заказчику важны:
- lower entry cost;
- управляемый пилот;
- понятная архитектура;
- возможность адаптации без полной смены платформы.
### Экономия на лицензиях
Корректная коммерческая формулировка такая:
- заказчик потенциально экономит на лицензиях и внедрении по сравнению с тяжелыми enterprise-пакетами;
- итоговая экономия зависит от числа endpoint'ов, объема enforcement, требований к SIEM/SSO/RBAC и объема кастомизации;
- сильная сторона решения — контролируемая стоимость владения, а не обещание “заменить все enterprise DLP в один клик”.
## Поддержка и обучение
Проект уже опирается на:
- подробную русскую документацию;
- runbook и deployment guides;
- Ansible и PowerShell automation;
- community-style support model;
- возможность кастомизации под нужды конкретного заказчика.
Для коммерческого внедрения это означает, что можно предложить:
- пилот;
- rollout;
- обучение операторов и ИБ;
- кастомизацию dashboard, policy и integration path.
## Контакты и следующий шаг
Практический следующий шаг для потенциального заказчика:
1. Провести короткий discovery по инфраструктуре, числу Windows/RDP-host'ов и наличию `1С`.
2. Определить, нужен ли только monitor-mode или сразу важен enforcement path.
3. Выделить пилотный сегмент.
4. Поднять pilot deployment с management report layer и базовым DLP/monitoring контуром.
5. После пилота решить, какие enterprise-усиления действительно нужны, а какие не дадут окупаемого эффекта.
Самая сильная подача продукта простая: не обещать “всё для всех”, а показывать, что `AWatch-rus` уже дает работающий operational control contour с DLP, management и forensic follow-up там, где многие компании либо переплачивают за тяжелые платформы, либо вообще живут без управляемого контроля.
-41
View File
@@ -1,41 +0,0 @@
# Security Policy
## Reporting a vulnerability
Please report suspected vulnerabilities privately to the project maintainer
before publishing technical details. If a private contact channel is not
available, open a GitHub issue with a minimal description and no exploit,
secret, customer data, employee logs or personal data.
Do not include:
- passwords, tokens, cookies or private keys;
- real employee logs;
- personal data;
- private network details;
- customer evidence that has not been anonymized.
Use demo or anonymized evidence whenever possible.
## Security scope
AWatch-rus is not positioned as a certified security product. It is not a replacement for DLP or SIEM platforms. Public security checks are advisory validation for engineering transparency.
## Public validation
GitHub Actions security checks run in the public mirror:
- cargo audit;
- cargo deny;
- secret-pattern check;
- dependency review for pull requests.
GitHub remains public mirror validation only. Registry release security
evidence must be produced in the Russian build contour on the Russian
build-runner.
## Registry-readiness note
Security checks do not confirm legal completion of Russian software registry
registration. Final submission requires rightsholder confirmation and legal
review.
-454
View File
@@ -1,454 +0,0 @@
# AWatch-rus: обзор системы для службы информационной безопасности
## Обзор системы
`AWatch-rus` в текущем состоянии — это не только русифицированный `ActivityWatch`, а полный production-контур контроля пользовательской активности, DLP-сигналов, управленческой отчетности и bounded forensic follow-up.
Архитектурно систему удобно рассматривать как **четыре основных operational tiers с выделенным forensic layer**:
1. `Windows Clients / RDP host`
На рабочих станциях и RDP-хостах работают PowerShell-коллекторы, которые собирают активность и DLP-сигналы.
2. `Linux Server`
Серверный контур `AW-rus` на Linux принимает события, хранит bucket-данные, отдает WebUI и server-side API.
3. `Integration Layer`
Здесь живут policy engine, case management, SIEM/webhook/syslog/CEF интеграции, Telegram operator path, `1C`-аналитика и внешние poller'ы.
4. `Monitoring Stack`
Grafana, Prometheus, SQL/ClickHouse аналитические слои и operator gateway для обзорных и управленческих экранов.
5. `Forensic Layer`
Отдельный bounded DFIR-путь через `Hayabusa`, который используется для post-incident enrichment, а не как основной real-time detector.
Подтвержденный runtime для `AWatch-rus`:
- `<AW_SERVER_HOST>` — основной `AW-rus` server, health, worktime/reporting, DLP server-side services, `Hayabusa` processing.
- `<GATEWAY_HOST>` — operator/gateway host, Telegram bot, web gateway, часть `1C` analytics runtime.
- `<WINDOWS_HOST>``HOST-EXAMPLE`, Windows/RDP host с collector toolkit.
- `<GRAFANA_HOST>` — Grafana.
- `<FIREWALL_HOST>``pfSense`, сетевой perimeter и VPN.
Ключевые потоки данных:
- endpoint collector -> `AW-rus` API -> `aw-dlp-endpoint-signals_*`, `aw-file-operations_*`, `aw-worktime-sessions_*`, `aw-dlp-incidents_*`;
- server-side policy/case/integration services -> operator workflows и compliance artifacts;
- worktime/management API на `:5610` -> management pages, executive summary, trend/source freshness;
- `1C` file telemetry -> ClickHouse/API/Grafana management contour;
- EVTX package -> `Hayabusa` intake -> case linkage / Telegram alert / bounded metadata.
## DLP функционал
### Endpoint Signals Collector
Файл: `windows/dlp-endpoint-signals-collector.ps1`
Реализует:
- мониторинг `clipboard`;
- мониторинг печати;
- мониторинг `USB`;
- загрузку локальной или server-side DLP policy;
- генерацию heartbeat и incident событий;
- transport queue на диске с lock-файлом и безопасным flush-потоком;
- telemetry по `queueDepth`, `eventsEnqueued`, `eventsFlushed`, `sendFailures`.
Для `action: "block"` реализованы активные меры:
- `clipboard` — очистка буфера обмена;
- `USB` — write-block через `Set-Disk -IsReadOnly`;
- `print` — отмена print jobs.
Важно:
- enforcement уже реализован, но его scope ограничен endpoint/email каналами;
- это не inline network DLP и не full-content gateway.
### Browser Domains Collector с категоризацией
Файл: `windows/browser-domains-native-collector.ps1`
Реализует:
- сбор доменов и web-контекста;
- нормализацию в `aw-detmir-web-category_*`;
- сопоставление доменов с policy rules;
- генерацию DLP incident событий по web-правилам.
Практическое ограничение:
- web-контур в текущей модели в первую очередь наблюдающий и аналитический;
- Telegram DLP toggle не превращает browser path в настоящий inline web-block.
### Email Outbound Collector
Файл: `windows/email-outbound-collector.ps1`
Реализует:
- мониторинг исходящей почты через Outlook COM и сетевые SMTP-сигналы;
- DLP-правила `endpoint.email[]`;
- reaction path для `action: "block"` через перемещение письма в Drafts в Outlook mode;
- privacy-preserving подход: тема и получатели могут храниться как hash/metadata, без постоянного чтения тела письма.
### DLP Aggregator
Файл: `scripts/aggregate_dlp_events.py`
Реализует:
- сбор `aw-file-operations_*` и `aw-dlp-incidents_*` в нормализованную БД;
- SQLite/PostgreSQL режимы;
- `PRAGMA journal_mode=WAL` для SQLite;
- основу для Grafana/SIEM-style reporting и поиска по событиям.
### DLP Policy API
Каталог: `aw-server/dlp-policy-engine/`
Реализует:
- централизованную активную policy;
- versioning и checksum;
- endpoint pull-model;
- API `GET /api/0/dlp/policies/active`;
- API `GET /api/0/dlp/policies/active/version`;
- agent heartbeat / desired state path.
Это уже production-usable server-side policy layer, но не enterprise policy suite с RBAC, approval matrix и криптографической подписью policy bundle.
## Управленческий мониторинг
### Management Report Layer на `:5610`
Файл: `aw-server/aw-worktime-api.py`
Контур включает:
- `GET /reports/worktime/today`;
- `GET /reports/worktime/management`;
- форматы `json`, `csv`, `html`;
- отдельную управленческую интерпретацию рабочего окна против календарной активности.
### Алиасы пользователей
Контур поддерживает:
- alias-файл сотрудников;
- owner/department mapping;
- manager-facing rollups по `owner` и `department`;
- нормализацию display names и руководителей.
### Actions с приоритетами
Management report строит:
- очередь действий;
- `critical/high` приоритеты;
- owner/department scope;
- executive interpretation уровня “что делать сегодня”.
### Source freshness monitoring
В management layer уже встроен контроль свежести источников:
- `aw-worktime-sessions_*`;
- `aw-watcher-window_*`;
- `aw-watcher-afk_*`;
- `aw-file-operations_*`;
- `aw-detmir-web-category_*`;
- смежные operational buckets.
Это важно с ИБ-позиции: система различает “данные есть, но пользователь не работал” и “данные stale, поэтому вывод ненадежен”.
### Executive summary и trend-анализ
Server-side management report уже выдает:
- summary по active/inactive users;
- actions queue;
- executive summary;
- trend за несколько дней;
- filtered management view по owner/department.
Практический смысл:
- это не просто тайм-трекер;
- это управленческий слой поверх telemetry, который помогает различать operational drift, real inactivity и collector degradation.
## Мониторинг и визуализация
### Prometheus Exporter
В проекте есть operational contour с Prometheus-compatible health/metrics logic и E2E проверками. Это используется для контроля server-side доступности и для внешних dashboard/alert workflows.
### Grafana дашборды
Version-controlled dashboard JSON находятся в `grafana/` и `clickhouse-1c/grafana/...`.
Основные экраны:
- RDP/worktime activity;
- DLP и ИБ overview;
- management/security boards;
- `1C` file telemetry;
- `1C` management board;
- financial reporting board.
### SQL Exporter для 1С KPI
Для `1C` контура реализован отдельный analytics stack:
- `ClickHouse`;
- ETL;
- company intelligence marts;
- management pages;
- Grafana dashboards.
Это read-only аналитический слой поверх telemetry и выгрузок, а не write-back path в production `1C`.
### E2E мониторинг
Контур уже содержит:
- `aw-health-check`;
- `scripts/dlp-health-check.py`;
- `check-aw-full.sh`;
- `check-aw-data.sh`;
- autoheal для worktime/reporting;
- внешний операторский контроль через Telegram bot.
### Proxmox Web Gateway
Развертывание: `ansible/deploy_proxmox_web_gateway.yml`
Назначение:
- единая внутренняя точка входа для operator/management pages;
- HTTPS reverse entrypoint;
- маршруты на Proxmox GUI, AW-rus UI, management reports, Grafana и `1C` pages.
## Надежность и отказоустойчивость
### WAL buffering
В проекте используются два близких, но разных механизма устойчивости:
- **server-side SQLite WAL** в policy/case/aggregation storage;
- **Windows collector disk queue** с lock-файлами и последующим flush в AW API.
Это снижает риск потери событий при кратковременной сетевой недоступности и при transient server-side сбоях.
### Graceful shutdown
Collector и server-side сервисы проектировались так, чтобы:
- не терять queued данные при штатной остановке;
- не держать transport lock во время network I/O;
- не блокировать весь pipeline одним зависшим POST.
### Health snapshots
Реализованы:
- `aw-rus-healthd.py`;
- state snapshots в `AW_RUS_HEALTH_STATE_DIR`;
- validation snapshots по Windows deploy/validation path;
- `Hayabusa` state snapshots (`latest-intake.json`).
Это дает operator и ИБ-команде не только “жив/мертв”, но и подтвержденное состояние последней валидации.
### Retry с exponential backoff
Реализован retry/backoff path минимум в:
- Windows transport queue flush;
- webhook sender;
- ряде integration/ingest контуров.
Это защищает от transient network/API failure, не превращая ошибку в постоянный incident storm.
### Предотвращение дубликатов процессов
В проекте есть отдельная работа против multi-instance regressions:
- lock-файлы для recovery/launch loops;
- проверки на stale queue + held lock;
- hardening deployment для Windows/RDP;
- частичное dedupe по incident/case semantics.
Практически это уменьшает риск process storm и ложных дублей telemetry.
### Ротация архивов деплоя
В Windows deploy toolkit и forensic/ingest контурах есть архивирование и ротация:
- deploy/install archives;
- backup/rollback roots;
- `Hayabusa` package archive и extracted payload archive;
- install-kit snapshots.
Это важно для расследований и rollback, потому что артефакты не исчезают после первой обработки.
## Интеграции
### Hayabusa forensic анализ
`Hayabusa` интегрирован как bounded DFIR enrichment:
- Windows экспортирует EVTX package;
- сервер принимает пакет в drop/inbox;
- `aw-hayabusa` строит forensic report;
- case linkage пишет bounded metadata;
- `high`-severity path может триггерить Telegram alert.
Ключевая граница:
- `Hayabusa` не является primary runtime detector;
- это forensic follow-up после инцидентов.
### pfSense poller
Файл: `pfsense/pfsense-aw-poller.py`
Реализует:
- внешний poller для `pfSense` API;
- отправку сетевой telemetry в `ActivityWatch`;
- включение firewall/VPN perimeter в единый observability contour.
### File-1C telemetry
Файл: `windows/export-upload-file-1c-telemetry.ps1`
Реализует:
- read-only telemetry по файловым базам `1C`;
- snapshots по `db size`, `reglog`, active locks, temp markers, scheduler activity;
- передачу данных в аналитический `ClickHouse` контур.
### TSJ Guardian Bot
Файл: `proxmox/tsj_guardian_bot.py`
Реализует:
- operator-facing health checks;
- DLP mode control;
- bounded auto-heal;
- status, support and investigation commands;
- human-readable operator menu для DLP и forensic path.
### MCP / PowerShell remote для AWatch-rus
Документ: `docs/POWERSHELL_MCP_REMOTE_RU.md`
Реализует:
- operator/Codex remote path к Windows host;
- `SSH + powershell.exe` вместо `WSMan` для interactive operations;
- преднастроенный управляемый PowerShell path для `<WINDOWS_HOST>`.
## Деплой и эксплуатация
### Proxmox LXC
Базовый production deployment рассчитан на:
- Proxmox;
- LXC/CT для `AW-rus` server и смежных сервисов;
- отдельные runtime-host'ы для Grafana и operator/gateway paths.
### Ansible automation
Репозиторий содержит playbook'и для:
- server deployment;
- Windows deployment через `WinRM`;
- Grafana dashboard import;
- `pfSense` poller rollout;
- Proxmox web gateway rollout;
- bot/operator infrastructure.
### Windows deploy modes
Поддерживаются:
- `single-user`;
- `domain-users`;
- `ensemble`;
- standalone-service deployment mode;
- validation и hardening/recovery paths.
### Backup / rollback
В эксплуатационной модели уже предусмотрены:
- backup-first approach;
- deploy archives;
- rollback roots;
- `vzdump`/snapshot сценарии для LXC;
- forensic archive paths для intake payloads.
### Health validation publishing
Операционная модель уже поддерживает публикацию validation/health state:
- server-side health snapshots;
- Windows validation reports;
- transport freshness checks;
- operator-visible status через runbook и Telegram path.
## Безопасность и приватность
### Хранение секретов
Проектный принцип:
- реальные секреты не должны лежать в репозитории;
- используются `.example` и local secret files;
- для PowerShell/MCP отдельно оговорен локальный secret-config с правами `600`.
### Приватность данных
Ключевые ограничения и свойства:
- система не ведет постоянную запись экрана;
- OCR применяется к incident artifacts, а не к постоянному screen stream;
- email path не обязан хранить тело писем в открытом виде;
- management и `1C` слои строятся на read-only telemetry/выгрузках.
### Сетевая безопасность
Целевой operational подход:
- внутренний/VPN access вместо лишней публикации сервисов наружу;
- `pfSense` как perimeter control;
- operator access через gateway и управляемые entrypoints;
- `SSH` и `WinRM` разделены по назначению.
### Права доступа
Практическая модель прав:
- endpoint collectors и enforcement-функции требуют локальные Windows-права по своему каналу;
- часть enforcement logic требует admin/SYSTEM scope;
- server-side operator actions должны идти через ограниченные operational paths, а не прямой произвольный shell everywhere.
### TLS для Proxmox gateway
`Proxmox Web Gateway` разворачивается через `nginx` с TLS:
- HTTP redirect на HTTPS;
- `TLSv1.2` / `TLSv1.3`;
- отдельные certificate/key paths;
- по умолчанию возможен self-signed режим;
- для production рекомендуется заменить self-signed на корпоративный сертификат и держать gateway во внутреннем management contour.
## Вывод для ИБ
`AWatch-rus` уже дает практический DLP/monitoring/investigation contour для Windows/RDP и связанного Linux/operator слоя:
- endpoint и email DLP;
- management и source-freshness layer;
- case/integration/reporting path;
- bounded `Hayabusa` follow-up;
- production automation и health/autoheal.
При этом систему нужно честно оценивать как **open-source industrial scaffold с реализованными production-механиками**, а не как полностью завершенную enterprise DLP-платформу со встроенным RBAC, SSO и hardware-grade isolation.
-27
View File
@@ -1,27 +0,0 @@
# Сторонние компоненты
Основной перечень сторонних компонентов ведется в
`docs/THIRD_PARTY_LICENSES_RU.md`.
## Runtime и инфраструктура
- ActivityWatch;
- Rust crates ecosystem;
- Grafana;
- Prometheus / InfluxDB compatible metrics stack;
- Ansible;
- PowerShell / Windows Task Scheduler;
- SQLite;
- Hayabusa и связанные DFIR-инструменты при включении модуля расследования.
## Правило поставки
В публичную поставку не входят production inventory, пароли, токены, домены,
IP-адреса конкретного экземпляра, customer runtime data и локальные операторские
пути. Такие параметры задаются в приватной конфигурации экземпляра.
## Лицензии
Для собственных частей проекта используется лицензия, указанная в `LICENSE`.
Лицензии сторонних компонентов должны проверяться перед коммерческой поставкой
и фиксироваться в составе release package.
-185
View File
@@ -1,185 +0,0 @@
# AWatch-rus: сторонние компоненты и лицензии
Статус документа: рабочий license inventory для подготовки поставки и
экспертной проверки. Документ не является юридическим заключением. Перед
коммерческой поставкой или подачей в реестр нужно выполнить полный
автоматизированный SBOM/license audit по конкретной release-сборке.
## 1. Собственный код проекта
Собственные компоненты `AWatch-rus`:
- Rust workspace `adk-rust/`;
- AWatch-rus status/check/auto/heal helpers;
- worktime exporters/API/bridge/autoheal;
- DLP server-side helpers;
- evidence API и portal helpers;
- Ansible playbooks и deployment automation;
- Windows PowerShell collectors/deployment scripts;
- ActivityWatch RU WebUI patches;
- Grafana dashboards проекта;
- install-kit tooling;
- документация.
Для собственного кода в корне репозитория указан `LICENSE`:
```text
Apache License 2.0
```
Это применимо только к собственным частям проекта. Сторонние компоненты
сохраняют свои лицензии.
## 2. Ключевые сторонние компоненты
| Компонент | Роль в продукте | Типовая лицензия upstream | Статус поставки | Комментарий для аудита |
|---|---|---|---|---|
| ActivityWatch | Базовый сбор и API событий активности | MPL-2.0 | Устанавливается/используется как внешний компонент | Weak copyleft на измененные MPL-файлы; модификации ActivityWatch нужно учитывать отдельно. |
| Grafana OSS | Dashboards и визуализация | AGPL-3.0 для современных версий Grafana OSS | Обычно внешний сервис/контейнер, не собственный код AWatch-rus | AGPL требует отдельной проверки модели распространения и сетевого использования. |
| Prometheus | Monitoring ecosystem, exporters, scrape model | Apache-2.0 | Внешний компонент при включении мониторинга | Совместим с Apache-поставкой при соблюдении notice/license требований. |
| InfluxDB / compatible TSDB | Хранилище временных рядов `aw_metrics` | Зависит от версии/дистрибутива | Внешний компонент | Зафиксировать конкретную версию в release notes. |
| Hayabusa | Offline/DFIR timeline и enrichment | AGPLv3; rules могут иметь Detection Rule License | Опциональный прикладной модуль расследования | Не позиционировать как ядро продукта; проверить obligations при включении в поставку. |
| Ansible | Deployment automation | GPL-3.0-or-later для Ansible core | Инструмент установки | Обычно не линкуется с кодом продукта; входит в toolchain. |
| PowerShell | Windows deployment/collectors runtime | MIT для PowerShell Core; Windows PowerShell как компонент ОС | Runtime/tooling | Уточнять окружение заказчика: Windows PowerShell или PowerShell 7. |
| SQLite | Local state/warehouse DB | Public domain/blessing style | Embedded/library/runtime | Обычно низкий license risk. |
| ClickHouse clients/tooling | 1C/file analytics integration | Зависит от клиента; ClickHouse server Apache-2.0 | Отдельный 1C/business-data слой | Не является обязательным ядром AWatch-rus. |
| OpenAI/Pollinations-compatible integrations | AI assistant/integration paths | API terms, не open-source license | Опционально | Не включать ключи/API credentials в поставку. |
## 3. Rust dependencies
Rust является основным runtime-слоем AWatch-rus. Точный список зависимостей должен
фиксироваться по `Cargo.lock` конкретного релиза.
Ключевые crates, используемые в workspace:
| Crate | Назначение | Типичные лицензии ecosystem | Действие перед релизом |
|---|---|---|---|
| `anyhow` | Error handling | MIT OR Apache-2.0 | Проверить через `cargo about`. |
| `clap` | CLI parsing | MIT OR Apache-2.0 | Проверить transitive deps. |
| `chrono` | Date/time | MIT OR Apache-2.0 | Зафиксировать версию. |
| `serde`, `serde_json`, `serde_yaml` | Serialization | MIT OR Apache-2.0 | Проверить YAML transitive deps. |
| `reqwest` | HTTP client | MIT OR Apache-2.0 | Проверить TLS backend и transitive deps. |
| `rusqlite` | SQLite access | MIT | Проверить bundled/system SQLite режим. |
| `regex` | Matching rules | MIT OR Apache-2.0 | Низкий риск. |
| `sha2` | Hashing | MIT OR Apache-2.0 | Низкий риск. |
| `base64` | Encoding/decoding | MIT OR Apache-2.0 | Низкий риск. |
| `tiny_http` | Lightweight HTTP service | MIT OR Apache-2.0 | Проверить версию. |
| `url`, `urlencoding` | URL handling | MIT OR Apache-2.0 | Проверить transitive deps. |
| `tempfile` | Tests/temp files | MIT OR Apache-2.0 | Test/dev dependency. |
Обязательные команды для release audit:
```bash
cargo install cargo-about cargo-deny cargo-auditable
cd adk-rust
cargo metadata --locked --format-version 1 > ../docs/sbom-cargo-metadata.json
cargo deny check
cargo about generate about.hbs > ../docs/licenses-rust.html
```
Если шаблон `about.hbs` отсутствует, его нужно добавить в release tooling или
использовать стандартный шаблон организации.
## 4. Python-зависимости
Python не является основным runtime-ядром AWatch-rus. Он остается для
согласованных вспомогательных направлений:
- Telegram bot runtime, если включен в экземпляре;
- OCR/content-analysis path;
- 1C/AI/ETL integration layer;
- MCP/dev helper tools;
- legacy-compatible scripts, не входящие в Rust-first server core.
Известные requirements:
| Файл | Назначение | Основные зависимости | License-audit действие |
|---|---|---|---|
| `aw-server/dlp-content-analysis/requirements.txt` | OCR/content analysis | `pytesseract`, `Pillow` | Проверить OCR stack и system Tesseract license отдельно. |
| `aw-server/dlp-case-management/requirements.txt` | Legacy/reference case API | `fastapi`, `uvicorn`, `pydantic` | Проверить, поставляется ли как runtime или только reference. |
| `aw-server/dlp-policy-engine/requirements.txt` | Legacy/reference policy API | `fastapi`, `uvicorn`, `pydantic` | Rust replacement должен быть primary runtime. |
| `aw-server/dlp-compliance/requirements.txt` | Legacy/reference reports | `requests` | Проверить, не входит ли в active runtime. |
| `aw-server/dlp-integrations/requirements.txt` | Legacy/reference integrations | `PyYAML` | Проверить статус после Rust migration. |
| `clickhouse-1c/ai/requirements.txt` | 1C/AI APIs | `fastapi`, `uvicorn`, `clickhouse-connect` | Отдельный business-data слой. |
| `clickhouse-1c/etl/requirements.txt` | 1C ETL | `clickhouse-connect`, `PyYAML`, `python-dateutil`, `openpyxl` | Отдельный ETL слой. |
| `detmir-mcp` | MCP helper | Python MCP stack | Не основное runtime-ядро продукта. |
Команды для Python license report:
```bash
python3 -m venv /tmp/detmir-license-audit
. /tmp/detmir-license-audit/bin/activate
python -m pip install -U pip pip-licenses
pip-licenses --from=mixed --format=markdown > docs/licenses-python.md
```
Команду нужно выполнять в окружении, где установлены зависимости конкретного
release profile.
## 5. Frontend, dashboards и browser tooling
| Компонент | Роль | License-audit действие |
|---|---|---|
| Grafana dashboards JSON | Собственные dashboards AWatch-rus | Входят в собственную поставку; проверить отсутствие embedded secrets/URLs. |
| ActivityWatch WebUI patches | Собственный overlay/patch слой | Учитывать MPL-2.0 границы ActivityWatch, если изменяются upstream файлы. |
| Playwright/browser smoke tooling | Проверки UI | Обычно dev/test dependency; не включать в runtime claim. |
| JavaScript snippets | WebUI patching/helper scripts | Проверить зависимости, если добавляются npm packages. |
## 6. Компоненты с повышенным вниманием
| Компонент | Причина внимания | Рекомендация |
|---|---|---|
| Grafana OSS | AGPL-3.0 для современных версий | В реестровой поставке описывать как внешний компонент или проверить obligations. |
| Hayabusa | AGPLv3 + отдельная лицензия rules | Держать как optional offline module; не смешивать с закрытым ядром без аудита. |
| Ansible | GPL toolchain | Описывать как инструмент установки, не как linked library продукта. |
| OCR/Tesseract stack | Несколько уровней зависимостей | Фиксировать конкретные пакеты ОС и Python packages. |
| Python legacy paths | Могут выглядеть как ядро | В документации указывать, что Rust-first runtime является основным. |
## 7. Что поставляется вместе с продуктом
В публичной поставке могут присутствовать:
- исходный код собственных модулей;
- шаблоны конфигурации;
- Ansible playbooks;
- Grafana dashboards;
- Windows collectors scripts;
- install-kit archives как GitHub Release assets;
- документация.
Не должны поставляться в публичном git:
- production inventory;
- реальные домены/IP конкретного экземпляра;
- пароли и токены;
- runtime базы данных;
- customer evidence;
- локальная история разработки;
- случайные binary archives в корне репозитория.
## 8. Release checklist по лицензиям
Перед каждым публичным release:
1. Собрать Rust SBOM по `Cargo.lock`.
2. Выполнить `cargo deny check`.
3. Сформировать Rust license report.
4. Сформировать Python license report для включенных profiles.
5. Проверить Grafana/Hayabusa/Ansible как внешние компоненты.
6. Проверить, что root репозитория не содержит случайных архивов сборки.
7. Проверить отсутствие secrets, private inventory, customer paths.
8. Зафиксировать версию ActivityWatch и способ ее установки.
9. Зафиксировать, какие optional modules включены в релиз.
10. Сохранить отчеты в release artifacts или `docs/licenses-*`.
## 9. Источники для проверки upstream лицензий
- ActivityWatch repository/license: `https://github.com/ActivityWatch/activitywatch`
- Grafana licensing: `https://grafana.com/licensing/`
- Grafana repository/license: `https://github.com/grafana/grafana`
- Prometheus repository/license: `https://github.com/prometheus/prometheus`
- Hayabusa repository/license: `https://github.com/Yamato-Security/hayabusa`
- Ansible repository/license: `https://github.com/ansible/ansible`
Финальная версия документа должна ссылаться на конкретные версии компонентов,
использованные в release build.
-4
View File
@@ -1,4 +0,0 @@
target/
.env
.env.*
!.env.example
-3503
View File
File diff suppressed because it is too large Load Diff
-96
View File
@@ -1,96 +0,0 @@
[workspace]
resolver = "3"
members = [
"crates/aw-1c-ingest",
"crates/aw-windows-telemetry",
"crates/awatch-agent",
"crates/awatch-agent-rs",
"crates/detmir-auto",
"crates/detmir-aw-client",
"crates/aw-db-health",
"crates/aw-db-maintenance",
"crates/aw-ensure-reliability",
"crates/aw-health-check",
"crates/aw-linux-install",
"crates/aw-prune-local-state",
"crates/check-aw-data",
"crates/check-aw-full",
"crates/check-install-kit-vs-repo",
"crates/validate-install-kit",
"crates/verify-innosetup-installer",
"crates/rebuild-install-kit",
"crates/quality-gate",
"crates/extract-ioc-from-sigma",
"crates/merge-aw-server-dbs",
"crates/prod-backup-restore",
"crates/prod-rollout",
"crates/rdp-worktime-report",
"crates/aw-contour-smoke",
"crates/aw-browser-smoke",
"crates/diag-and-manual-restart",
"crates/detmir-grafana-check",
"crates/detmir-readiness",
"crates/detmir-portal",
"crates/aw-slo-monitor",
"crates/aw-workforce-ingest",
"crates/aw-rus-healthd",
"crates/detmir-check",
"crates/detmir-core",
"crates/dlp-health-check",
"crates/dlp-content-analyzer",
"crates/dlp-admin-cli",
"crates/dlp-policy-engine",
"crates/dlp-case-management",
"crates/dlp-compliance",
"crates/hayabusa-tools",
"crates/dlp-cef-exporter",
"crates/dlp-aggregator",
"crates/dlp-influx-exporter",
"crates/worktime-influx-exporter",
"crates/worktime-autoheal",
"crates/worktime-api",
"crates/worktime-prewarm",
"crates/worktime-ui-bridge",
"crates/dlp-syslog-forwarder",
"crates/dlp-webhook-sender",
"crates/detmir-dlp",
"crates/detmir-heal-safe",
"crates/detmir-status",
"crates/detmir-state",
"crates/tsj-guardian-status",
"crates/tsj-guardian-watchdog",
]
[workspace.package]
edition = "2024"
rust-version = "1.85"
license = "Apache-2.0"
publish = false
[workspace.dependencies]
adk-rust = { version = "0.9.1", default-features = false }
anyhow = "1"
base64 = "0.22"
calamine = "=0.24.0"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
clap = { version = "4", features = ["derive", "env"] }
csv = "1"
detmir-aw-client = { path = "crates/detmir-aw-client" }
detmir-core = { path = "crates/detmir-core" }
detmir-state = { path = "crates/detmir-state" }
filetime = "0.2"
fs2 = "0.4"
notify = "6"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
regex = "1"
rusqlite = "0.32"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
sha1 = "0.10"
sha2 = "0.10"
tempfile = "3"
tiny_http = "0.12"
url = "2"
urlencoding = "2"
windows-sys = "0.59"
-127
View File
@@ -1,127 +0,0 @@
# DetMir ADK-Rust Workspace
This directory is the Rust workspace for replacing operational Python and shell
scripts with durable standalone Rust modules.
## Layout
- `Cargo.toml` - workspace manifest and shared dependency versions.
- `Cargo.lock` - pinned dependency graph for reproducible builds.
- `crates/<module>` - one binary or library module per operational function.
- `target/` - local build output, ignored by git.
## Current Modules
- `detmir-auto` - no-heal autonomous orchestration shadow binary for running
`detmir-check`, `detmir-dlp`, state/report writes, latest symlink updates, and
retention cleanup.
- `detmir-core` - shared status levels, exit codes, and UTC timestamp helpers.
- `detmir-state` - DetMir autonomous state models, normalization, and atomic JSON writes.
- `detmir-aw-client` - small blocking ActivityWatch HTTP client and event timestamp helpers.
- `detmir-check` - read-only DetMir contour check replacement for the legacy Python command.
- `detmir-dlp` - SSH wrapper replacement for remote DLP health JSON collection.
- `dlp-health-check` - AW server DLP health check replacement.
- `aw-db-maintenance` - guarded weekly SQLite maintenance for old allowlisted
process-level session events, with backup-before-delete; nightly SQLite
compaction is handled by the same binary in `--vacuum` mode and scheduled
separately from the trim job.
- `aw-ensure-reliability` - safe dry-run/apply planner for AW service
reliability repair actions that were previously immediate Bash mutations.
- `aw-linux-install` - safe dry-run/apply planner for Linux ActivityWatch
install wrappers; legacy shell install is explicit.
- `check-aw-full` - read-only local AW/RDP full check replacement for the
legacy shell helper.
- `dlp-aggregator` - AW server DLP warehouse aggregator replacement.
- `dlp-influx-exporter` - AW server DLP InfluxDB line protocol exporter replacement.
- `worktime-autoheal` - AW server worktime autoheal and backfill replacement.
- `worktime-influx-exporter` - AW server worktime InfluxDB line protocol exporter replacement.
- `worktime-prewarm` - AW server worktime report cache prewarm replacement.
- `worktime-ui-bridge` - AW server worktime sessions to AFK/window bridge replacement.
- `dlp-syslog-forwarder` - AW server DLP syslog integration replacement.
- `dlp-webhook-sender` - AW server DLP webhook integration replacement.
- `dlp-cef-exporter` - AW server DLP CEF/syslog exporter replacement.
- `extract-ioc-from-sigma` - offline Sigma/Hayabusa IOC export replacement
used by the DLP IOC preload wrapper.
- `merge-aw-server-dbs` - ActivityWatch SQLite DB merge replacement used by
legacy root DB recovery/deploy tooling.
- `prod-backup-restore` - safe planner/checker for the destructive production
backup-restore flow; apply remains explicit legacy-only at this stage.
- `prod-rollout` - safe planner/orchestrator for production AW server/Windows
rollout; normal script runs are plan-only and real rollout requires
explicit `--apply`.
- `rdp-worktime-report` - local RDP worktime CSV/JSON report helper
replacement for the legacy shell/Python script.
- `aw-contour-smoke` - Rust replacement for the Proxmox-side DetMir contour
smoke checks, used through a Rust-first project wrapper.
- `diag-and-manual-restart` - Rust replacement for the AW/DLP diagnostic and
explicit manual restart helper, with conservative no-restart healthy path.
- `aw-browser-smoke` - Rust launcher for the browser smoke test; Playwright
remains the execution engine and the legacy Node script remains fallback.
- `detmir-status` - read-only DetMir state normalizer with text, JSON, and ADK
`Content` output. Also builds `detmir-adk-status` as a compatibility binary.
## SQLite Maintenance Safety
`aw-db-maintenance` has two separate modes:
- default trim mode removes only old allowlisted `process_start` /
`process_stop` rows from the configured session bucket and is dry-run unless
`--apply` is passed;
- `--vacuum` compacts the SQLite DB with `VACUUM INTO`, checks
`PRAGMA integrity_check`, preserves owner/mode, and replaces the DB only after
backup and integrity success.
Both apply modes use `AW_DB_MAINTENANCE_LOCK_PATH` /
`--lock-path` to block concurrent trim/VACUUM runs. VACUUM also checks the
configured `activitywatch-server.service` through systemd, refuses unknown or
failed unit states, stops the service before compaction, and starts it again
through a guard on success or error.
Do not run VACUUM during business hours, active incident response, evidence
collection, active backup/restore, or when the ActivityWatch service/unit state
is unclear. Rollback is replacing the SQLite DB from
`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db` while
`activitywatch-server.service` is stopped, then starting the service and
checking `aw-db-health`/`detmir-status`.
The Ansible deploy installs the VACUUM unit files but does not enable the
nightly timer unless `aw_db_vacuum_timer_enabled=true` is set explicitly.
Disable it with:
```bash
systemctl disable --now aw-db-vacuum.timer
```
## Migration Runbook
Use `RUNBOOK.md` as the operational plan for replacing Python and shell modules
with Rust. It defines migration phases, safety gates, rollback rules, and the
order in which DetMir modules should be moved.
## Commands
```bash
cd adk-rust
cargo fmt --all
cargo check --workspace
cargo build --release --workspace
```
Run the current status module:
```bash
cd adk-rust
cargo run -p detmir-status -- --json
cargo run -p detmir-status -- --adk-json
cargo run -p detmir-status -- status --json
```
## Migration Rules
- New replacements go under `crates/` and are added to workspace `members`.
- Keep modules standalone: config comes from files, environment variables, or
explicit CLI flags; no laptop-only assumptions.
- Default to read-only behavior first; add mutation/recovery paths only with
tests and explicit operator-safe failure modes.
- Do not embed secrets in binaries, source files, or examples.
- Every module should expose machine-readable JSON output where practical.
-1812
View File
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "aw-1c-ingest"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
calamine.workspace = true
chrono.workspace = true
clap.workspace = true
csv.workspace = true
filetime.workspace = true
fs2.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
sha1.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
@@ -1,14 +0,0 @@
[package]
name = "aw-browser-smoke"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -1,149 +0,0 @@
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::Parser;
#[derive(Debug, Parser)]
#[command(
about = "Rust launcher for the ActivityWatch-Russian browser smoke test",
trailing_var_arg = true
)]
struct Cli {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
script: Option<PathBuf>,
#[arg(long)]
node: Option<PathBuf>,
#[arg(last = true)]
args: Vec<OsString>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LaunchPlan {
node: PathBuf,
script: PathBuf,
args: Vec<OsString>,
node_path: Option<OsString>,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let plan = build_launch_plan(&cli)?;
run_child(&plan)
}
fn build_launch_plan(cli: &Cli) -> Result<LaunchPlan> {
let root = cli.root.clone();
let script = cli
.script
.clone()
.unwrap_or_else(|| root.join("scripts").join("aw-webui-browser-smoke.mjs"));
if !script.is_file() {
bail!("browser smoke script not found: {}", script.display());
}
Ok(LaunchPlan {
node: cli.node.clone().unwrap_or_else(|| PathBuf::from("node")),
script,
args: cli.args.clone(),
node_path: default_node_path(std::env::var_os("NODE_PATH").as_deref()),
})
}
fn default_node_path(current: Option<&OsStr>) -> Option<OsString> {
if current.is_some_and(|value| !value.is_empty()) {
return None;
}
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home)
.join(".agents")
.join("skills")
.join("playwright")
.join("node_modules");
if path.is_dir() {
Some(path.as_os_str().to_os_string())
} else {
None
}
}
fn run_child(plan: &LaunchPlan) -> Result<i32> {
let mut command = Command::new(&plan.node);
command.arg(&plan.script).args(&plan.args);
if let Some(node_path) = &plan.node_path {
command.env("NODE_PATH", node_path);
}
let status = command
.status()
.with_context(|| format!("run {}", plan.node.display()))?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn keeps_existing_node_path_untouched() {
assert_eq!(
default_node_path(Some(OsStr::new("/custom/node_modules"))),
None
);
}
#[test]
fn launch_plan_uses_default_script_and_passes_args() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
let script = scripts.join("aw-webui-browser-smoke.mjs");
fs::write(&script, "console.log('ok')\n").unwrap();
let cli = Cli {
root: dir.path().to_path_buf(),
script: None,
node: Some(PathBuf::from("/usr/bin/node")),
args: vec![OsString::from("--probe"), OsString::from("value")],
};
let plan = build_launch_plan(&cli).unwrap();
assert_eq!(plan.node, PathBuf::from("/usr/bin/node"));
assert_eq!(plan.script, script);
assert_eq!(
plan.args,
vec![OsString::from("--probe"), OsString::from("value")]
);
}
#[test]
fn missing_script_is_an_error() {
let cli = Cli {
root: PathBuf::from("/tmp/no-such-aw-browser-root"),
script: None,
node: None,
args: vec![],
};
let err = build_launch_plan(&cli).unwrap_err().to_string();
assert!(err.contains("browser smoke script not found"));
}
}
@@ -1,13 +0,0 @@
[package]
name = "aw-contour-smoke"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
reqwest.workspace = true
serde_json.workspace = true
@@ -1,425 +0,0 @@
use std::net::{TcpStream, ToSocketAddrs};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use reqwest::blocking::Client;
use reqwest::redirect::Policy;
use serde_json::Value;
#[derive(Debug, Parser)]
#[command(about = "ActivityWatch-Russian contour smoke checks")]
struct Cli {
#[arg(long, value_enum, default_value_t = Mode::ProxmoxRemote)]
mode: Mode,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Mode {
ProxmoxRemote,
}
#[derive(Debug, Default)]
struct Counts {
ok: usize,
warn: usize,
fail: usize,
skip: usize,
}
impl Counts {
fn pass(&mut self, msg: impl AsRef<str>) {
self.ok += 1;
println!("[OK] {}", msg.as_ref());
}
fn fail(&mut self, msg: impl AsRef<str>) {
self.fail += 1;
println!("[FAIL] {}", msg.as_ref());
}
fn skip(&mut self, msg: impl AsRef<str>) {
self.skip += 1;
println!("[SKIP] {}", msg.as_ref());
}
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
match cli.mode {
Mode::ProxmoxRemote => run_proxmox_remote(),
}
}
fn run_proxmox_remote() -> Result<i32> {
let mut counts = Counts::default();
let http = Client::builder()
.no_proxy()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(15))
.build()
.context("build HTTP client")?;
let no_redirect_http = Client::builder()
.no_proxy()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(15))
.redirect(Policy::none())
.build()
.context("build no-redirect HTTP client")?;
section("Host");
print_command("hostnamectl", &["hostnamectl"]);
print_command("date", &["date", "-Is"]);
print_command("uptime", &["uptime"]);
section("Core Services");
for unit in [
"nginx.service",
"pveproxy.service",
"pvedaemon.service",
"pvestatd.service",
"pve-cluster.service",
"docker.service",
"aw-1c-company-api.service",
"aw-pve-webadmin-logger.service",
] {
check_service(&mut counts, unit);
}
section("Timers");
for unit in [
"aw-1c-ingest.timer",
"aw-1c-proofcheck.timer",
"aw-1c-manager-brief.timer",
"aw-1c-recovery-brief.timer",
"aw-1c-weekly-digest.timer",
] {
check_timer(&mut counts, unit);
}
if let Ok(out) = command_output("systemctl", &["list-timers", "--all", "--no-pager"]) {
print_filtered_lines(&out, &["aw-1c", "NEXT", "LEFT", "PASSED"], 40);
}
section("Ports");
check_tcp(&mut counts, "nginx http", "127.0.0.1", 80);
check_tcp(&mut counts, "nginx https", "127.0.0.1", 443);
check_tcp(&mut counts, "proxmox web", "127.0.0.1", 8006);
check_tcp(&mut counts, "1C company API", "10.10.10.2", 8710);
check_tcp(&mut counts, "clickhouse native", "127.0.0.1", 9000);
check_tcp(&mut counts, "clickhouse http", "127.0.0.1", 8123);
if let Ok(out) = command_output("ss", &["-tulpn"]) {
print_filtered_lines(
&out,
&[":80", ":443", ":8006", ":8710", ":8123", ":9000"],
40,
);
}
section("Gateway HTTP");
check_http_code(
&mut counts,
&http,
"nginx healthz",
"https://127.0.0.1/healthz",
&[200],
);
check_http_code(
&mut counts,
&http,
"go proxmox gui protected",
"https://127.0.0.1/go/proxmox-gui",
&[401],
);
check_http_code(
&mut counts,
&http,
"go file1c brief protected",
"https://127.0.0.1/go/file1c-brief",
&[401],
);
check_http_code(
&mut counts,
&http,
"go file1c actions protected",
"https://127.0.0.1/go/file1c-actions",
&[401],
);
section("1C Company API");
check_http_code(
&mut counts,
&no_redirect_http,
"1C root redirect",
"http://10.10.10.2:8710/",
&[307],
);
for (name, url) in [
("1C /health", "http://10.10.10.2:8710/health"),
("1C /api/health", "http://10.10.10.2:8710/api/health"),
("1C manager brief", "http://10.10.10.2:8710/manager/brief"),
(
"1C manager actions",
"http://10.10.10.2:8710/manager/actions",
),
(
"1C manager recovery",
"http://10.10.10.2:8710/manager/recovery",
),
(
"1C weekly digest",
"http://10.10.10.2:8710/manager/digest/weekly",
),
] {
check_http_code(&mut counts, &http, name, url, &[200]);
}
section("ClickHouse");
check_docker_container(&mut counts, "aw-rus-1c-clickhouse");
check_http_code(
&mut counts,
&http,
"ClickHouse ping",
"http://127.0.0.1:8123/ping",
&[200],
);
if command_exists("docker") && docker_container_running("aw-rus-1c-clickhouse") {
check_command(
&mut counts,
"ClickHouse SELECT 1",
"docker",
&[
"exec",
"aw-rus-1c-clickhouse",
"clickhouse-client",
"--query",
"SELECT 1",
],
);
}
section("System Capacity");
print_command("df", &["df", "-h", "/", "/var", "/opt"]);
print_command("free", &["free", "-h"]);
section("Summary");
println!(
"OK={} WARN={} FAIL={} SKIP={}",
counts.ok, counts.warn, counts.fail, counts.skip
);
Ok(if counts.fail > 0 { 2 } else { 0 })
}
fn section(name: &str) {
println!();
println!("== {name} ==");
}
fn command_exists(cmd: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {cmd} >/dev/null 2>&1"))
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn command_output(cmd: &str, args: &[&str]) -> Result<String> {
let output = Command::new(cmd)
.args(args)
.output()
.with_context(|| format!("run {cmd}"))?;
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(&output.stdout));
text.push_str(&String::from_utf8_lossy(&output.stderr));
if output.status.success() {
Ok(text)
} else {
Err(anyhow::anyhow!("{cmd} failed: {text}"))
}
}
fn print_command(label: &str, command: &[&str]) {
if let Some((cmd, args)) = command.split_first() {
match command_output(cmd, args) {
Ok(out) => print_indented(&out, 80),
Err(err) => println!(" {label}: {err:#}"),
}
}
}
fn print_indented(text: &str, max_lines: usize) {
for line in text.lines().take(max_lines) {
println!(" {line}");
}
}
fn print_filtered_lines(text: &str, patterns: &[&str], max_lines: usize) {
for line in text
.lines()
.filter(|line| patterns.iter().any(|pattern| line.contains(pattern)))
.take(max_lines)
{
println!(" {line}");
}
}
fn check_service(counts: &mut Counts, unit: &str) {
check_systemd_unit(counts, unit, "service");
}
fn check_timer(counts: &mut Counts, unit: &str) {
check_systemd_unit(counts, unit, "timer");
}
fn check_systemd_unit(counts: &mut Counts, unit: &str, kind: &str) {
if !Command::new("systemctl")
.args(["list-unit-files", unit])
.status()
.map(|status| status.success())
.unwrap_or(false)
{
counts.skip(format!("{unit} is not installed"));
return;
}
if Command::new("systemctl")
.args(["is-active", "--quiet", unit])
.status()
.map(|status| status.success())
.unwrap_or(false)
{
counts.pass(format!("{unit} active"));
} else {
counts.fail(format!("{unit} inactive or failed"));
if let Ok(out) = command_output("systemctl", &["--no-pager", "--lines=8", "status", unit]) {
print_indented(&out, 30);
} else {
let _ = kind;
}
}
}
fn check_tcp(counts: &mut Counts, name: &str, host: &str, port: u16) {
let addr = format!("{host}:{port}");
let ok = addr
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
.and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(4)).ok())
.is_some();
if ok {
counts.pass(format!("{name} TCP {host}:{port}"));
} else {
counts.fail(format!("{name} TCP {host}:{port}"));
}
}
fn check_http_code(counts: &mut Counts, client: &Client, name: &str, url: &str, expected: &[u16]) {
match client.get(url).send() {
Ok(response) => {
let code = response.status().as_u16();
if expected.contains(&code) {
counts.pass(format!("{name} HTTP {code} {url}"));
} else {
counts.fail(format!("{name} HTTP {code} {url}"));
if let Ok(text) = response.text() {
print_indented(&text, 40);
}
}
}
Err(err) => counts.fail(format!("{name} HTTP error {url}: {err}")),
}
}
fn check_command(counts: &mut Counts, name: &str, cmd: &str, args: &[&str]) {
match command_output(cmd, args) {
Ok(out) => {
counts.pass(name);
print_indented(&out, 40);
}
Err(err) => counts.fail(format!("{name}: {err:#}")),
}
}
fn check_docker_container(counts: &mut Counts, name: &str) {
if !command_exists("docker") {
counts.skip("docker command unavailable");
return;
}
if docker_container_running(name) {
counts.pass(format!("docker container {name} running"));
if let Ok(out) = command_output(
"docker",
&[
"ps",
"--filter",
&format!("name=^/{name}$"),
"--format",
"{{.Names}} {{.Status}} {{.Ports}}",
],
) {
print_indented(&out, 20);
}
} else {
counts.fail(format!("docker container {name} not running"));
if let Ok(out) = command_output(
"docker",
&[
"ps",
"-a",
"--filter",
&format!("name=^/{name}$"),
"--format",
"{{.Names}} {{.Status}} {{.Ports}}",
],
) {
print_indented(&out, 20);
}
}
}
fn docker_container_running(name: &str) -> bool {
command_output("docker", &["ps", "--format", "{{.Names}}"])
.map(|out| out.lines().any(|line| line == name))
.unwrap_or(false)
}
#[allow(dead_code)]
fn parse_json_key_present(value: &Value, key: &str) -> bool {
value.get(key).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_exit_code_matches_failures() {
let counts = Counts {
ok: 1,
warn: 1,
fail: 0,
skip: 1,
};
assert_eq!(if counts.fail > 0 { 2 } else { 0 }, 0);
let counts = Counts { fail: 1, ..counts };
assert_eq!(if counts.fail > 0 { 2 } else { 0 }, 2);
}
#[test]
fn default_counts_are_zero() {
let counts = Counts::default();
assert_eq!(counts.ok + counts.warn + counts.fail + counts.skip, 0);
}
}
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "aw-db-health"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
-562
View File
@@ -1,562 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use clap::Parser;
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
use serde::Serialize;
use serde_json::{Value, json};
const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db";
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
#[derive(Debug, Parser)]
#[command(author, version, about = "Read-only ActivityWatch SQLite growth guard")]
struct Cli {
#[arg(long, default_value = DEFAULT_DB_PATH, env = "AW_DB_HEALTH_DB_PATH")]
db_path: PathBuf,
#[arg(long, env = "AW_DB_HEALTH_SESSION_BUCKET")]
session_bucket: Option<String>,
#[arg(long, env = "AW_WORKTIME_HOST")]
host: Option<String>,
#[arg(long, env = "AW_DB_HEALTH_WINDOWS_CONFIG")]
windows_config: Option<PathBuf>,
#[arg(long, default_value_t = gib(2), env = "AW_DB_HEALTH_DB_WARN_BYTES")]
db_warn_bytes: u64,
#[arg(long, default_value_t = gib(5), env = "AW_DB_HEALTH_DB_FAIL_BYTES")]
db_fail_bytes: u64,
#[arg(long, default_value_t = mib(256), env = "AW_DB_HEALTH_WAL_WARN_BYTES")]
wal_warn_bytes: u64,
#[arg(long, default_value_t = gib(1), env = "AW_DB_HEALTH_WAL_FAIL_BYTES")]
wal_fail_bytes: u64,
#[arg(long, default_value_t = 10_000, env = "AW_DB_HEALTH_SESSION_ROWS_WARN")]
session_rows_warn: i64,
#[arg(
long,
default_value_t = 100_000,
env = "AW_DB_HEALTH_SESSION_ROWS_FAIL"
)]
session_rows_fail: i64,
#[arg(
long,
default_value_t = 600,
env = "AW_DB_HEALTH_RECENT_PROCESS_WINDOW_SECONDS"
)]
recent_process_window_seconds: i64,
#[arg(long, default_value_t = 1, env = "AW_DB_HEALTH_RECENT_PROCESS_WARN")]
recent_process_warn: i64,
#[arg(long, default_value_t = 100, env = "AW_DB_HEALTH_RECENT_PROCESS_FAIL")]
recent_process_fail: i64,
#[arg(long)]
json: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
enum Status {
Ok,
Warn,
Fail,
}
#[derive(Debug, Serialize)]
struct CheckResult {
name: String,
status: Status,
summary: String,
details: Value,
}
#[derive(Debug, Serialize)]
struct Report {
ok: bool,
generated_at_utc: String,
counts: Counts,
results: Vec<CheckResult>,
}
#[derive(Debug, Default, Serialize)]
struct Counts {
ok: usize,
warn: usize,
fail: usize,
}
const fn mib(value: u64) -> u64 {
value * 1024 * 1024
}
const fn gib(value: u64) -> u64 {
value * 1024 * 1024 * 1024
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let report = build_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_text(&report);
}
Ok(if report.ok { 0 } else { 2 })
}
fn build_report(cli: &Cli) -> Result<Report> {
let mut results = Vec::new();
let db_path = &cli.db_path;
let wal_path = PathBuf::from(format!("{}-wal", db_path.display()));
let shm_path = PathBuf::from(format!("{}-shm", db_path.display()));
let db_size = file_size(db_path)?;
results.push(threshold_result(
"sqlite:file-size",
db_size,
cli.db_warn_bytes,
cli.db_fail_bytes,
"ActivityWatch SQLite DB size",
json!({
"path": db_path,
"size_bytes": db_size,
"warn_bytes": cli.db_warn_bytes,
"fail_bytes": cli.db_fail_bytes,
}),
));
let wal_size = file_size_optional(&wal_path)?;
results.push(threshold_result(
"sqlite:wal-size",
wal_size,
cli.wal_warn_bytes,
cli.wal_fail_bytes,
"ActivityWatch SQLite WAL size",
json!({
"path": wal_path,
"size_bytes": wal_size,
"warn_bytes": cli.wal_warn_bytes,
"fail_bytes": cli.wal_fail_bytes,
}),
));
let shm_size = file_size_optional(&shm_path)?;
results.push(CheckResult {
name: "sqlite:shm-size".to_string(),
status: Status::Ok,
summary: format!("SHM size is {}", human_bytes(shm_size)),
details: json!({
"path": shm_path,
"size_bytes": shm_size,
}),
});
let conn = open_readonly(db_path)?;
let bucket = cli.session_bucket.clone().unwrap_or_else(|| {
format!(
"aw-session-events_{}",
cli.host.as_deref().unwrap_or(DEFAULT_HOST)
)
});
match bucket_row(&conn, &bucket)? {
Some(bucketrow) => {
let total_rows = count_session_rows(&conn, bucketrow)?;
results.push(threshold_result_i64(
"aw-session-events:rows",
total_rows,
cli.session_rows_warn,
cli.session_rows_fail,
"aw-session-events row count",
json!({
"bucket": bucket,
"bucketrow": bucketrow,
"rows": total_rows,
"warn_rows": cli.session_rows_warn,
"fail_rows": cli.session_rows_fail,
}),
));
let cutoff_ns = now_ns()? - cli.recent_process_window_seconds.max(1) * 1_000_000_000;
let recent_process = count_recent_process_events(&conn, bucketrow, cutoff_ns)?;
results.push(threshold_result_i64(
"aw-session-events:recent-process-events",
recent_process,
cli.recent_process_warn,
cli.recent_process_fail,
"recent process-level aw-session-events",
json!({
"bucket": bucket,
"bucketrow": bucketrow,
"recent_process_events": recent_process,
"window_seconds": cli.recent_process_window_seconds,
"cutoff_ns": cutoff_ns,
"warn_events": cli.recent_process_warn,
"fail_events": cli.recent_process_fail,
}),
));
let latest = latest_session_event(&conn, bucketrow)?;
results.push(CheckResult {
name: "aw-session-events:latest".to_string(),
status: Status::Ok,
summary: latest
.as_ref()
.map(|event| format!("latest eventType={}", event.event_type))
.unwrap_or_else(|| "no session events".to_string()),
details: json!({
"bucket": bucket,
"latest": latest,
}),
});
}
None => {
results.push(CheckResult {
name: "aw-session-events:bucket".to_string(),
status: Status::Warn,
summary: format!("bucket {bucket} not found"),
details: json!({ "bucket": bucket }),
});
}
}
if let Some(path) = &cli.windows_config {
results.push(check_windows_config(path)?);
}
let counts = count_statuses(&results);
Ok(Report {
ok: counts.fail == 0,
generated_at_utc: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
counts,
results,
})
}
fn open_readonly(path: &Path) -> Result<Connection> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("open SQLite DB read-only: {}", path.display()))?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
Ok(conn)
}
fn bucket_row(conn: &Connection, bucket: &str) -> Result<Option<i64>> {
conn.query_row("SELECT id FROM buckets WHERE name=?", [bucket], |row| {
row.get(0)
})
.optional()
.context("lookup bucket row")
}
fn count_session_rows(conn: &Connection, bucketrow: i64) -> Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM events WHERE bucketrow=?",
[bucketrow],
|row| row.get(0),
)
.context("count session event rows")
}
fn count_recent_process_events(conn: &Connection, bucketrow: i64, cutoff_ns: i64) -> Result<i64> {
conn.query_row(
r#"
SELECT COUNT(*)
FROM events
WHERE bucketrow=?
AND endtime >= ?
AND (data LIKE ? OR data LIKE ?)
"#,
params![
bucketrow,
cutoff_ns,
"%\"eventType\":\"process_start\"%",
"%\"eventType\":\"process_stop\"%",
],
|row| row.get(0),
)
.context("count recent process-level session events")
}
#[derive(Debug, Serialize)]
struct LatestEvent {
id: i64,
endtime_ns: i64,
event_type: String,
source: String,
}
fn latest_session_event(conn: &Connection, bucketrow: i64) -> Result<Option<LatestEvent>> {
let row = conn
.query_row(
"SELECT id, endtime, data FROM events WHERE bucketrow=? ORDER BY id DESC LIMIT 1",
[bucketrow],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
))
},
)
.optional()
.context("read latest session event")?;
Ok(row.map(|(id, endtime_ns, data)| LatestEvent {
id,
endtime_ns,
event_type: json_field(&data, "eventType").unwrap_or_else(|| "unknown".to_string()),
source: json_field(&data, "source").unwrap_or_else(|| "unknown".to_string()),
}))
}
fn check_windows_config(path: &Path) -> Result<CheckResult> {
let text = fs::read_to_string(path)
.with_context(|| format!("read Windows deployment config {}", path.display()))?;
let payload: Value = serde_json::from_str(&text).context("parse Windows deployment config")?;
let enabled = payload
.pointer("/sessionEvents/processEventsEnabled")
.and_then(Value::as_bool);
let status = match enabled {
Some(false) => Status::Ok,
Some(true) => Status::Fail,
None => Status::Warn,
};
let summary = match enabled {
Some(false) => "processEventsEnabled=false".to_string(),
Some(true) => {
"processEventsEnabled=true; high-volume process stream is enabled".to_string()
}
None => "processEventsEnabled missing".to_string(),
};
Ok(CheckResult {
name: "windows-config:process-events".to_string(),
status,
summary,
details: json!({
"path": path,
"processEventsEnabled": enabled,
}),
})
}
fn threshold_result(
name: &str,
value: u64,
warn: u64,
fail: u64,
label: &str,
details: Value,
) -> CheckResult {
let status = status_for_u64(value, warn, fail);
CheckResult {
name: name.to_string(),
status,
summary: format!("{label}: {}", human_bytes(value)),
details,
}
}
fn threshold_result_i64(
name: &str,
value: i64,
warn: i64,
fail: i64,
label: &str,
details: Value,
) -> CheckResult {
let status = status_for_i64(value, warn, fail);
CheckResult {
name: name.to_string(),
status,
summary: format!("{label}: {value}"),
details,
}
}
fn status_for_u64(value: u64, warn: u64, fail: u64) -> Status {
if value >= fail {
Status::Fail
} else if value >= warn {
Status::Warn
} else {
Status::Ok
}
}
fn status_for_i64(value: i64, warn: i64, fail: i64) -> Status {
if value >= fail {
Status::Fail
} else if value >= warn {
Status::Warn
} else {
Status::Ok
}
}
fn count_statuses(results: &[CheckResult]) -> Counts {
let mut counts = Counts::default();
for result in results {
match result.status {
Status::Ok => counts.ok += 1,
Status::Warn => counts.warn += 1,
Status::Fail => counts.fail += 1,
}
}
counts
}
fn file_size(path: &Path) -> Result<u64> {
Ok(fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.len())
}
fn file_size_optional(path: &Path) -> Result<u64> {
match fs::metadata(path) {
Ok(metadata) => Ok(metadata.len()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(err) => Err(err).with_context(|| format!("stat {}", path.display())),
}
}
fn now_ns() -> Result<i64> {
Utc::now()
.timestamp_nanos_opt()
.ok_or_else(|| anyhow!("current timestamp out of range"))
}
fn json_field(data: &str, key: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(data).ok()?;
value.get(key)?.as_str().map(ToString::to_string)
}
fn human_bytes(value: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
let mut size = value as f64;
let mut unit = 0usize;
while size >= 1024.0 && unit + 1 < UNITS.len() {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{value} {}", UNITS[unit])
} else {
format!("{size:.1} {}", UNITS[unit])
}
}
fn print_text(report: &Report) {
println!("=== AW DB Health ===");
println!("Timestamp: {}", report.generated_at_utc);
for result in &report.results {
let mark = match result.status {
Status::Ok => "",
Status::Warn => "",
Status::Fail => "",
};
println!("{mark} {}: {}", result.name, result.summary);
}
println!(
"Summary: ok={} warn={} fail={}",
report.counts.ok, report.counts.warn, report.counts.fail
);
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
#[test]
fn classifies_thresholds() {
assert_eq!(status_for_u64(10, 20, 30), Status::Ok);
assert_eq!(status_for_u64(20, 20, 30), Status::Warn);
assert_eq!(status_for_u64(30, 20, 30), Status::Fail);
assert_eq!(status_for_i64(0, 1, 100), Status::Ok);
assert_eq!(status_for_i64(1, 1, 100), Status::Warn);
assert_eq!(status_for_i64(101, 0, 100), Status::Fail);
}
#[test]
fn reads_session_metrics() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let conn = Connection::open(tmp.path()).unwrap();
conn.execute(
"CREATE TABLE buckets (id INTEGER PRIMARY KEY, name TEXT)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE events (id INTEGER PRIMARY KEY, bucketrow INTEGER, endtime INTEGER, data TEXT)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO buckets (id, name) VALUES (15, 'aw-session-events_TEST')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO events (bucketrow, endtime, data) VALUES (15, ?, ?)",
params![
now_ns().unwrap(),
r#"{"eventType":"process_start","source":"worktime-session-collector"}"#
],
)
.unwrap();
conn.execute(
"INSERT INTO events (bucketrow, endtime, data) VALUES (15, ?, ?)",
params![
now_ns().unwrap(),
r#"{"eventType":"logon","source":"launch-watchers-awatch-rus"}"#
],
)
.unwrap();
drop(conn);
let conn = open_readonly(tmp.path()).unwrap();
let bucketrow = bucket_row(&conn, "aw-session-events_TEST")
.unwrap()
.unwrap();
assert_eq!(count_session_rows(&conn, bucketrow).unwrap(), 2);
assert_eq!(
count_recent_process_events(&conn, bucketrow, now_ns().unwrap() - 60_000_000_000)
.unwrap(),
1
);
let latest = latest_session_event(&conn, bucketrow).unwrap().unwrap();
assert_eq!(latest.event_type, "logon");
}
#[test]
fn checks_windows_config_flag() {
let tmp = tempfile::NamedTempFile::new().unwrap();
fs::write(
tmp.path(),
r#"{"sessionEvents":{"processEventsEnabled":false}}"#,
)
.unwrap();
let result = check_windows_config(tmp.path()).unwrap();
assert_eq!(result.status, Status::Ok);
}
}
@@ -1,18 +0,0 @@
[package]
name = "aw-db-maintenance"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
rusqlite = { workspace = true, features = ["backup"] }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -1,894 +0,0 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use chrono::{SecondsFormat, Utc};
use clap::Parser;
use rusqlite::{Connection, DatabaseName, OpenFlags, params};
use serde::Serialize;
use serde_json::Value;
const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db";
const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db";
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
const DEFAULT_SERVICE_UNIT: &str = "activitywatch-server.service";
const DEFAULT_LOCK_PATH: &str = "/run/aw-db-maintenance.lock";
const ALLOWED_EVENT_TYPES: &[&str] = &["process_start", "process_stop"];
#[derive(Debug, Parser)]
#[command(about = "Safe ActivityWatch SQLite maintenance for old process-level session events")]
struct Cli {
#[arg(long, default_value = DEFAULT_DB_PATH, env = "AW_DB_MAINTENANCE_DB_PATH")]
db_path: PathBuf,
#[arg(long, default_value = DEFAULT_BACKUP_DIR, env = "AW_DB_MAINTENANCE_BACKUP_DIR")]
backup_dir: PathBuf,
#[arg(long, env = "AW_DB_MAINTENANCE_SESSION_BUCKET")]
session_bucket: Option<String>,
#[arg(long, env = "AW_WORKTIME_HOST")]
host: Option<String>,
#[arg(long, default_value_t = 7, env = "AW_DB_MAINTENANCE_RETENTION_DAYS")]
retention_days: i64,
#[arg(long, default_value_t = 1000, env = "AW_DB_MAINTENANCE_CHUNK_SIZE")]
chunk_size: usize,
#[arg(long)]
apply: bool,
#[arg(long)]
vacuum: bool,
#[arg(
long,
default_value = DEFAULT_SERVICE_UNIT,
env = "AW_DB_MAINTENANCE_SERVICE_UNIT"
)]
service_unit: String,
#[arg(long, default_value = DEFAULT_LOCK_PATH, env = "AW_DB_MAINTENANCE_LOCK_PATH")]
lock_path: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Debug, Serialize)]
struct Report {
apply: bool,
generated_at_utc: String,
db_path: String,
backup_path: Option<String>,
bucket: String,
bucketrow: Option<i64>,
retention_days: i64,
cutoff_ns: i64,
allowed_event_types: Vec<&'static str>,
planned_delete_rows: usize,
deleted_rows: usize,
backup_created: bool,
lock_path: String,
skipped_reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct VacuumReport {
apply: bool,
generated_at_utc: String,
db_path: String,
service_unit: String,
service_was_active: bool,
service_restarted: bool,
backup_path: Option<String>,
backup_created: bool,
lock_path: String,
db_size_before_bytes: Option<u64>,
vacuumed_path: Option<String>,
vacuumed_size_bytes: Option<u64>,
integrity_check: Option<String>,
replaced_db: bool,
skipped_reason: Option<String>,
}
struct VacuumResult {
backup_path: PathBuf,
vacuumed_path: PathBuf,
db_size_before_bytes: u64,
vacuumed_size_bytes: u64,
integrity_check: String,
}
struct ServiceGuard {
unit: String,
was_active: bool,
restored: bool,
}
struct TempFileGuard {
path: PathBuf,
keep: bool,
}
struct LockFileGuard {
path: PathBuf,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
if cli.vacuum {
let report = build_vacuum_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_vacuum_text(&report);
}
} else {
let report = build_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_text(&report);
}
}
Ok(0)
}
fn build_report(cli: &Cli) -> Result<Report> {
if cli.retention_days < 1 {
bail!("retention_days must be >= 1");
}
if cli.chunk_size == 0 {
bail!("chunk_size must be > 0");
}
let bucket = cli.session_bucket.clone().unwrap_or_else(|| {
format!(
"aw-session-events_{}",
cli.host.as_deref().unwrap_or(DEFAULT_HOST)
)
});
let cutoff_ns = (Utc::now().timestamp() - cli.retention_days * 86_400) * 1_000_000_000;
let conn = open_connection(&cli.db_path, cli.apply)?;
let bucketrow = bucket_row(&conn, &bucket)?;
let Some(bucketrow) = bucketrow else {
return Ok(base_report(
cli,
bucket,
None,
cutoff_ns,
0,
0,
None,
false,
Some("session bucket not found".to_string()),
));
};
let delete_ids = find_deletable_event_ids(&conn, bucketrow, cutoff_ns)?;
let planned = delete_ids.len();
let mut backup_file = None;
let mut backup_created = false;
let mut deleted = 0;
let _lock_guard = if cli.apply && planned > 0 {
Some(LockFileGuard::acquire(&cli.lock_path)?)
} else {
None
};
if cli.apply && planned > 0 {
fs::create_dir_all(&cli.backup_dir)
.with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?;
let backup = backup_path(&cli.backup_dir, "aw-sqlite-before-db-maintenance");
copy_sqlite_via_backup(&cli.db_path, &backup)?;
backup_file = Some(backup);
backup_created = true;
deleted = delete_events(&conn, &delete_ids, cli.chunk_size)?;
}
Ok(base_report(
cli,
bucket,
Some(bucketrow),
cutoff_ns,
planned,
deleted,
backup_file,
backup_created,
None,
))
}
fn build_vacuum_report(cli: &Cli) -> Result<VacuumReport> {
if !cli.db_path.exists() {
return Ok(vacuum_report(
cli,
false,
None,
false,
false,
false,
None,
None,
None,
None,
Some("database not found".to_string()),
));
}
if !cli.apply {
return Ok(vacuum_report(
cli,
false,
Some(file_size(&cli.db_path)?),
false,
false,
false,
None,
None,
None,
None,
Some("dry-run".to_string()),
));
}
let _lock_guard = LockFileGuard::acquire(&cli.lock_path)?;
let mut service_guard = ServiceGuard::stop_if_active(&cli.service_unit)?;
let service_was_active = service_guard.was_active;
let result = vacuum_sqlite_db(&cli.db_path, &cli.backup_dir)?;
let service_restarted = service_guard.restore()?;
Ok(vacuum_report(
cli,
true,
Some(result.db_size_before_bytes),
true,
service_was_active,
service_restarted,
Some(result.backup_path),
Some(result.vacuumed_path),
Some(result.vacuumed_size_bytes),
Some(result.integrity_check),
None,
))
}
#[allow(clippy::too_many_arguments)]
fn base_report(
cli: &Cli,
bucket: String,
bucketrow: Option<i64>,
cutoff_ns: i64,
planned_delete_rows: usize,
deleted_rows: usize,
backup_path: Option<PathBuf>,
backup_created: bool,
skipped_reason: Option<String>,
) -> Report {
Report {
apply: cli.apply,
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
db_path: cli.db_path.display().to_string(),
backup_path: backup_path.map(|path| path.display().to_string()),
bucket,
bucketrow,
retention_days: cli.retention_days,
cutoff_ns,
allowed_event_types: ALLOWED_EVENT_TYPES.to_vec(),
planned_delete_rows,
deleted_rows,
backup_created,
lock_path: cli.lock_path.display().to_string(),
skipped_reason,
}
}
#[allow(clippy::too_many_arguments)]
fn vacuum_report(
cli: &Cli,
apply: bool,
db_size_before_bytes: Option<u64>,
backup_created: bool,
service_was_active: bool,
service_restarted: bool,
backup_path: Option<PathBuf>,
vacuumed_path: Option<PathBuf>,
vacuumed_size_bytes: Option<u64>,
integrity_check: Option<String>,
skipped_reason: Option<String>,
) -> VacuumReport {
VacuumReport {
apply,
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
db_path: cli.db_path.display().to_string(),
service_unit: cli.service_unit.clone(),
service_was_active,
service_restarted,
backup_path: backup_path.map(|path| path.display().to_string()),
backup_created,
lock_path: cli.lock_path.display().to_string(),
db_size_before_bytes,
vacuumed_path: vacuumed_path.map(|path| path.display().to_string()),
vacuumed_size_bytes,
integrity_check,
replaced_db: apply && skipped_reason.is_none(),
skipped_reason,
}
}
fn open_connection(path: &Path, writable: bool) -> Result<Connection> {
let flags = if writable {
OpenFlags::SQLITE_OPEN_READ_WRITE
} else {
OpenFlags::SQLITE_OPEN_READ_ONLY
};
let conn = Connection::open_with_flags(path, flags)
.with_context(|| format!("open SQLite DB {}", path.display()))?;
conn.busy_timeout(Duration::from_secs(10))?;
Ok(conn)
}
fn bucket_row(conn: &Connection, bucket: &str) -> Result<Option<i64>> {
let mut stmt =
conn.prepare("select rowid from buckets where name = ?1 order by rowid limit 1")?;
let mut rows = stmt.query([bucket])?;
Ok(rows.next()?.map(|row| row.get::<_, i64>(0)).transpose()?)
}
fn find_deletable_event_ids(conn: &Connection, bucketrow: i64, cutoff_ns: i64) -> Result<Vec<i64>> {
let mut stmt = conn
.prepare("select id, data from events where bucketrow = ?1 and endtime < ?2 order by id")?;
let rows = stmt.query_map(params![bucketrow, cutoff_ns], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
let mut ids = Vec::new();
for row in rows {
let (id, data) = row?;
if is_allowed_process_event(&data) {
ids.push(id);
}
}
Ok(ids)
}
fn is_allowed_process_event(data: &str) -> bool {
let Ok(value) = serde_json::from_str::<Value>(data) else {
return false;
};
let event_type = value
.pointer("/eventType")
.or_else(|| value.pointer("/data/eventType"))
.and_then(Value::as_str);
event_type.is_some_and(|event_type| ALLOWED_EVENT_TYPES.contains(&event_type))
}
fn copy_sqlite_via_backup(src: &Path, dst: &Path) -> Result<()> {
let source =
Connection::open(src).with_context(|| format!("open backup source {}", src.display()))?;
source
.backup(DatabaseName::Main, dst, None)
.with_context(|| format!("backup {} to {}", src.display(), dst.display()))
}
fn delete_events(conn: &Connection, ids: &[i64], chunk_size: usize) -> Result<usize> {
let mut deleted = 0;
for chunk in ids.chunks(chunk_size) {
let tx = conn.unchecked_transaction()?;
{
let mut stmt = tx.prepare("delete from events where id = ?1")?;
for id in chunk {
deleted += stmt.execute([id])?;
}
}
tx.commit()?;
}
Ok(deleted)
}
fn backup_path(backup_dir: &Path, prefix: &str) -> PathBuf {
backup_dir.join(format!(
"{}-{}.db",
prefix,
Utc::now().format("%Y%m%dT%H%M%SZ")
))
}
fn vacuum_sqlite_db(db_path: &Path, backup_dir: &Path) -> Result<VacuumResult> {
fs::create_dir_all(backup_dir)
.with_context(|| format!("create backup dir {}", backup_dir.display()))?;
let db_size_before_bytes = file_size(db_path)?;
let backup_path = backup_path(backup_dir, "aw-sqlite-before-db-vacuum");
copy_sqlite_via_backup(db_path, &backup_path)?;
let vacuumed_path = vacuumed_path(db_path)?;
let mut vacuum_cleanup = TempFileGuard::new(vacuumed_path.clone());
vacuum_into(db_path, &vacuumed_path)?;
preserve_sqlite_metadata(db_path, &vacuumed_path)?;
let vacuumed_size_bytes = file_size(&vacuumed_path)?;
let integrity_check = integrity_check(&vacuumed_path)?;
remove_sqlite_sidecars(db_path)?;
fs::rename(&vacuumed_path, db_path).with_context(|| {
format!(
"replace {} with {}",
db_path.display(),
vacuumed_path.display()
)
})?;
vacuum_cleanup.disarm();
Ok(VacuumResult {
backup_path,
vacuumed_path,
db_size_before_bytes,
vacuumed_size_bytes,
integrity_check,
})
}
fn vacuum_into(src: &Path, dst: &Path) -> Result<()> {
let conn = open_connection(src, true)?;
let sql = format!("VACUUM INTO {}", sqlite_string_literal(dst));
conn.execute_batch(&sql)
.with_context(|| format!("VACUUM INTO {}", dst.display()))
}
fn integrity_check(path: &Path) -> Result<String> {
let conn = open_connection(path, false)?;
let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if result != "ok" {
bail!("integrity_check failed for {}: {result}", path.display());
}
Ok(result)
}
fn remove_sqlite_sidecars(db_path: &Path) -> Result<()> {
for suffix in ["-wal", "-shm", "-journal"] {
let sidecar = sqlite_sidecar_path(db_path, suffix)?;
match fs::remove_file(&sidecar) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err).with_context(|| format!("remove {}", sidecar.display())),
}
}
Ok(())
}
fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> Result<PathBuf> {
let file_name = db_path
.file_name()
.and_then(|value| value.to_str())
.context("database path must have a file name")?;
Ok(db_path.with_file_name(format!("{file_name}{suffix}")))
}
fn vacuumed_path(db_path: &Path) -> Result<PathBuf> {
let file_name = db_path
.file_name()
.and_then(|value| value.to_str())
.context("database path must have a file name")?;
Ok(db_path.with_file_name(format!(
"{file_name}.vacuumed-{}",
Utc::now().format("%Y%m%dT%H%M%SZ")
)))
}
fn file_size(path: &Path) -> Result<u64> {
Ok(fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.len())
}
fn sqlite_string_literal(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "''"))
}
fn preserve_sqlite_metadata(src: &Path, dst: &Path) -> Result<()> {
let metadata = fs::metadata(src).with_context(|| format!("stat {}", src.display()))?;
let permissions = metadata.permissions();
fs::set_permissions(dst, permissions)
.with_context(|| format!("preserve permissions for {}", dst.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let dst_metadata = fs::metadata(dst).with_context(|| format!("stat {}", dst.display()))?;
if dst_metadata.uid() != metadata.uid() || dst_metadata.gid() != metadata.gid() {
let status = Command::new("chown")
.arg(format!("{}:{}", metadata.uid(), metadata.gid()))
.arg(dst)
.status()
.context("run chown for vacuumed SQLite DB")?;
if !status.success() {
bail!("chown failed for {}", dst.display());
}
}
}
Ok(())
}
fn systemctl_is_active(unit: &str) -> Result<bool> {
let load_state = systemctl_load_state(unit)?;
if load_state != "loaded" {
bail!("refusing SQLite VACUUM because systemd unit {unit} load_state={load_state:?}");
}
let output = Command::new("systemctl")
.args(["is-active", unit])
.output()
.with_context(|| format!("systemctl is-active {unit}"))?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() && state == "active" {
return Ok(true);
}
if output.status.code() == Some(3) && state == "inactive" {
return Ok(false);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"refusing SQLite VACUUM because systemctl is-active {unit} returned state={state:?}, status={}, stderr={stderr:?}",
output.status
);
}
fn systemctl_load_state(unit: &str) -> Result<String> {
let output = Command::new("systemctl")
.args(["show", "-p", "LoadState", "--value", unit])
.output()
.with_context(|| format!("systemctl show LoadState {unit}"))?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() && !state.is_empty() {
return Ok(state);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"refusing SQLite VACUUM because systemctl show LoadState {unit} failed with status={}, stderr={stderr:?}",
output.status
);
}
fn systemctl_action(action: &str, unit: &str) -> Result<()> {
let status = Command::new("systemctl")
.args([action, unit])
.status()
.with_context(|| format!("systemctl {action} {unit}"))?;
if status.success() {
Ok(())
} else {
bail!("systemctl {action} {unit} failed with status {status}");
}
}
impl ServiceGuard {
fn stop_if_active(unit: &str) -> Result<Self> {
let was_active = systemctl_is_active(unit)?;
if was_active {
systemctl_action("stop", unit)?;
}
Ok(Self {
unit: unit.to_string(),
was_active,
restored: !was_active,
})
}
fn restore(&mut self) -> Result<bool> {
if self.was_active && !self.restored {
systemctl_action("start", &self.unit)?;
self.restored = true;
}
Ok(self.was_active)
}
}
impl Drop for ServiceGuard {
fn drop(&mut self) {
if self.was_active && !self.restored {
let _ = systemctl_action("start", &self.unit);
}
}
}
impl TempFileGuard {
fn new(path: PathBuf) -> Self {
Self { path, keep: false }
}
fn disarm(&mut self) {
self.keep = true;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if !self.keep {
let _ = fs::remove_file(&self.path);
}
}
}
impl LockFileGuard {
fn acquire(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create lock parent {}", parent.display()))?;
}
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
bail!("maintenance lock already exists: {}", path.display());
}
Err(err) => return Err(err).with_context(|| format!("create lock {}", path.display())),
};
writeln!(
file,
"pid={} generated_at_utc={}",
std::process::id(),
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
)
.with_context(|| format!("write lock {}", path.display()))?;
Ok(Self {
path: path.to_path_buf(),
})
}
}
impl Drop for LockFileGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn print_text(report: &Report) {
println!(
"aw-db-maintenance: {}",
if report.apply { "apply" } else { "dry-run" }
);
println!("db_path: {}", report.db_path);
println!("bucket: {}", report.bucket);
println!("retention_days: {}", report.retention_days);
println!("planned_delete_rows: {}", report.planned_delete_rows);
println!("deleted_rows: {}", report.deleted_rows);
println!("backup_created: {}", report.backup_created);
println!("lock_path: {}", report.lock_path);
if let Some(path) = &report.backup_path {
println!("backup_path: {path}");
}
if let Some(reason) = &report.skipped_reason {
println!("skipped_reason: {reason}");
}
}
fn print_vacuum_text(report: &VacuumReport) {
println!(
"aw-db-vacuum: {}",
if report.apply { "apply" } else { "dry-run" }
);
println!("db_path: {}", report.db_path);
println!("service_unit: {}", report.service_unit);
println!("service_was_active: {}", report.service_was_active);
println!("service_restarted: {}", report.service_restarted);
println!("backup_created: {}", report.backup_created);
println!("lock_path: {}", report.lock_path);
if let Some(path) = &report.backup_path {
println!("backup_path: {path}");
}
if let Some(size) = report.db_size_before_bytes {
println!("db_size_before_bytes: {size}");
}
if let Some(path) = &report.vacuumed_path {
println!("vacuumed_path: {path}");
}
if let Some(size) = report.vacuumed_size_bytes {
println!("vacuumed_size_bytes: {size}");
}
if let Some(check) = &report.integrity_check {
println!("integrity_check: {check}");
}
println!("replaced_db: {}", report.replaced_db);
if let Some(reason) = &report.skipped_reason {
println!("skipped_reason: {reason}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_allows_process_start_stop_events() {
assert!(is_allowed_process_event(r#"{"eventType":"process_start"}"#));
assert!(is_allowed_process_event(
r#"{"data":{"eventType":"process_stop"}}"#
));
assert!(!is_allowed_process_event(r#"{"eventType":"logon"}"#));
assert!(!is_allowed_process_event(r#"not-json"#));
}
#[test]
fn dry_run_does_not_delete_or_backup() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("aw.db");
create_fixture_db(&db);
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: Some("aw-session-events_TEST".to_string()),
host: None,
retention_days: 7,
chunk_size: 100,
apply: false,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_report(&cli).unwrap();
assert_eq!(report.planned_delete_rows, 2);
assert_eq!(report.deleted_rows, 0);
assert!(!report.backup_created);
assert_eq!(count_events(&db), 3);
}
#[test]
fn apply_deletes_only_old_process_events_and_keeps_logon() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("aw.db");
create_fixture_db(&db);
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: Some("aw-session-events_TEST".to_string()),
host: None,
retention_days: 7,
chunk_size: 1,
apply: true,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_report(&cli).unwrap();
assert_eq!(report.planned_delete_rows, 2);
assert_eq!(report.deleted_rows, 2);
assert!(report.backup_created);
assert_eq!(count_events(&db), 1);
}
#[test]
fn vacuum_apply_compacts_database_and_preserves_rows() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("sqlite.db");
create_vacuum_fixture_db(&db);
let before = file_size(&db).unwrap();
let result = vacuum_sqlite_db(&db, dir.path()).unwrap();
let after = file_size(&db).unwrap();
assert!(result.vacuumed_size_bytes < result.db_size_before_bytes);
assert!(after < before);
assert_eq!(result.integrity_check, "ok");
assert!(result.backup_path.exists());
assert_eq!(count_rows(&db), 32);
}
#[test]
fn vacuum_dry_run_skips_mutation() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("sqlite.db");
create_vacuum_fixture_db(&db);
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: None,
host: None,
retention_days: 7,
chunk_size: 100,
apply: false,
vacuum: true,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_vacuum_report(&cli).unwrap();
assert!(!report.backup_created);
assert!(!report.replaced_db);
assert_eq!(report.skipped_reason.as_deref(), Some("dry-run"));
assert_eq!(count_rows(&db), 32);
}
#[test]
fn apply_refuses_when_lock_exists() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("aw.db");
create_fixture_db(&db);
let lock_path = dir.path().join("maintenance.lock");
fs::write(&lock_path, "busy").unwrap();
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: Some("aw-session-events_TEST".to_string()),
host: None,
retention_days: 7,
chunk_size: 1,
apply: true,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path,
json: true,
};
let err = build_report(&cli).unwrap_err().to_string();
assert!(err.contains("maintenance lock already exists"));
assert_eq!(count_events(&db), 3);
}
fn create_fixture_db(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"create table buckets (name text);
create table events (id integer primary key autoincrement, bucketrow integer, starttime integer, endtime integer, data text);",
)
.unwrap();
conn.execute(
"insert into buckets (name) values ('aw-session-events_TEST')",
[],
)
.unwrap();
conn.execute(
"insert into events (bucketrow,starttime,endtime,data) values (1,1,2,'{\"eventType\":\"process_start\"}')",
[],
)
.unwrap();
conn.execute(
"insert into events (bucketrow,starttime,endtime,data) values (1,3,4,'{\"eventType\":\"process_stop\"}')",
[],
)
.unwrap();
conn.execute(
"insert into events (bucketrow,starttime,endtime,data) values (1,5,6,'{\"eventType\":\"logon\"}')",
[],
)
.unwrap();
}
fn create_vacuum_fixture_db(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"create table items (id integer primary key autoincrement, payload text);",
)
.unwrap();
let payload = "x".repeat(4096);
for _ in 0..64 {
conn.execute("insert into items (payload) values (?1)", [&payload])
.unwrap();
}
for id in 1..=32 {
conn.execute("delete from items where id = ?1", [id])
.unwrap();
}
}
fn count_events(path: &Path) -> i64 {
Connection::open(path)
.unwrap()
.query_row("select count(*) from events", [], |row| row.get(0))
.unwrap()
}
fn count_rows(path: &Path) -> i64 {
Connection::open(path)
.unwrap()
.query_row("select count(*) from items", [], |row| row.get(0))
.unwrap()
}
}
@@ -1,16 +0,0 @@
[package]
name = "aw-ensure-reliability"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -1,512 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde::Serialize;
const LOGROTATE_CONTENT: &str = include_str!("../../../../aw-server/logrotate.conf");
const HEALTH_TIMER_CONTENT: &str = r#"[Unit]
Description=AW Health Check Timer
Requires=aw-health-check.service
[Timer]
OnCalendar=*:0/5:00
Persistent=true
[Install]
WantedBy=timers.target
"#;
const HEALTH_SERVICE_CONTENT: &str = r#"[Unit]
Description=AW Health Check
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/aw-health-check
User=root
Group=root
"#;
#[derive(Debug, Parser)]
#[command(about = "Plan or apply AW service reliability hardening")]
struct Cli {
#[arg(long, default_value = "/etc/activitywatch/aw-server.env")]
env_file: PathBuf,
#[arg(long, default_value = "/var/lib/activitywatch")]
data_dir: PathBuf,
#[arg(long, default_value = "/var/log/activitywatch")]
log_dir: PathBuf,
#[arg(long, default_value = "/opt/activitywatch")]
opt_dir: PathBuf,
#[arg(long, default_value = "/etc/logrotate.d/activitywatch")]
logrotate_target: PathBuf,
#[arg(long, default_value = "/usr/local/bin/aw-health-check")]
health_script_target: PathBuf,
#[arg(long, default_value = "/etc/systemd/system/aw-health-check.timer")]
health_timer_target: PathBuf,
#[arg(long, default_value = "/etc/systemd/system/aw-health-check.service")]
health_service_target: PathBuf,
#[arg(long, default_value_t = false)]
apply: bool,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum StepKind {
Check,
Chown,
Chmod,
Write,
Systemd,
Sleep,
}
#[derive(Debug, Clone, Serialize)]
struct Step {
order: usize,
name: String,
kind: StepKind,
command: String,
mutation: bool,
needed: bool,
reason: String,
}
#[derive(Debug, Serialize)]
struct Report {
apply: bool,
ok: bool,
env_file: PathBuf,
missing_required: Vec<String>,
steps: Vec<Step>,
executed: Vec<ExecResult>,
}
#[derive(Debug, Serialize)]
struct ExecResult {
order: usize,
name: String,
ok: bool,
exit_code: Option<i32>,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let mut report = build_report(&cli);
if cli.apply {
if !report.missing_required.is_empty() {
report.ok = false;
print_report(&report, cli.json)?;
bail!("refusing --apply because required inputs are missing");
}
apply_steps(&mut report)?;
}
print_report(&report, cli.json)?;
Ok(if report.ok { 0 } else { 1 })
}
fn build_report(cli: &Cli) -> Report {
let mut missing_required = Vec::new();
if !cli.env_file.is_file() {
missing_required.push(format!("env file missing: {}", cli.env_file.display()));
}
if !cli.health_script_target.is_file() {
missing_required.push(format!(
"health script target missing: {}",
cli.health_script_target.display()
));
}
let mut steps = Vec::new();
push_step(
&mut steps,
"check-env-file",
StepKind::Check,
format!("test -f {}", shell_quote(&cli.env_file)),
false,
!cli.env_file.is_file(),
"required before reliability actions".to_string(),
);
for dir in [&cli.data_dir, &cli.log_dir, &cli.opt_dir] {
push_step(
&mut steps,
format!("chown-{}", dir.display()),
StepKind::Chown,
format!("chown -R activitywatch:activitywatch {}", shell_quote(dir)),
true,
true,
"preserve legacy ownership repair".to_string(),
);
}
for dir in [&cli.data_dir, &cli.log_dir, &cli.opt_dir] {
push_step(
&mut steps,
format!("chmod-{}", dir.display()),
StepKind::Chmod,
format!("chmod 755 {}", shell_quote(dir)),
true,
true,
"preserve legacy directory mode repair".to_string(),
);
}
push_step(
&mut steps,
"install-logrotate",
StepKind::Write,
format!("write {}", shell_quote(&cli.logrotate_target)),
true,
!cli.logrotate_target.is_file(),
if cli.logrotate_target.is_file() {
"logrotate already configured".to_string()
} else {
"logrotate target missing".to_string()
},
);
push_step(
&mut steps,
"check-health-script",
StepKind::Check,
format!("test -x {}", shell_quote(&cli.health_script_target)),
false,
!cli.health_script_target.is_file(),
if cli.health_script_target.is_file() {
"health script already installed by Ansible".to_string()
} else {
"health script target missing".to_string()
},
);
for (name, path, content_name) in [
(
"install-health-timer",
&cli.health_timer_target,
"aw-health-check.timer",
),
(
"install-health-service",
&cli.health_service_target,
"aw-health-check.service",
),
] {
push_step(
&mut steps,
name,
StepKind::Write,
format!("write {} ({content_name})", shell_quote(path)),
true,
!path.is_file(),
if path.is_file() {
format!("{content_name} already installed")
} else {
format!("{content_name} target missing")
},
);
}
for (name, command) in [
("daemon-reload-before-restart", "systemctl daemon-reload"),
(
"stop-services",
"systemctl stop aw-worktime-api aw-worktime-ui-bridge activitywatch-server || true",
),
("sleep-after-stop", "sleep 2"),
(
"start-activitywatch-server",
"systemctl start activitywatch-server",
),
("sleep-after-server-start", "sleep 3"),
("start-worktime-api", "systemctl start aw-worktime-api"),
("sleep-after-api-start", "sleep 2"),
(
"start-worktime-ui-bridge",
"systemctl start aw-worktime-ui-bridge",
),
(
"enable-activitywatch-server",
"systemctl enable activitywatch-server",
),
("enable-worktime-api", "systemctl enable aw-worktime-api"),
(
"enable-worktime-ui-bridge",
"systemctl enable aw-worktime-ui-bridge",
),
("daemon-reload-health", "systemctl daemon-reload"),
(
"enable-health-timer",
"systemctl enable aw-health-check.timer",
),
(
"start-health-timer",
"systemctl start aw-health-check.timer",
),
] {
let kind = if command.starts_with("sleep") {
StepKind::Sleep
} else {
StepKind::Systemd
};
push_step(
&mut steps,
name,
kind,
command.to_string(),
true,
true,
"preserve legacy reliability action".to_string(),
);
}
Report {
apply: cli.apply,
ok: missing_required.is_empty(),
env_file: cli.env_file.clone(),
missing_required,
steps,
executed: Vec::new(),
}
}
fn push_step(
steps: &mut Vec<Step>,
name: impl Into<String>,
kind: StepKind,
command: String,
mutation: bool,
needed: bool,
reason: String,
) {
steps.push(Step {
order: steps.len() + 1,
name: name.into(),
kind,
command,
mutation,
needed,
reason,
});
}
fn apply_steps(report: &mut Report) -> Result<()> {
let steps = report.steps.clone();
for step in steps.iter().filter(|step| step.needed) {
let result = match step.name.as_str() {
"check-env-file" => ExecResult {
order: step.order,
name: step.name.clone(),
ok: Path::new(&report.env_file).is_file(),
exit_code: Some(if Path::new(&report.env_file).is_file() {
0
} else {
1
}),
},
"check-health-script" => {
let target = Path::new("/usr/local/bin/aw-health-check");
ExecResult {
order: step.order,
name: step.name.clone(),
ok: target.is_file(),
exit_code: Some(if target.is_file() { 0 } else { 1 }),
}
}
"install-logrotate" => write_file_result(step, report, LOGROTATE_CONTENT, 0o644)?,
"install-health-timer" => write_file_result(step, report, HEALTH_TIMER_CONTENT, 0o644)?,
"install-health-service" => {
write_file_result(step, report, HEALTH_SERVICE_CONTENT, 0o644)?
}
_ => run_shell_step(step)?,
};
let ok = result.ok;
report.executed.push(result);
if !ok {
report.ok = false;
return Ok(());
}
}
report.ok = true;
Ok(())
}
fn write_file_result(step: &Step, report: &Report, content: &str, mode: u32) -> Result<ExecResult> {
let target = match step.name.as_str() {
"install-logrotate" => target_from_command(&step.command)?,
"install-health-timer" => target_from_command(&step.command)?,
"install-health-service" => target_from_command(&step.command)?,
_ => bail!("unsupported write step {}", step.name),
};
let _ = report;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(&target, content).with_context(|| format!("write {}", target.display()))?;
set_mode(&target, mode).with_context(|| format!("chmod {:o} {}", mode, target.display()))?;
Ok(ExecResult {
order: step.order,
name: step.name.clone(),
ok: true,
exit_code: Some(0),
})
}
fn run_shell_step(step: &Step) -> Result<ExecResult> {
let status = Command::new("sh")
.arg("-c")
.arg(&step.command)
.status()
.with_context(|| format!("run {}", step.command))?;
Ok(ExecResult {
order: step.order,
name: step.name.clone(),
ok: status.success(),
exit_code: status.code(),
})
}
fn target_from_command(command: &str) -> Result<PathBuf> {
let raw = command
.split_whitespace()
.nth(1)
.or_else(|| command.split_whitespace().nth(2))
.context("parse target from command")?;
Ok(PathBuf::from(raw.trim_matches('\'')))
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(mode);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
fn print_report(report: &Report, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(report)?);
return Ok(());
}
println!(
"aw-ensure-reliability: {}",
if report.apply { "apply" } else { "dry-run" }
);
println!("env_file: {}", report.env_file.display());
println!("ok: {}", report.ok);
if !report.missing_required.is_empty() {
println!("missing_required:");
for item in &report.missing_required {
println!(" - {item}");
}
}
println!("planned steps:");
for step in &report.steps {
let risk = if step.mutation { "MUTATION" } else { "check" };
let needed = if step.needed { "needed" } else { "skip" };
println!(
" {:02}. {:<28} {:<8} {:<6} {}",
step.order, step.name, risk, needed, step.command
);
}
if report.executed.is_empty() {
println!("No mutation executed. Use --apply for explicit reliability fix.");
} else {
println!("executed:");
for item in &report.executed {
println!(
" {:02}. {:<28} ok={} exit={:?}",
item.order, item.name, item.ok, item.exit_code
);
}
}
Ok(())
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dry_run_marks_legacy_mutations() {
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join("aw-server.env");
let health_target = dir.path().join("bin/aw-health-check");
fs::write(&env_file, "AW_BASE_URL=http://127.0.0.1:5600\n").unwrap();
fs::create_dir_all(health_target.parent().unwrap()).unwrap();
fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap();
let cli = Cli {
env_file,
data_dir: dir.path().join("data"),
log_dir: dir.path().join("log"),
opt_dir: dir.path().join("opt"),
logrotate_target: dir.path().join("logrotate/activitywatch"),
health_script_target: health_target,
health_timer_target: dir.path().join("systemd/aw-health-check.timer"),
health_service_target: dir.path().join("systemd/aw-health-check.service"),
apply: false,
json: true,
};
let report = build_report(&cli);
assert!(report.ok);
assert!(report.steps.iter().any(|step| step.name == "stop-services"));
assert!(
report
.steps
.iter()
.any(|step| step.name == "install-logrotate")
);
assert!(report.steps.iter().any(|step| step.mutation));
}
#[test]
fn missing_env_blocks_apply() {
let dir = tempfile::tempdir().unwrap();
let health_target = dir.path().join("bin/aw-health-check");
fs::create_dir_all(health_target.parent().unwrap()).unwrap();
fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap();
let cli = Cli {
env_file: dir.path().join("missing.env"),
data_dir: dir.path().join("data"),
log_dir: dir.path().join("log"),
opt_dir: dir.path().join("opt"),
logrotate_target: dir.path().join("logrotate/activitywatch"),
health_script_target: health_target,
health_timer_target: dir.path().join("systemd/aw-health-check.timer"),
health_service_target: dir.path().join("systemd/aw-health-check.service"),
apply: true,
json: true,
};
let report = build_report(&cli);
assert!(!report.ok);
assert_eq!(report.missing_required.len(), 1);
}
}
@@ -1,12 +0,0 @@
[package]
name = "aw-health-check"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
reqwest.workspace = true
serde_json.workspace = true
-495
View File
@@ -1,495 +0,0 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use reqwest::blocking::Client;
use serde_json::Value;
const ENV_PATH: &str = "/etc/activitywatch/aw-server.env";
const SERVICES: &[&str] = &[
"activitywatch-server",
"aw-worktime-api",
"aw-worktime-ui-bridge",
];
const DLP_TRANSPORT_CHECKS: &[&str] = &[
"buckets:endpoint-signals",
"buckets:file-operations",
"endpoint-self-test-metrics",
];
const AW_DB_HEALTH_CHECKS: &[&str] = &[
"sqlite:file-size",
"sqlite:wal-size",
"aw-session-events:rows",
"aw-session-events:recent-process-events",
"windows-config:process-events",
];
#[derive(Debug, Default)]
struct HealthState {
unhealthy: Vec<String>,
warnings: Vec<String>,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let env = load_effective_env(Path::new(ENV_PATH))?;
let client = Client::builder()
.timeout(Duration::from_secs(30))
.no_proxy()
.build()
.context("build HTTP client")?;
let mut state = HealthState::default();
println!("=== AW Services Health Check ===");
println!(
"Timestamp: {}",
command_stdout("date", &[]).unwrap_or_else(|_| "unknown".to_string())
);
println!();
for service in SERVICES {
check_service(&mut state, service)?;
}
println!();
let worktime_base = env_first(
&env,
"AW_RUS_HEALTH_WORKTIME_API",
&env_first(&env, "AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610"),
);
let worktime_url = format!("{}/health", worktime_base.trim_end_matches('/'));
let worktime_timeout = env_i64(&env, "AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS", 15);
let aw_timeout = env_i64(&env, "AW_RUS_HEALTH_AW_TIMEOUT_SECONDS", 15);
let aw_attempts = env_i64(&env, "AW_RUS_HEALTH_AW_ATTEMPTS", 3);
let settings_timeout = env_i64(&env, "AW_RUS_HEALTH_SETTINGS_TIMEOUT_SECONDS", 15);
let settings_attempts = env_i64(&env, "AW_RUS_HEALTH_SETTINGS_ATTEMPTS", 3);
check_api_endpoint(
&client,
&mut state,
"http://127.0.0.1:5600/api/0/info",
"activitywatch-server",
aw_timeout,
aw_attempts,
);
check_api_endpoint(
&client,
&mut state,
&worktime_url,
"aw-worktime-api",
worktime_timeout,
2,
);
check_dlp_transport_freshness(&mut state, &env);
check_aw_db_health(&mut state, &env);
check_expected_setting(
&client,
&mut state,
"startOfDay",
env_first(&env, "AW_EXPECT_START_OF_DAY", ""),
"startOfDay",
settings_timeout,
settings_attempts,
);
check_expected_setting(
&client,
&mut state,
"always_active_pattern",
env_first(&env, "AW_EXPECT_ALWAYS_ACTIVE_PATTERN", ""),
"always_active_pattern",
settings_timeout,
settings_attempts,
);
check_expected_setting(
&client,
&mut state,
"landingpage",
env_first(&env, "AW_EXPECT_LANDINGPAGE", ""),
"landingpage",
settings_timeout,
settings_attempts,
);
println!();
if state.unhealthy.is_empty() {
println!("✓ All services are healthy");
if !state.warnings.is_empty() {
println!("⚠ Warnings: {}", state.warnings.join(" "));
}
Ok(0)
} else {
println!("✗ Unhealthy services: {}", state.unhealthy.join(" "));
Ok(1)
}
}
fn check_service(state: &mut HealthState, service: &str) -> Result<()> {
if service == "aw-worktime-ui-bridge" {
let active = systemctl_success(&["is-active", "--quiet", "aw-worktime-ui-bridge.timer"])?;
let enabled = systemctl_success(&["is-enabled", "--quiet", "aw-worktime-ui-bridge.timer"])?;
if active && enabled {
println!("✓ aw-worktime-ui-bridge.timer is running and enabled");
} else {
println!("✗ aw-worktime-ui-bridge.timer is not active/enabled");
state
.unhealthy
.push("aw-worktime-ui-bridge.timer".to_string());
}
return Ok(());
}
if systemctl_success(&["is-active", "--quiet", service])? {
println!("{service} is running");
} else {
println!("{service} is not running");
state.unhealthy.push(service.to_string());
}
Ok(())
}
fn systemctl_success(args: &[&str]) -> Result<bool> {
Ok(Command::new("systemctl")
.args(args)
.status()
.with_context(|| format!("run systemctl {}", args.join(" ")))?
.success())
}
fn check_api_endpoint(
client: &Client,
state: &mut HealthState,
url: &str,
service_name: &str,
timeout_seconds: i64,
attempts: i64,
) {
for attempt in 1..=attempts.max(1) {
let result = client
.get(url)
.timeout(Duration::from_secs(timeout_seconds.max(1) as u64))
.send()
.and_then(|resp| resp.error_for_status())
.map(|_| ());
if result.is_ok() {
println!("{service_name} API endpoint is responding");
return;
}
if attempt < attempts {
std::thread::sleep(Duration::from_secs(1));
}
}
println!("{service_name} API endpoint is not responding");
state.unhealthy.push(format!("{service_name}-api"));
}
fn check_expected_setting(
client: &Client,
state: &mut HealthState,
key: &str,
expected: String,
label: &str,
timeout_seconds: i64,
attempts: i64,
) {
if expected.is_empty() {
println!("⚠ expected value for {label} is not configured, skipping drift check");
state.warnings.push(format!("{key}-expected-missing"));
return;
}
match read_setting_value(client, key, timeout_seconds, attempts) {
Ok(actual) if actual == expected => {
println!("{label} matches expected value ({expected})");
}
Ok(actual) => {
println!("{label} drift detected: actual='{actual}' expected='{expected}'");
state.unhealthy.push(format!("setting-{key}"));
}
Err(_) => {
println!("✗ failed to read setting {label}");
state.unhealthy.push(format!("setting-{key}"));
}
}
}
fn read_setting_value(
client: &Client,
key: &str,
timeout_seconds: i64,
attempts: i64,
) -> Result<String> {
let url = format!("http://127.0.0.1:5600/api/0/settings/{key}");
for attempt in 1..=attempts.max(1) {
let result = client
.get(&url)
.timeout(Duration::from_secs(timeout_seconds.max(1) as u64))
.send()
.and_then(|resp| resp.error_for_status())
.and_then(|resp| resp.json::<Value>());
match result {
Ok(value) => return Ok(json_value_to_shell_print(value)),
Err(err) if attempt >= attempts => return Err(anyhow!(err)),
Err(_) => std::thread::sleep(Duration::from_secs(1)),
}
}
Err(anyhow!("setting read exhausted"))
}
fn check_dlp_transport_freshness(state: &mut HealthState, env: &HashMap<String, String>) {
let dlp_health = env_first(env, "DLP_HEALTH_BIN", "/usr/local/bin/dlp-health-check");
if !is_executable(Path::new(&dlp_health)) {
println!("⚠ dlp-health-check is not available, skipping DLP transport freshness checks");
state.warnings.push("dlp-health-check-missing".to_string());
return;
}
let output = match Command::new(&dlp_health).arg("--json").output() {
Ok(output) => output,
Err(_) => {
println!(
"⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks"
);
state.warnings.push("dlp-health-check-empty".to_string());
return;
}
};
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
println!("⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks");
state.warnings.push("dlp-health-check-empty".to_string());
return;
}
let payload = match serde_json::from_str::<Value>(&stdout) {
Ok(payload) => payload,
Err(_) => {
println!(
"⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks"
);
state.warnings.push("dlp-health-check-empty".to_string());
return;
}
};
let mut errors = Vec::new();
let mut warnings = Vec::new();
for result in payload
.get("results")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let name = result.get("name").and_then(Value::as_str).unwrap_or("");
if !DLP_TRANSPORT_CHECKS.contains(&name) {
continue;
}
let status = result.get("status").and_then(Value::as_str).unwrap_or("");
let summary = result.get("summary").and_then(Value::as_str).unwrap_or("");
if status == "fail" {
errors.push(format!("{name}:{summary}"));
} else if status == "warn" {
warnings.push(format!("{name}:{summary}"));
}
}
if errors.is_empty() {
println!("✓ DLP transport freshness check passed");
} else {
println!("✗ DLP transport freshness check failed");
state.unhealthy.push("dlp-transport".to_string());
}
if !errors.is_empty() {
println!(" errors: {}", errors.join(", "));
}
if !warnings.is_empty() {
let text = warnings.join(", ");
println!(" warnings: {text}");
state.warnings.push(text);
}
}
fn check_aw_db_health(state: &mut HealthState, env: &HashMap<String, String>) {
let aw_db_health = env_first(env, "AW_DB_HEALTH_BIN", "/usr/local/bin/aw-db-health");
if !is_executable(Path::new(&aw_db_health)) {
println!("⚠ aw-db-health is not available, skipping AW DB growth checks");
state.warnings.push("aw-db-health-missing".to_string());
return;
}
let output = match Command::new(&aw_db_health).arg("--json").output() {
Ok(output) => output,
Err(_) => {
println!("⚠ aw-db-health did not return JSON, skipping AW DB growth checks");
state.warnings.push("aw-db-health-empty".to_string());
return;
}
};
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
println!("⚠ aw-db-health did not return JSON, skipping AW DB growth checks");
state.warnings.push("aw-db-health-empty".to_string());
return;
}
let payload = match serde_json::from_str::<Value>(&stdout) {
Ok(payload) => payload,
Err(_) => {
println!("⚠ aw-db-health did not return JSON, skipping AW DB growth checks");
state.warnings.push("aw-db-health-empty".to_string());
return;
}
};
let mut errors = Vec::new();
let mut warnings = Vec::new();
for result in payload
.get("results")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let name = result.get("name").and_then(Value::as_str).unwrap_or("");
if !AW_DB_HEALTH_CHECKS.contains(&name) {
continue;
}
let status = result.get("status").and_then(Value::as_str).unwrap_or("");
let summary = result.get("summary").and_then(Value::as_str).unwrap_or("");
if status == "fail" {
errors.push(format!("{name}:{summary}"));
} else if status == "warn" {
warnings.push(format!("{name}:{summary}"));
}
}
if errors.is_empty() {
println!("✓ AW DB growth guard passed");
} else {
println!("✗ AW DB growth guard failed");
state.unhealthy.push("aw-db-health".to_string());
}
if !errors.is_empty() {
println!(" errors: {}", errors.join(", "));
}
if !warnings.is_empty() {
let text = warnings.join(", ");
println!(" warnings: {text}");
state.warnings.push(text);
}
}
fn command_stdout(program: &str, args: &[&str]) -> Result<String> {
let output = Command::new(program).args(args).output()?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn is_executable(path: &Path) -> bool {
path.is_file()
}
fn load_effective_env(path: &Path) -> Result<HashMap<String, String>> {
let mut env = std::env::vars().collect::<HashMap<_, _>>();
if path.is_file() {
for (key, value) in parse_env_file(&fs::read_to_string(path)?) {
env.insert(key, value);
}
}
Ok(env)
}
fn parse_env_file(text: &str) -> HashMap<String, String> {
let mut values = HashMap::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || !line.contains('=') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty()
|| !key
.chars()
.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
{
continue;
}
values.insert(key.to_string(), unquote_env_value(value.trim()));
}
values
}
fn unquote_env_value(value: &str) -> String {
if value.len() >= 2 {
let bytes = value.as_bytes();
if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
{
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
fn env_first(env: &HashMap<String, String>, key: &str, default: &str) -> String {
env.get(key)
.filter(|value| !value.is_empty())
.cloned()
.unwrap_or_else(|| default.to_string())
}
fn env_i64(env: &HashMap<String, String>, key: &str, default: i64) -> i64 {
env.get(key)
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(default)
}
fn json_value_to_shell_print(value: Value) -> String {
match value {
Value::String(value) => value,
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_env_file() {
let parsed = parse_env_file(
r#"
# comment
AW_EXPECT_START_OF_DAY="00:00"
AW_EXPECT_LANDINGPAGE=/#/activity/HOST-EXAMPLE/view/
BAD KEY=value
"#,
);
assert_eq!(parsed.get("AW_EXPECT_START_OF_DAY").unwrap(), "00:00");
assert_eq!(
parsed.get("AW_EXPECT_LANDINGPAGE").unwrap(),
"/#/activity/HOST-EXAMPLE/view/"
);
assert!(!parsed.contains_key("BAD KEY"));
}
#[test]
fn formats_json_setting_like_python_print_json_load() {
assert_eq!(
json_value_to_shell_print(Value::String("00:00".to_string())),
"00:00"
);
assert_eq!(json_value_to_shell_print(Value::Bool(true)), "true");
}
}
@@ -1,16 +0,0 @@
[package]
name = "aw-linux-install"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -1,290 +0,0 @@
use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::{Parser, ValueEnum};
use serde::Serialize;
#[derive(Debug, Clone, Copy, ValueEnum, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum InstallKind {
Client,
RemoteWorker,
ConsoleSsh,
WebCategory,
PveWebadmin,
}
#[derive(Debug, Parser)]
#[command(about = "Safe planner/apply wrapper for AW Linux install scripts")]
struct Cli {
#[arg(long, value_enum)]
kind: InstallKind,
#[arg(long)]
legacy_script: PathBuf,
#[arg(long, default_value = "192.0.2.13")]
server_host: String,
#[arg(long, default_value = "5600")]
server_port: String,
#[arg(long, default_value = "5")]
poll_interval: String,
#[arg(long, default_value = "0.13.2")]
version: String,
#[arg(long)]
install_base: Option<PathBuf>,
#[arg(long, default_value_t = false)]
force: bool,
#[arg(long, default_value_t = false)]
apply: bool,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Serialize)]
struct Plan {
apply: bool,
kind: InstallKind,
legacy_script: PathBuf,
required_files: Vec<Requirement>,
steps: Vec<Step>,
missing_count: usize,
}
#[derive(Debug, Serialize)]
struct Requirement {
name: String,
ok: bool,
detail: String,
}
#[derive(Debug, Serialize)]
struct Step {
order: usize,
name: String,
mutation: bool,
command: Vec<String>,
summary: String,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let plan = build_plan(&cli);
if !cli.apply {
print_plan(&plan, cli.json)?;
return Ok(if plan.missing_count == 0 { 0 } else { 2 });
}
if plan.missing_count > 0 {
print_plan(&plan, cli.json)?;
bail!("refusing --apply because required files are missing");
}
let Some(step) = plan.steps.first() else {
bail!("empty install plan");
};
let Some(program) = step.command.first() else {
bail!("empty legacy command");
};
let status = Command::new(program)
.args(&step.command[1..])
.status()
.with_context(|| format!("run {}", shell_join(&step.command)))?;
Ok(status.code().unwrap_or(1))
}
fn build_plan(cli: &Cli) -> Plan {
let required_files = vec![Requirement {
name: "legacy_script".to_string(),
ok: cli.legacy_script.is_file(),
detail: cli.legacy_script.display().to_string(),
}];
let command = legacy_command(cli);
let steps = vec![Step {
order: 1,
name: format!("{:?}", cli.kind).to_lowercase(),
mutation: true,
summary: summary(cli.kind).to_string(),
command,
}];
let missing_count = required_files.iter().filter(|item| !item.ok).count();
Plan {
apply: cli.apply,
kind: cli.kind,
legacy_script: cli.legacy_script.clone(),
required_files,
steps,
missing_count,
}
}
fn legacy_command(cli: &Cli) -> Vec<String> {
let mut command = vec![
"sh".to_string(),
cli.legacy_script.display().to_string(),
"--apply-legacy".to_string(),
"--server-host".to_string(),
cli.server_host.clone(),
"--server-port".to_string(),
cli.server_port.clone(),
];
match cli.kind {
InstallKind::Client => {
command.extend(["--version".to_string(), cli.version.clone()]);
if let Some(path) = &cli.install_base {
command.extend(["--install-base".to_string(), path.display().to_string()]);
}
if cli.force {
command.push("--force".to_string());
}
}
InstallKind::RemoteWorker => {
command.extend([
"--poll-interval".to_string(),
cli.poll_interval.clone(),
"--version".to_string(),
cli.version.clone(),
]);
}
InstallKind::ConsoleSsh | InstallKind::WebCategory | InstallKind::PveWebadmin => {
command.extend(["--poll-interval".to_string(), cli.poll_interval.clone()]);
}
}
command
}
fn summary(kind: InstallKind) -> &'static str {
match kind {
InstallKind::Client => {
"Install ActivityWatch Linux GUI watcher bundle and remote server config"
}
InstallKind::RemoteWorker => {
"Install Linux client, console/SSH logger, and web category logger"
}
InstallKind::ConsoleSsh => "Install console command and SSH session logger",
InstallKind::WebCategory => "Install Linux web category logger",
InstallKind::PveWebadmin => "Install Proxmox webadmin logger service",
}
}
fn print_plan(plan: &Plan, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(plan)?);
return Ok(());
}
println!(
"aw-linux-install: {}",
if plan.apply { "apply" } else { "dry-run" }
);
println!("kind: {:?}", plan.kind);
println!("legacy_script: {}", plan.legacy_script.display());
println!("missing_inputs: {}", plan.missing_count);
println!("required files:");
for item in &plan.required_files {
println!(
" [{}] {} - {}",
if item.ok { "OK" } else { "MISS" },
item.name,
item.detail
);
}
println!("planned steps:");
for step in &plan.steps {
println!(
" {:02}. MUTATION {} :: {}",
step.order,
step.summary,
shell_join(&step.command)
);
}
if !plan.apply {
println!("No install executed. Use --apply for explicit legacy install execution.");
}
Ok(())
}
fn shell_join(command: &[String]) -> String {
command
.iter()
.map(|part| {
if part
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "-_./:=,".contains(ch))
{
part.clone()
} else {
format!("'{}'", part.replace('\'', "'\\''"))
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_plan_preserves_version_and_force() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("install.sh");
std::fs::write(&script, "#!/bin/sh\n").unwrap();
let cli = Cli {
kind: InstallKind::Client,
legacy_script: script,
server_host: "192.0.2.13".to_string(),
server_port: "5600".to_string(),
poll_interval: "5".to_string(),
version: "0.13.2".to_string(),
install_base: Some(PathBuf::from("/tmp/aw")),
force: true,
apply: false,
json: false,
};
let plan = build_plan(&cli);
let cmd = &plan.steps[0].command;
assert!(cmd.contains(&"--apply-legacy".to_string()));
assert!(cmd.contains(&"--install-base".to_string()));
assert!(cmd.contains(&"--force".to_string()));
}
#[test]
fn remote_worker_plan_includes_poll_and_version() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("install.sh");
std::fs::write(&script, "#!/bin/sh\n").unwrap();
let cli = Cli {
kind: InstallKind::RemoteWorker,
legacy_script: script,
server_host: "host".to_string(),
server_port: "5600".to_string(),
poll_interval: "9".to_string(),
version: "0.13.3".to_string(),
install_base: None,
force: false,
apply: false,
json: false,
};
let plan = build_plan(&cli);
let joined = shell_join(&plan.steps[0].command);
assert!(joined.contains("--poll-interval 9"));
assert!(joined.contains("--version 0.13.3"));
}
}
@@ -1,16 +0,0 @@
[package]
name = "aw-prune-local-state"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -1,621 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde::Serialize;
const DEFAULT_DATA_DIR: &str = "/var/lib/activitywatch";
const TMP_ARCHIVE_PATTERNS: &[NamePattern] = &[
NamePattern::PrefixSuffix("activitywatch-", ".zip"),
NamePattern::PrefixSuffix("hayabusa-", ".zip"),
NamePattern::Exact("aw-hayabusa-profiles.txt"),
];
const TMP_WEBUI_PATTERNS: &[NamePattern] = &[
NamePattern::Exact("aw-worktime-ui-bridge.py"),
NamePattern::Exact("views-default.json"),
NamePattern::Exact("apply_webui_ru_patch.out"),
];
#[derive(Debug, Parser)]
#[command(about = "Safely prune ActivityWatch local app state and temporary artifacts")]
struct Cli {
#[arg(long)]
data_dir: Option<PathBuf>,
#[arg(long)]
backup_dir: Option<PathBuf>,
#[arg(long)]
browser_smoke_dir: Option<PathBuf>,
#[arg(long)]
tmp_dir: Option<PathBuf>,
#[arg(long, default_value_t = 7)]
backup_retention_days: u64,
#[arg(long, default_value_t = 2)]
backup_keep_last_db: usize,
#[arg(long, default_value_t = 2)]
backup_keep_last_json: usize,
#[arg(long, default_value_t = 24)]
browser_smoke_keep_runs: usize,
#[arg(long, default_value_t = 1)]
browser_smoke_retention_days: u64,
#[arg(long, default_value_t = 1)]
tmp_archive_retention_days: u64,
#[arg(long, default_value_t = 2)]
tmp_webui_retention_days: u64,
#[arg(long, default_value_t = false)]
apply: bool,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Clone)]
struct Config {
data_dir: PathBuf,
backup_dir: PathBuf,
browser_smoke_dir: PathBuf,
tmp_dir: PathBuf,
backup_retention_days: u64,
backup_keep_last_db: usize,
backup_keep_last_json: usize,
browser_smoke_keep_runs: usize,
browser_smoke_retention_days: u64,
tmp_archive_retention_days: u64,
tmp_webui_retention_days: u64,
apply: bool,
json: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum ItemKind {
File,
Directory,
}
#[derive(Debug, Clone, Serialize)]
struct PruneItem {
path: PathBuf,
kind: ItemKind,
reason: String,
age_days: Option<u64>,
size_bytes: u64,
}
#[derive(Debug, Serialize)]
struct Summary {
apply: bool,
planned: usize,
deleted: usize,
failed: usize,
bytes: u64,
items: Vec<PruneItem>,
errors: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
enum NamePattern {
Exact(&'static str),
PrefixSuffix(&'static str, &'static str),
}
impl NamePattern {
fn matches(self, name: &str) -> bool {
match self {
Self::Exact(expected) => name == expected,
Self::PrefixSuffix(prefix, suffix) => {
name.starts_with(prefix) && name.ends_with(suffix)
}
}
}
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cfg = Config::from_cli(Cli::parse());
let mut items = Vec::new();
fs::create_dir_all(&cfg.backup_dir)
.with_context(|| format!("create backup dir {}", cfg.backup_dir.display()))?;
plan_backup_group(
&cfg.backup_dir.join("db"),
cfg.backup_keep_last_db,
cfg.backup_retention_days,
"backup_db",
&mut items,
)?;
plan_backup_group(
&cfg.backup_dir,
cfg.backup_keep_last_json,
cfg.backup_retention_days,
"backup_root",
&mut items,
)?;
plan_browser_smoke(&cfg, &mut items)?;
plan_tmp(
&cfg.tmp_dir,
TMP_ARCHIVE_PATTERNS,
cfg.tmp_archive_retention_days,
"tmp_archive",
&mut items,
)?;
plan_tmp(
&cfg.tmp_dir,
TMP_WEBUI_PATTERNS,
cfg.tmp_webui_retention_days,
"tmp_webui",
&mut items,
)?;
validate_plan(&cfg, &items)?;
let mut summary = Summary {
apply: cfg.apply,
planned: items.len(),
deleted: 0,
failed: 0,
bytes: items.iter().map(|item| item.size_bytes).sum(),
items,
errors: Vec::new(),
};
if cfg.apply {
apply_plan(&mut summary);
}
print_summary(&summary, cfg.json)?;
if summary.failed == 0 { Ok(0) } else { Ok(1) }
}
impl Config {
fn from_cli(cli: Cli) -> Self {
let data_dir = cli
.data_dir
.or_else(|| env_path("AW_DATA_DIR"))
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_DIR));
let backup_dir = cli
.backup_dir
.or_else(|| env_path("AW_BACKUP_DIR"))
.unwrap_or_else(|| data_dir.join("backups"));
let browser_smoke_dir = cli
.browser_smoke_dir
.or_else(|| env_path("AW_BROWSER_SMOKE_OUTPUT_DIR"))
.unwrap_or_else(|| data_dir.join("browser-smoke"));
let tmp_dir = cli
.tmp_dir
.or_else(|| env_path("AW_TMP_DIR"))
.unwrap_or_else(|| PathBuf::from("/tmp"));
Self {
data_dir,
backup_dir,
browser_smoke_dir,
tmp_dir,
backup_retention_days: env_u64("AW_BACKUP_RETENTION_DAYS", cli.backup_retention_days),
backup_keep_last_db: env_usize("AW_BACKUP_KEEP_LAST_DB", cli.backup_keep_last_db),
backup_keep_last_json: env_usize("AW_BACKUP_KEEP_LAST_JSON", cli.backup_keep_last_json),
browser_smoke_keep_runs: env_usize(
"AW_BROWSER_SMOKE_KEEP_RUNS",
cli.browser_smoke_keep_runs,
),
browser_smoke_retention_days: env_u64(
"AW_BROWSER_SMOKE_RETENTION_DAYS",
cli.browser_smoke_retention_days,
),
tmp_archive_retention_days: env_u64(
"AW_TMP_ARCHIVE_RETENTION_DAYS",
cli.tmp_archive_retention_days,
),
tmp_webui_retention_days: env_u64(
"AW_TMP_WEBUI_RETENTION_DAYS",
cli.tmp_webui_retention_days,
),
apply: cli.apply,
json: cli.json,
}
}
}
fn plan_backup_group(
dir: &Path,
keep_last: usize,
keep_days: u64,
reason: &str,
items: &mut Vec<PruneItem>,
) -> Result<()> {
let mut files = list_files(dir)?;
files.sort_by(|a, b| b.modified.cmp(&a.modified));
let cutoff = cutoff(keep_days);
for (idx, candidate) in files.into_iter().enumerate() {
if idx < keep_last || candidate.modified >= cutoff || is_rollback_critical(&candidate.path)
{
continue;
}
items.push(candidate.into_item(reason));
}
Ok(())
}
fn plan_browser_smoke(cfg: &Config, items: &mut Vec<PruneItem>) -> Result<()> {
let mut dirs = list_run_dirs(&cfg.browser_smoke_dir)?;
dirs.sort_by(|a, b| b.modified.cmp(&a.modified));
let cutoff = cutoff(cfg.browser_smoke_retention_days);
for (idx, candidate) in dirs.into_iter().enumerate() {
if idx < cfg.browser_smoke_keep_runs || candidate.modified >= cutoff {
continue;
}
items.push(candidate.into_item("browser_smoke_run"));
}
Ok(())
}
fn plan_tmp(
dir: &Path,
patterns: &[NamePattern],
keep_days: u64,
reason: &str,
items: &mut Vec<PruneItem>,
) -> Result<()> {
if !dir.exists() {
return Ok(());
}
let cutoff = cutoff(keep_days);
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_file() && !file_type.is_symlink() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if !patterns.iter().any(|pattern| pattern.matches(&name)) {
continue;
}
let meta = entry.metadata()?;
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
if modified >= cutoff {
continue;
}
items.push(
Candidate {
path: entry.path(),
kind: ItemKind::File,
modified,
size_bytes: meta.len(),
}
.into_item(reason),
);
}
Ok(())
}
#[derive(Debug)]
struct Candidate {
path: PathBuf,
kind: ItemKind,
modified: SystemTime,
size_bytes: u64,
}
impl Candidate {
fn into_item(self, reason: &str) -> PruneItem {
PruneItem {
path: self.path,
kind: self.kind,
reason: reason.to_string(),
age_days: age_days(self.modified),
size_bytes: self.size_bytes,
}
}
}
fn list_files(dir: &Path) -> Result<Vec<Candidate>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let meta = entry.metadata()?;
files.push(Candidate {
path: entry.path(),
kind: ItemKind::File,
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
size_bytes: meta.len(),
});
}
Ok(files)
}
fn list_run_dirs(dir: &Path) -> Result<Vec<Candidate>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut dirs = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if !looks_like_browser_smoke_run(&name) {
continue;
}
let meta = entry.metadata()?;
dirs.push(Candidate {
path: entry.path(),
kind: ItemKind::Directory,
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
size_bytes: dir_size(&entry.path()).unwrap_or(0),
});
}
Ok(dirs)
}
fn looks_like_browser_smoke_run(name: &str) -> bool {
name.len() >= 20
&& name.starts_with("20")
&& name.contains('T')
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}
fn validate_plan(cfg: &Config, items: &[PruneItem]) -> Result<()> {
for item in items {
if is_rollback_critical(&item.path) {
bail!(
"refusing to delete rollback-critical path {}",
item.path.display()
);
}
let allowed = match item.reason.as_str() {
"backup_db" => is_under_or_equal(&item.path, &cfg.backup_dir.join("db")),
"backup_root" => is_under_or_equal(&item.path, &cfg.backup_dir),
"browser_smoke_run" => is_under_or_equal(&item.path, &cfg.browser_smoke_dir),
"tmp_archive" | "tmp_webui" => is_under_or_equal(&item.path, &cfg.tmp_dir),
_ => false,
};
if !allowed {
bail!(
"refusing to delete path outside allowlist {}",
item.path.display()
);
}
if item.reason == "backup_root" && item.path.parent() != Some(cfg.backup_dir.as_path()) {
bail!("refusing nested backup_root delete {}", item.path.display());
}
if item.path == cfg.data_dir
|| item.path == cfg.backup_dir
|| item.path == cfg.browser_smoke_dir
{
bail!("refusing to delete root directory {}", item.path.display());
}
let name = item
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if is_sqlite_db_name(name) && item.reason != "backup_db" {
bail!(
"refusing to delete SQLite DB outside backup_db {}",
item.path.display()
);
}
}
Ok(())
}
fn apply_plan(summary: &mut Summary) {
for item in summary.items.clone() {
let result = match item.kind {
ItemKind::File => fs::remove_file(&item.path),
ItemKind::Directory => fs::remove_dir_all(&item.path),
};
match result {
Ok(()) => summary.deleted += 1,
Err(err) if !item.path.exists() => {
summary.deleted += 1;
summary
.errors
.push(format!("already gone: {} ({err})", item.path.display()));
}
Err(err) => {
summary.failed += 1;
summary
.errors
.push(format!("{}: {err}", item.path.display()));
}
}
}
}
fn print_summary(summary: &Summary, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(summary)?);
return Ok(());
}
let mode = if summary.apply { "apply" } else { "dry-run" };
println!(
"aw-prune-local-state: mode={mode} planned={} deleted={} failed={} bytes={}",
summary.planned, summary.deleted, summary.failed, summary.bytes
);
for item in &summary.items {
let verb = if summary.apply {
"DELETE"
} else {
"WOULD_DELETE"
};
println!(
"{verb} {:?} {} reason={} age_days={} bytes={}",
item.kind,
item.path.display(),
item.reason,
item.age_days
.map(|value| value.to_string())
.unwrap_or_else(|| "unknown".to_string()),
item.size_bytes
);
}
for err in &summary.errors {
eprintln!("WARN {err}");
}
Ok(())
}
fn cutoff(days: u64) -> SystemTime {
SystemTime::now()
.checked_sub(Duration::from_secs(days.saturating_mul(86_400)))
.unwrap_or(SystemTime::UNIX_EPOCH)
}
fn age_days(modified: SystemTime) -> Option<u64> {
SystemTime::now()
.duration_since(modified)
.ok()
.map(|age| age.as_secs() / 86_400)
}
fn dir_size(path: &Path) -> Result<u64> {
let mut total = 0;
for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? {
let entry = entry?;
let file_type = entry.file_type()?;
let child = entry.path();
if file_type.is_dir() {
total += dir_size(&child).unwrap_or(0);
} else if file_type.is_file() || file_type.is_symlink() {
total += entry.metadata().map(|meta| meta.len()).unwrap_or(0);
}
}
Ok(total)
}
fn is_under_or_equal(path: &Path, root: &Path) -> bool {
path == root || path.starts_with(root)
}
fn is_rollback_critical(path: &Path) -> bool {
let text = path.to_string_lossy().to_ascii_lowercase();
text.contains("switch-backups")
|| text.contains("before-rust")
|| text.contains("rollback")
|| text.contains("pre-switch")
}
fn is_sqlite_db_name(name: &str) -> bool {
name.ends_with(".sqlite")
|| name.ends_with(".sqlite3")
|| name.ends_with(".db")
|| name.ends_with(".db-shm")
|| name.ends_with(".db-wal")
}
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(default)
}
fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
#[test]
fn browser_smoke_run_name_is_narrow() {
assert!(looks_like_browser_smoke_run("2026-06-02T03-02-19-530Z"));
assert!(!looks_like_browser_smoke_run(".cache"));
assert!(!looks_like_browser_smoke_run("latest-result.json"));
}
#[test]
fn rollback_critical_paths_are_protected() {
assert!(is_rollback_critical(Path::new(
"/var/lib/activitywatch/health/switch-backups/file"
)));
assert!(is_rollback_critical(Path::new(
"/var/lib/activitywatch/backups/db/before-rust.sqlite"
)));
}
#[test]
fn backup_group_keeps_newest_even_with_zero_retention() {
let tmp = tempfile::tempdir().expect("tempdir");
let old = tmp.path().join("old.json");
let new = tmp.path().join("new.json");
File::create(&old).expect("old");
std::thread::sleep(Duration::from_millis(5));
File::create(&new).expect("new");
let mut items = Vec::new();
plan_backup_group(tmp.path(), 1, 0, "backup_root", &mut items).expect("plan");
assert_eq!(items.len(), 1);
assert_eq!(items[0].path, old);
}
#[test]
fn validation_rejects_sqlite_outside_backup_db() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg = Config {
data_dir: tmp.path().to_path_buf(),
backup_dir: tmp.path().join("backups"),
browser_smoke_dir: tmp.path().join("browser-smoke"),
tmp_dir: tmp.path().join("tmp"),
backup_retention_days: 1,
backup_keep_last_db: 1,
backup_keep_last_json: 1,
browser_smoke_keep_runs: 1,
browser_smoke_retention_days: 1,
tmp_archive_retention_days: 1,
tmp_webui_retention_days: 1,
apply: false,
json: false,
};
let item = PruneItem {
path: cfg.browser_smoke_dir.join("state.db"),
kind: ItemKind::File,
reason: "browser_smoke_run".to_string(),
age_days: Some(2),
size_bytes: 1,
};
assert!(validate_plan(&cfg, &[item]).is_err());
}
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "aw-rus-healthd"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
detmir-core.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "aw-slo-monitor"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
urlencoding.workspace = true
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
@@ -1,26 +0,0 @@
[package]
name = "aw-windows-telemetry"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
base64.workspace = true
chrono.workspace = true
clap.workspace = true
csv.workspace = true
notify.workspace = true
reqwest.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
url.workspace = true
[target.'cfg(windows)'.dependencies]
windows = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Accessibility", "Win32_UI_WindowsAndMessaging"] }
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Memory", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
[package]
name = "aw-workforce-ingest"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
detmir-aw-client.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
urlencoding.workspace = true
File diff suppressed because it is too large Load Diff
@@ -1,10 +0,0 @@
fn main() {
let code = match aw_workforce_ingest::run_from_args() {
Ok(()) => 0,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
@@ -1,26 +0,0 @@
[package]
name = "awatch-agent-rs"
version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_System_RemoteDesktop"] }
[dev-dependencies]
tempfile.workspace = true
[features]
default = []
asm-lowlevel = []
legacy-powershell = []
@@ -1,18 +0,0 @@
server_url = "https://awatch.local/api/telemetry"
api_key = "change-me"
collect_interval_seconds = 60
role = "workstation"
enable_processes = true
enable_network = true
enable_security_events = true
enable_workforce_activity = true
spool_dir = "/var/lib/awatch-agent/spool"
timeout_seconds = 10
retry_attempts = 3
# Optional ActivityWatch compatibility output.
# Enables Rust replacement for the PowerShell worktime-session collector path.
aw_api_base = "http://awatch.local:5600/api/0"
aw_worktime_enabled = false
@@ -1,127 +0,0 @@
use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;
use chrono::Utc;
use crate::config::AgentRole;
use crate::telemetry::{SecurityEventInfo, SessionInfo};
pub fn command_output(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn hostname() -> String {
env::var("HOSTNAME")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| fs::read_to_string("/etc/hostname").ok())
.or_else(|| command_output("hostname", &[]))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "HOST-EXAMPLE".to_string())
}
pub fn username() -> String {
env::var("USER")
.or_else(|_| env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
pub fn domain() -> String {
env::var("USERDOMAIN")
.or_else(|_| env::var("DOMAIN"))
.unwrap_or_default()
}
pub fn agent_id(hostname: &str) -> String {
format!("awatch-{hostname}")
}
pub fn role_security_events(role: AgentRole) -> Vec<SecurityEventInfo> {
if role == AgentRole::Firewall {
vec![SecurityEventInfo {
event_id: "pfsense-mode-prototype".to_string(),
source: "awatch-agent-rs".to_string(),
severity: "INFO".to_string(),
summary: "pfSense/firewall mode enabled; counters are collected from platform-specific probes when available".to_string(),
timestamp: Utc::now(),
evidence: vec!["read-only mode".to_string()],
}]
} else {
Vec::new()
}
}
pub fn current_session(session_type: &str) -> SessionInfo {
SessionInfo {
session_id: format!("{}-{}", session_type, username()),
username: username(),
session_type: session_type.to_string(),
session_source: Some("env_sessionname_fallback".to_string()),
remote_addr: std::env::var("SSH_CLIENT")
.ok()
.and_then(|value| value.split_whitespace().next().map(str::to_string)),
started_at: None,
active: true,
}
}
pub fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn parse_os_release(path: &Path) -> (String, String) {
let text = fs::read_to_string(path).unwrap_or_default();
let mut name = String::new();
let mut version = String::new();
for line in text.lines() {
if let Some(value) = line.strip_prefix("NAME=") {
name = value.trim_matches('"').to_string();
}
if let Some(value) = line.strip_prefix("VERSION_ID=") {
version = value.trim_matches('"').to_string();
}
}
if name.is_empty() {
name = "Linux".to_string();
}
(name, version)
}
pub fn parse_hex_ipv4(value: &str) -> Option<String> {
if value.len() != 8 {
return None;
}
let raw = u32::from_str_radix(value, 16).ok()?;
let bytes = raw.to_le_bytes();
Some(format!(
"{}.{}.{}.{}",
bytes[0], bytes[1], bytes[2], bytes[3]
))
}
pub fn tcp_state(value: &str) -> &'static str {
match value {
"01" => "ESTABLISHED",
"02" => "SYN_SENT",
"03" => "SYN_RECV",
"04" => "FIN_WAIT1",
"05" => "FIN_WAIT2",
"06" => "TIME_WAIT",
"07" => "CLOSE",
"08" => "CLOSE_WAIT",
"09" => "LAST_ACK",
"0A" => "LISTEN",
"0B" => "CLOSING",
_ => "UNKNOWN",
}
}
@@ -1,214 +0,0 @@
use std::fs;
use anyhow::Result;
use chrono::Utc;
use crate::collectors::common::{
agent_id, command_output, current_session, domain, hostname, role_security_events, username,
};
use crate::config::AgentRole;
use crate::telemetry::{
IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo,
ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo,
dedupe_sessions, diagnostics_for_sessions, empty_workforce_activity,
};
#[derive(Debug, Clone)]
pub struct FreeBsdCollector {
role: AgentRole,
}
impl FreeBsdCollector {
pub fn new(role: AgentRole) -> Self {
Self { role }
}
}
impl TelemetryCollector for FreeBsdCollector {
fn collect_identity(&self) -> Result<IdentityInfo> {
let host = hostname();
Ok(IdentityInfo {
agent_id: agent_id(&host),
hostname: host,
os_name: command_output("uname", &["-s"]).unwrap_or_else(|| "FreeBSD".to_string()),
os_version: command_output("uname", &["-r"]).unwrap_or_default(),
platform: "freebsd".to_string(),
username: username(),
domain: domain(),
})
}
fn collect_sessions(&self) -> Result<SessionSnapshot> {
let mut active = vec![current_session("local")];
let mut ssh = Vec::new();
if std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() {
let session = current_session("ssh");
ssh.push(session.clone());
active.push(session);
}
let host = hostname();
let active = dedupe_sessions(&host, active);
let ssh = dedupe_sessions(&host, ssh);
let diagnostics = diagnostics_for_sessions(&active, &[], "env_sessionname_fallback", None);
Ok(SessionSnapshot {
active_sessions: active,
rdp_sessions: Vec::new(),
ssh_sessions: ssh,
diagnostics,
})
}
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
Ok(freebsd_processes(128))
}
fn collect_resources(&self) -> Result<ResourceInfo> {
let memory_total = command_output("sysctl", &["-n", "hw.physmem"])
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
Ok(ResourceInfo {
uptime_seconds: 0,
cpu_usage_percent: 0.0,
memory_total,
memory_used: 0,
})
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
Ok(NetworkSnapshot {
interfaces: freebsd_interfaces(),
connections: freebsd_connections(256),
})
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
let mut events = role_security_events(self.role);
if let Some(summary) = freebsd_syslog_summary() {
events.push(SecurityEventInfo {
event_id: "freebsd-syslog-summary".to_string(),
source: "syslog".to_string(),
severity: "INFO".to_string(),
summary,
timestamp: Utc::now(),
evidence: vec!["/var/log/messages".to_string()],
});
}
Ok(events)
}
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
let mut activity = empty_workforce_activity();
activity.active_today = true;
activity.explanation = vec![
"FreeBSD collector reports host/session/process/network context; pfSense mode is read-only".to_string(),
];
Ok(activity)
}
}
fn freebsd_processes(limit: usize) -> Vec<ProcessInfo> {
let Some(raw) = command_output("ps", &["-axo", "pid,ppid,comm,rss"]) else {
return Vec::new();
};
let mut items = raw
.lines()
.skip(1)
.filter_map(|line| {
let cols = line.split_whitespace().collect::<Vec<_>>();
let pid = cols.first()?.parse::<u32>().ok()?;
let ppid = cols.get(1).and_then(|value| value.parse::<u32>().ok());
let name = cols.get(2).unwrap_or(&"process").to_string();
let memory_bytes = cols
.get(3)
.and_then(|value| value.parse::<u64>().ok())
.map(|value| value.saturating_mul(1024));
Some(ProcessInfo {
pid,
ppid,
name,
exe: None,
username: None,
cpu_percent: None,
memory_bytes,
started_at: None,
})
})
.collect::<Vec<_>>();
items.truncate(limit);
items
}
fn freebsd_interfaces() -> Vec<NetworkInterfaceInfo> {
let Some(raw) = command_output("ifconfig", &["-l"]) else {
return Vec::new();
};
raw.split_whitespace()
.map(|name| NetworkInterfaceInfo {
name: name.to_string(),
mac: None,
addresses: Vec::new(),
up: true,
rx_bytes: None,
tx_bytes: None,
})
.collect()
}
fn freebsd_connections(limit: usize) -> Vec<NetworkConnectionInfo> {
let Some(raw) = command_output("sockstat", &["-4", "-6"]) else {
return Vec::new();
};
let mut items = raw
.lines()
.skip(1)
.filter_map(parse_sockstat_line)
.collect::<Vec<_>>();
items.truncate(limit);
items
}
fn parse_sockstat_line(line: &str) -> Option<NetworkConnectionInfo> {
let cols = line.split_whitespace().collect::<Vec<_>>();
let protocol = cols.get(4)?.to_ascii_lowercase();
if protocol != "tcp" && protocol != "udp" {
return None;
}
let (local_addr, local_port) = split_host_port(cols.get(5)?)?;
let (remote_addr, remote_port) = cols
.get(6)
.and_then(|value| split_host_port(value))
.unwrap_or_default();
Some(NetworkConnectionInfo {
protocol: protocol.clone(),
local_addr,
local_port,
remote_addr: Some(remote_addr),
remote_port: Some(remote_port),
state: if protocol == "tcp" { "OPEN" } else { "UDP" }.to_string(),
pid: cols.get(2).and_then(|value| value.parse::<u32>().ok()),
})
}
fn split_host_port(value: &str) -> Option<(String, u16)> {
let (host, port) = value.rsplit_once(':')?;
Some((
host.trim_matches(['[', ']']).to_string(),
port.parse().ok()?,
))
}
fn freebsd_syslog_summary() -> Option<String> {
let text = fs::read_to_string("/var/log/messages").ok()?;
let count = text
.lines()
.rev()
.take(200)
.filter(|line| {
let lower = line.to_lowercase();
lower.contains("error") || lower.contains("fail") || lower.contains("denied")
})
.count();
Some(format!(
"recent FreeBSD syslog warning/error lines: {count}"
))
}
@@ -1,275 +0,0 @@
use std::fs;
use std::path::Path;
use anyhow::Result;
use chrono::Utc;
use crate::collectors::common::{
agent_id, current_session, domain, hostname, parse_hex_ipv4, parse_os_release, read_trimmed,
role_security_events, tcp_state, username,
};
use crate::config::AgentRole;
use crate::telemetry::{
IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo,
ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo,
dedupe_sessions, diagnostics_for_sessions, empty_workforce_activity,
};
#[derive(Debug, Clone)]
pub struct LinuxCollector {
role: AgentRole,
}
impl LinuxCollector {
pub fn new(role: AgentRole) -> Self {
Self { role }
}
}
impl TelemetryCollector for LinuxCollector {
fn collect_identity(&self) -> Result<IdentityInfo> {
let host = hostname();
let (os_name, os_version) = parse_os_release(Path::new("/etc/os-release"));
Ok(IdentityInfo {
agent_id: agent_id(&host),
hostname: host,
os_name,
os_version,
platform: "linux".to_string(),
username: username(),
domain: domain(),
})
}
fn collect_sessions(&self) -> Result<SessionSnapshot> {
let mut active = vec![current_session("local")];
let mut ssh = Vec::new();
if std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() {
let session = current_session("ssh");
ssh.push(session.clone());
active.push(session);
}
let host = hostname();
let active = dedupe_sessions(&host, active);
let ssh = dedupe_sessions(&host, ssh);
let diagnostics = diagnostics_for_sessions(&active, &[], "env_sessionname_fallback", None);
Ok(SessionSnapshot {
active_sessions: active,
rdp_sessions: Vec::new(),
ssh_sessions: ssh,
diagnostics,
})
}
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
Ok(read_processes(128))
}
fn collect_resources(&self) -> Result<ResourceInfo> {
let uptime_seconds = fs::read_to_string("/proc/uptime")
.ok()
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
.map(|value| value as u64)
.unwrap_or(0);
let (memory_total, memory_available) = read_meminfo();
Ok(ResourceInfo {
uptime_seconds,
cpu_usage_percent: read_loadavg_percent(),
memory_total,
memory_used: memory_total.saturating_sub(memory_available),
})
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
Ok(NetworkSnapshot {
interfaces: read_interfaces(),
connections: read_connections(),
})
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
let mut events = role_security_events(self.role);
if let Some(summary) = recent_syslog_summary() {
events.push(SecurityEventInfo {
event_id: "linux-syslog-summary".to_string(),
source: "syslog".to_string(),
severity: "INFO".to_string(),
summary,
timestamp: Utc::now(),
evidence: vec!["/var/log/syslog or /var/log/messages".to_string()],
});
}
Ok(events)
}
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
let mut activity = empty_workforce_activity();
activity.active_today = true;
activity.explanation = vec![
"Linux collector reports presence and process/network context; application weighting is calculated server-side".to_string(),
];
Ok(activity)
}
}
fn read_meminfo() -> (u64, u64) {
let mut total = 0;
let mut available = 0;
let text = fs::read_to_string("/proc/meminfo").unwrap_or_default();
for line in text.lines() {
if let Some(value) = line.strip_prefix("MemTotal:") {
total = parse_kib(value);
}
if let Some(value) = line.strip_prefix("MemAvailable:") {
available = parse_kib(value);
}
}
(total, available)
}
fn parse_kib(value: &str) -> u64 {
value
.split_whitespace()
.next()
.and_then(|item| item.parse::<u64>().ok())
.unwrap_or(0)
* 1024
}
fn read_loadavg_percent() -> f64 {
fs::read_to_string("/proc/loadavg")
.ok()
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
.map(|load| (load * 100.0).clamp(0.0, 100.0))
.unwrap_or(0.0)
}
fn read_processes(limit: usize) -> Vec<ProcessInfo> {
let mut items = fs::read_dir("/proc")
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let pid = entry.file_name().to_string_lossy().parse::<u32>().ok()?;
let stat = fs::read_to_string(entry.path().join("stat")).ok()?;
let name = stat.split_once('(')?.1.split_once(')')?.0.to_string();
let exe = fs::read_link(entry.path().join("exe"))
.ok()
.map(|path| path.display().to_string());
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
let ppid = status
.lines()
.find_map(|line| line.strip_prefix("PPid:"))
.and_then(|value| value.trim().parse::<u32>().ok());
let memory_bytes = status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.map(parse_kib);
Some(ProcessInfo {
pid,
ppid,
name,
exe,
username: None,
cpu_percent: None,
memory_bytes,
started_at: None,
})
})
.collect::<Vec<_>>();
items.sort_by_key(|item| item.pid);
items.truncate(limit);
items
}
fn read_interfaces() -> Vec<NetworkInterfaceInfo> {
fs::read_dir("/sys/class/net")
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let up = read_trimmed(path.join("operstate")).is_some_and(|state| state == "up");
let rx_bytes =
read_trimmed(path.join("statistics/rx_bytes")).and_then(|v| v.parse().ok());
let tx_bytes =
read_trimmed(path.join("statistics/tx_bytes")).and_then(|v| v.parse().ok());
NetworkInterfaceInfo {
name,
mac: read_trimmed(path.join("address")),
addresses: Vec::new(),
up,
rx_bytes,
tx_bytes,
}
})
.collect()
}
fn read_connections() -> Vec<NetworkConnectionInfo> {
let mut items = Vec::new();
read_proc_net("/proc/net/tcp", "tcp", &mut items);
read_proc_net("/proc/net/udp", "udp", &mut items);
items.truncate(256);
items
}
fn read_proc_net(path: &str, protocol: &str, items: &mut Vec<NetworkConnectionInfo>) {
let text = fs::read_to_string(path).unwrap_or_default();
for line in text.lines().skip(1) {
let cols = line.split_whitespace().collect::<Vec<_>>();
if cols.len() < 4 {
continue;
}
let Some((local_addr, local_port)) = parse_addr(cols[1]) else {
continue;
};
let (remote_addr, remote_port) = parse_addr(cols[2]).unwrap_or_default();
items.push(NetworkConnectionInfo {
protocol: protocol.to_string(),
local_addr,
local_port,
remote_addr: if remote_addr == "0.0.0.0" {
None
} else {
Some(remote_addr)
},
remote_port: if remote_port == 0 {
None
} else {
Some(remote_port)
},
state: if protocol == "tcp" {
tcp_state(cols[3]).to_string()
} else {
"UDP".to_string()
},
pid: None,
});
}
}
fn parse_addr(value: &str) -> Option<(String, u16)> {
let (ip, port) = value.split_once(':')?;
Some((parse_hex_ipv4(ip)?, u16::from_str_radix(port, 16).ok()?))
}
fn recent_syslog_summary() -> Option<String> {
for path in ["/var/log/syslog", "/var/log/messages"] {
let Ok(text) = fs::read_to_string(path) else {
continue;
};
let count = text
.lines()
.rev()
.take(200)
.filter(|line| {
let lower = line.to_lowercase();
lower.contains("error") || lower.contains("fail") || lower.contains("denied")
})
.count();
return Some(format!("recent syslog warning/error lines: {count}"));
}
None
}
@@ -1,22 +0,0 @@
pub mod common;
pub mod freebsd;
pub mod linux;
pub mod windows;
use anyhow::{Result, bail};
use crate::config::AgentRole;
use crate::telemetry::TelemetryCollector;
pub fn platform_collector(role: AgentRole) -> Result<Box<dyn TelemetryCollector>> {
if cfg!(target_os = "linux") {
return Ok(Box::new(linux::LinuxCollector::new(role)));
}
if cfg!(target_os = "windows") {
return Ok(Box::new(windows::WindowsCollector::new(role)));
}
if cfg!(target_os = "freebsd") {
return Ok(Box::new(freebsd::FreeBsdCollector::new(role)));
}
bail!("unsupported platform for awatch-agent-rs")
}
@@ -1,536 +0,0 @@
use anyhow::Result;
use chrono::Utc;
use std::process::Command;
use crate::collectors::common::{
agent_id, command_output, current_session, domain, hostname, role_security_events, username,
};
use crate::config::AgentRole;
use crate::telemetry::{
IdentityInfo, NetworkConnectionInfo, NetworkSnapshot, ProcessInfo, ResourceInfo,
SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo, dedupe_sessions,
diagnostics_for_sessions, empty_workforce_activity,
};
#[derive(Debug, Clone)]
pub struct WindowsCollector {
role: AgentRole,
}
impl WindowsCollector {
pub fn new(role: AgentRole) -> Self {
Self { role }
}
}
impl TelemetryCollector for WindowsCollector {
fn collect_identity(&self) -> Result<IdentityInfo> {
let host = hostname();
Ok(IdentityInfo {
agent_id: agent_id(&host),
hostname: host,
os_name: "Windows".to_string(),
os_version: windows_version(),
platform: "windows".to_string(),
username: username(),
domain: domain(),
})
}
fn collect_sessions(&self) -> Result<SessionSnapshot> {
let host = hostname();
let mut collection = windows_query_user_sessions();
if collection.sessions.is_empty()
&& std::env::var("SESSIONNAME")
.unwrap_or_default()
.to_ascii_lowercase()
.contains("rdp")
{
let mut session = current_session("rdp");
session.session_source = Some("env_sessionname_fallback".to_string());
collection.sessions.push(session);
collection.source = "env_sessionname_fallback".to_string();
}
if collection.sessions.is_empty() {
let mut session = current_session("local");
session.session_source = Some("local_fallback".to_string());
collection.sessions.push(session);
collection.source = "local_fallback".to_string();
collection.error = Some("WTS API and quser did not return sessions".to_string());
}
let mut active = dedupe_sessions(&host, collection.sessions);
let mut rdp = active
.iter()
.filter(|session| session.session_type == "rdp")
.cloned()
.collect::<Vec<_>>();
if std::env::var("SESSIONNAME")
.unwrap_or_default()
.to_ascii_lowercase()
.contains("rdp")
{
let mut merged = active.clone();
merged.push(with_session_source(
current_session("rdp"),
"env_sessionname_fallback",
));
active = dedupe_sessions(&host, merged);
rdp = active
.iter()
.filter(|session| session.session_type == "rdp")
.cloned()
.collect::<Vec<_>>();
}
rdp = dedupe_sessions(&host, rdp);
let diagnostics =
diagnostics_for_sessions(&active, &rdp, collection.source, collection.error);
Ok(SessionSnapshot {
active_sessions: active,
rdp_sessions: rdp,
ssh_sessions: Vec::new(),
diagnostics,
})
}
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
Ok(windows_processes(128))
}
fn collect_resources(&self) -> Result<ResourceInfo> {
let (memory_total, memory_used) = windows_memory();
Ok(ResourceInfo {
uptime_seconds: 0,
cpu_usage_percent: 0.0,
memory_total,
memory_used,
})
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
Ok(NetworkSnapshot {
interfaces: Vec::new(),
connections: windows_connections(256),
})
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
let mut events = role_security_events(self.role);
events.push(SecurityEventInfo {
event_id: "windows-collector-v03".to_string(),
source: "awatch-agent-rs".to_string(),
severity: "INFO".to_string(),
summary: "Windows read-only collector is active without PowerShell primary collection; WinAPI/ETW/WMI depth is planned behind the same TelemetryRecord contract".to_string(),
timestamp: Utc::now(),
evidence: vec!["no PowerShell primary collector".to_string()],
});
Ok(events)
}
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
let mut activity = empty_workforce_activity();
activity.active_today = true;
activity.explanation = vec![
"Windows collector reports session/process/network context; ActivityWatch/workforce scoring is calculated server-side".to_string(),
];
Ok(activity)
}
}
fn windows_version() -> String {
command_output("cmd", &["/C", "ver"])
.or_else(|| std::env::var("OS").ok())
.unwrap_or_else(|| "Windows".to_string())
}
#[derive(Debug)]
struct SessionCollection {
sessions: Vec<crate::telemetry::SessionInfo>,
source: String,
error: Option<String>,
}
fn with_session_source(
mut session: crate::telemetry::SessionInfo,
source: &str,
) -> crate::telemetry::SessionInfo {
session.session_source = Some(source.to_string());
session
}
fn windows_query_user_sessions() -> SessionCollection {
let native = windows_wts_sessions();
if !native.is_empty() {
return SessionCollection {
sessions: native,
source: "wts_api".to_string(),
error: None,
};
}
if let Some(raw) = command_output_utf16le("cmd", &["/U", "/C", "query user"])
.or_else(|| command_output_utf16le("cmd", &["/U", "/C", "quser"]))
{
let sessions = parse_query_user_sessions(&raw, "quser_utf16");
if !sessions.is_empty() {
return SessionCollection {
sessions,
source: "quser_utf16".to_string(),
error: None,
};
}
}
if let Some(raw) = command_output_lossy_combined("cmd", &["/C", "query user"])
.or_else(|| command_output_lossy_combined("cmd", &["/C", "quser"]))
{
let sessions = parse_query_user_sessions(&raw, "quser_lossy");
if !sessions.is_empty() {
return SessionCollection {
sessions,
source: "quser_lossy".to_string(),
error: None,
};
}
}
SessionCollection {
sessions: Vec::new(),
source: "local_fallback".to_string(),
error: Some("WTS API and quser returned no sessions".to_string()),
}
}
fn parse_query_user_sessions(raw: &str, source: &str) -> Vec<crate::telemetry::SessionInfo> {
raw.lines()
.skip(1)
.filter_map(|line| parse_query_user_line(line, source))
.collect()
}
#[cfg(windows)]
fn windows_wts_sessions() -> Vec<crate::telemetry::SessionInfo> {
use std::ptr;
use windows_sys::Win32::System::RemoteDesktop::{
WTS_CURRENT_SERVER_HANDLE, WTS_SESSION_INFOW, WTSActive, WTSEnumerateSessionsW,
WTSFreeMemory, WTSUserName,
};
let mut sessions_ptr: *mut WTS_SESSION_INFOW = ptr::null_mut();
let mut count = 0_u32;
let ok = unsafe {
WTSEnumerateSessionsW(
WTS_CURRENT_SERVER_HANDLE,
0,
1,
&mut sessions_ptr,
&mut count,
)
};
if ok == 0 || sessions_ptr.is_null() || count == 0 {
return Vec::new();
}
let sessions =
unsafe { std::slice::from_raw_parts(sessions_ptr, usize::try_from(count).unwrap_or(0)) };
let mut items = Vec::new();
for session in sessions {
let username = wts_session_string(session.SessionId, WTSUserName);
if username.trim().is_empty() {
continue;
}
let station = unsafe { wide_nul_to_string(session.pWinStationName) };
let state = session.State;
let session_type = if station.to_ascii_lowercase().contains("rdp") {
"rdp"
} else {
"local"
};
items.push(crate::telemetry::SessionInfo {
session_id: session.SessionId.to_string(),
username,
session_type: session_type.to_string(),
session_source: Some("wts_api".to_string()),
remote_addr: None,
started_at: None,
active: state == WTSActive,
});
}
unsafe {
WTSFreeMemory(sessions_ptr.cast());
}
items
}
#[cfg(not(windows))]
fn windows_wts_sessions() -> Vec<crate::telemetry::SessionInfo> {
Vec::new()
}
#[cfg(windows)]
fn wts_session_string(session_id: u32, class: i32) -> String {
use std::ptr;
use windows_sys::Win32::System::RemoteDesktop::{
WTS_CURRENT_SERVER_HANDLE, WTSFreeMemory, WTSQuerySessionInformationW,
};
let mut buffer = ptr::null_mut();
let mut bytes = 0_u32;
let ok = unsafe {
WTSQuerySessionInformationW(
WTS_CURRENT_SERVER_HANDLE,
session_id,
class,
&mut buffer,
&mut bytes,
)
};
if ok == 0 || buffer.is_null() || bytes == 0 {
return String::new();
}
let len = usize::try_from(bytes / 2).unwrap_or(0);
let value = unsafe {
let slice = std::slice::from_raw_parts(buffer, len);
String::from_utf16_lossy(slice)
.trim_matches('\0')
.trim()
.to_string()
};
unsafe {
WTSFreeMemory(buffer.cast());
}
value
}
#[cfg(windows)]
unsafe fn wide_nul_to_string(ptr: *const u16) -> String {
if ptr.is_null() {
return String::new();
}
let mut len = 0;
while unsafe { *ptr.add(len) } != 0 {
len += 1;
}
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
String::from_utf16_lossy(slice)
}
fn command_output_utf16le(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
if !output.status.success() {
return None;
}
let mut bytes = output.stdout;
bytes.extend_from_slice(&output.stderr);
if bytes.is_empty() {
return None;
}
let mut words = Vec::new();
for chunk in bytes.chunks_exact(2) {
words.push(u16::from_le_bytes([chunk[0], chunk[1]]));
}
String::from_utf16(&words)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn command_output_lossy_combined(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
if !output.status.success() {
return None;
}
let mut bytes = output.stdout;
bytes.extend_from_slice(&output.stderr);
Some(String::from_utf8_lossy(&bytes).trim().to_string()).filter(|value| !value.is_empty())
}
fn parse_query_user_line(line: &str, source: &str) -> Option<crate::telemetry::SessionInfo> {
let cleaned = line.trim().trim_start_matches('>').trim();
if cleaned.is_empty() {
return None;
}
let parts = cleaned.split_whitespace().collect::<Vec<_>>();
if parts.len() < 3 {
return None;
}
let username = parts.first()?.to_string();
let (session_name, session_id, state) = if parts.get(1)?.chars().all(|ch| ch.is_ascii_digit()) {
("".to_string(), *parts.get(1)?, *parts.get(2)?)
} else {
(
parts.get(1)?.to_string(),
*parts.get(2)?,
*parts.get(3).unwrap_or(&"Unknown"),
)
};
let active = session_state_active(state);
let session_type = if session_name.to_ascii_lowercase().contains("rdp") {
"rdp"
} else {
"local"
};
Some(crate::telemetry::SessionInfo {
session_id: session_id.to_string(),
username,
session_type: session_type.to_string(),
session_source: Some(source.to_string()),
remote_addr: None,
started_at: None,
active,
})
}
fn session_state_active(state: &str) -> bool {
let lower = state.to_lowercase();
lower.contains("active") || lower.contains("актив")
}
fn windows_memory() -> (u64, u64) {
let Some(raw) = command_output(
"wmic",
&[
"OS",
"get",
"FreePhysicalMemory,TotalVisibleMemorySize",
"/Value",
],
) else {
return (0, 0);
};
let mut free_kib = 0;
let mut total_kib = 0;
for line in raw.lines() {
if let Some(value) = line.strip_prefix("FreePhysicalMemory=") {
free_kib = value.trim().parse::<u64>().unwrap_or(0);
}
if let Some(value) = line.strip_prefix("TotalVisibleMemorySize=") {
total_kib = value.trim().parse::<u64>().unwrap_or(0);
}
}
let total = total_kib.saturating_mul(1024);
let used = total_kib.saturating_sub(free_kib).saturating_mul(1024);
(total, used)
}
fn windows_processes(limit: usize) -> Vec<ProcessInfo> {
let Some(raw) = command_output("tasklist", &["/FO", "CSV", "/NH"]) else {
return Vec::new();
};
let mut items = raw
.lines()
.filter_map(parse_tasklist_line)
.collect::<Vec<_>>();
items.truncate(limit);
items
}
fn parse_tasklist_line(line: &str) -> Option<ProcessInfo> {
let cols = parse_csv_line(line);
let name = cols.first()?.to_string();
let pid = cols.get(1)?.parse::<u32>().ok()?;
let memory_bytes = cols.get(4).map(|value| parse_tasklist_memory(value));
Some(ProcessInfo {
pid,
ppid: None,
name,
exe: None,
username: None,
cpu_percent: None,
memory_bytes,
started_at: None,
})
}
fn parse_csv_line(line: &str) -> Vec<String> {
line.trim_matches('"')
.split("\",\"")
.map(|value| value.trim().to_string())
.collect()
}
fn parse_tasklist_memory(value: &str) -> u64 {
value
.chars()
.filter(|ch| ch.is_ascii_digit())
.collect::<String>()
.parse::<u64>()
.unwrap_or(0)
.saturating_mul(1024)
}
fn windows_connections(limit: usize) -> Vec<NetworkConnectionInfo> {
let Some(raw) = command_output("netstat", &["-ano"]) else {
return Vec::new();
};
let mut items = raw
.lines()
.filter_map(parse_netstat_line)
.collect::<Vec<_>>();
items.truncate(limit);
items
}
fn parse_netstat_line(line: &str) -> Option<NetworkConnectionInfo> {
let cols = line.split_whitespace().collect::<Vec<_>>();
let protocol = cols.first()?.to_ascii_lowercase();
if protocol != "tcp" && protocol != "udp" {
return None;
}
let (local_addr, local_port) = split_host_port(cols.get(1)?)?;
let (remote_addr, remote_port) = split_host_port(cols.get(2)?).unwrap_or_default();
let state = if protocol == "tcp" {
cols.get(3).unwrap_or(&"UNKNOWN").to_string()
} else {
"UDP".to_string()
};
let pid = cols.last().and_then(|value| value.parse::<u32>().ok());
Some(NetworkConnectionInfo {
protocol,
local_addr,
local_port,
remote_addr: Some(remote_addr),
remote_port: Some(remote_port),
state,
pid,
})
}
fn split_host_port(value: &str) -> Option<(String, u16)> {
let (host, port) = value.rsplit_once(':')?;
Some((
host.trim_matches(['[', ']']).to_string(),
port.parse().ok()?,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_query_user_line_with_rdp_session() {
let session = parse_query_user_line(
" user1 rdp-tcp#5 3 Active",
"quser_utf16",
)
.unwrap();
assert_eq!(session.username, "user1");
assert_eq!(session.session_id, "3");
assert_eq!(session.session_type, "rdp");
assert_eq!(session.session_source.as_deref(), Some("quser_utf16"));
assert!(session.active);
}
#[test]
fn parses_query_user_line_without_session_name() {
let session =
parse_query_user_line(" user2 4 Disc", "quser_lossy").unwrap();
assert_eq!(session.username, "user2");
assert_eq!(session.session_id, "4");
assert_eq!(session.session_type, "local");
assert_eq!(session.session_source.as_deref(), Some("quser_lossy"));
assert!(!session.active);
}
#[test]
fn detects_russian_active_state() {
assert!(session_state_active("Активно"));
}
}
@@ -1,158 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct AgentConfig {
pub server_url: String,
pub api_key: String,
pub collect_interval_seconds: u64,
pub role: AgentRole,
pub enable_processes: bool,
pub enable_network: bool,
pub enable_security_events: bool,
pub enable_workforce_activity: bool,
pub spool_dir: PathBuf,
pub timeout_seconds: u64,
pub retry_attempts: u32,
pub aw_api_base: Option<String>,
pub aw_worktime_enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRole {
Workstation,
Server,
Firewall,
}
impl AgentRole {
pub fn parse(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"firewall" | "pfsense" => Self::Firewall,
"server" => Self::Server,
_ => Self::Workstation,
}
}
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
server_url: "https://awatch.local/api/telemetry".to_string(),
api_key: "change-me".to_string(),
collect_interval_seconds: 60,
role: AgentRole::Workstation,
enable_processes: true,
enable_network: true,
enable_security_events: true,
enable_workforce_activity: true,
spool_dir: default_spool_dir(),
timeout_seconds: 10,
retry_attempts: 3,
aw_api_base: None,
aw_worktime_enabled: false,
}
}
}
pub fn default_config_path() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\AWatch\agent\awatch-agent.toml")
} else {
PathBuf::from("/etc/awatch-agent/awatch-agent.toml")
}
}
fn default_spool_dir() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\AWatch\agent\spool")
} else {
PathBuf::from("/var/lib/awatch-agent/spool")
}
}
impl AgentConfig {
pub fn load(path: &Path) -> Result<Self> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Self::parse_toml_like(&text)
}
pub fn parse_toml_like(text: &str) -> Result<Self> {
let mut config = Self::default();
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"server_url" => config.server_url = value.to_string(),
"api_key" => config.api_key = value.to_string(),
"collect_interval_seconds" => {
config.collect_interval_seconds = value.parse().unwrap_or(60)
}
"role" => config.role = AgentRole::parse(value),
"enable_processes" => config.enable_processes = parse_bool(value, true),
"enable_network" => config.enable_network = parse_bool(value, true),
"enable_security_events" => config.enable_security_events = parse_bool(value, true),
"enable_workforce_activity" => {
config.enable_workforce_activity = parse_bool(value, true)
}
"spool_dir" => config.spool_dir = PathBuf::from(value),
"timeout_seconds" => config.timeout_seconds = value.parse().unwrap_or(10),
"retry_attempts" => config.retry_attempts = value.parse().unwrap_or(3),
"aw_api_base" => config.aw_api_base = Some(value.trim_end_matches('/').to_string()),
"aw_worktime_enabled" => {
config.aw_worktime_enabled = parse_bool(value, false);
}
_ => {}
}
}
Ok(config)
}
}
fn parse_bool(value: &str, fallback: bool) -> bool {
match value.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => fallback,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_agent_config() {
let config = AgentConfig::parse_toml_like(
r#"
server_url = "https://awatch.local/api/telemetry"
api_key = "change-me"
collect_interval_seconds = 30
role = "firewall"
enable_processes = false
aw_api_base = "http://awatch.local:5600/api/0"
aw_worktime_enabled = true
spool_dir = "/tmp/awatch-spool"
"#,
)
.unwrap();
assert_eq!(config.role, AgentRole::Firewall);
assert_eq!(config.collect_interval_seconds, 30);
assert!(!config.enable_processes);
assert!(config.aw_worktime_enabled);
assert_eq!(
config.aw_api_base.as_deref(),
Some("http://awatch.local:5600/api/0")
);
assert_eq!(config.spool_dir, PathBuf::from("/tmp/awatch-spool"));
}
}
@@ -1,75 +0,0 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CpuFeatures {
pub architecture: String,
pub backend: &'static str,
pub features: Vec<String>,
}
pub fn get_cpu_features() -> CpuFeatures {
let mut features = Vec::new();
collect_cpu_features(&mut features);
features.sort();
features.dedup();
CpuFeatures {
architecture: std::env::consts::ARCH.to_string(),
backend: super::LOWLEVEL_BACKEND,
features,
}
}
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
fn collect_cpu_features(features: &mut Vec<String>) {
for (name, detected) in [
("sse2", std::is_x86_feature_detected!("sse2")),
("sse4.2", std::is_x86_feature_detected!("sse4.2")),
("avx", std::is_x86_feature_detected!("avx")),
("avx2", std::is_x86_feature_detected!("avx2")),
("aes", std::is_x86_feature_detected!("aes")),
] {
if detected {
features.push(name.to_string());
}
}
}
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
fn collect_cpu_features(features: &mut Vec<String>) {
for name in compile_time_features() {
features.push(name.to_string());
}
}
#[cfg(all(
not(any(target_arch = "x86", target_arch = "x86_64")),
target_arch = "aarch64"
))]
fn compile_time_features() -> &'static [&'static str] {
&[
#[cfg(target_feature = "aes")]
"aes",
#[cfg(target_feature = "neon")]
"neon",
]
}
#[cfg(all(
not(any(target_arch = "x86", target_arch = "x86_64")),
not(target_arch = "aarch64")
))]
fn compile_time_features() -> &'static [&'static str] {
&[]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpu_features_have_architecture_and_backend() {
let features = get_cpu_features();
assert!(!features.architecture.trim().is_empty());
assert!(!features.backend.trim().is_empty());
}
}
@@ -1,18 +0,0 @@
//! Safe low-level extension point for future agent optimizations.
//!
//! The default implementation is pure Rust. The `asm-lowlevel` feature is a
//! reserved integration point for future platform-specific CPU and timing
//! probes implemented through `core::arch`, inline assembly, or native OS
//! calls. Keep any future `unsafe` code isolated inside this module.
pub mod cpu;
pub mod timing;
pub use cpu::{CpuFeatures, get_cpu_features};
pub use timing::{high_precision_time_ns, monotonic_ticks};
#[cfg(feature = "asm-lowlevel")]
pub const LOWLEVEL_BACKEND: &str = "asm-lowlevel-ready-rust-fallback";
#[cfg(not(feature = "asm-lowlevel"))]
pub const LOWLEVEL_BACKEND: &str = "rust";
@@ -1,36 +0,0 @@
use std::sync::OnceLock;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
static MONOTONIC_START: OnceLock<Instant> = OnceLock::new();
pub fn monotonic_ticks() -> u128 {
MONOTONIC_START
.get_or_init(Instant::now)
.elapsed()
.as_nanos()
}
pub fn high_precision_time_ns() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn monotonic_ticks_do_not_go_backwards() {
let first = monotonic_ticks();
let second = monotonic_ticks();
assert!(second >= first);
}
#[test]
fn high_precision_time_is_epoch_based() {
assert!(high_precision_time_ns() > 1_000_000_000);
}
}
-138
View File
@@ -1,138 +0,0 @@
mod collectors;
mod config;
pub mod lowlevel;
mod telemetry;
mod transport;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use config::{AgentConfig, AgentRole, default_config_path};
use transport::{AwWorktimePublisher, TelemetryTransport, spool_health};
#[derive(Debug, Parser)]
#[command(about = "AWatch-rus Rust telemetry agent")]
struct Cli {
#[arg(long, env = "AWATCH_AGENT_CONFIG")]
config: Option<PathBuf>,
#[arg(long, env = "AWATCH_AGENT_SERVER_URL")]
server_url: Option<String>,
#[arg(long, env = "AWATCH_AGENT_API_KEY")]
api_key: Option<String>,
#[arg(long, env = "AWATCH_AGENT_ROLE")]
role: Option<String>,
#[arg(long, env = "AWATCH_AGENT_AW_API_BASE")]
aw_api_base: Option<String>,
#[arg(long, env = "AWATCH_AGENT_AW_WORKTIME_ENABLED")]
aw_worktime_enabled: Option<bool>,
#[arg(long)]
once: bool,
#[arg(long)]
print_json: bool,
#[arg(long)]
flush_spool: bool,
#[arg(long)]
spool_health: bool,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let mut config = load_config(cli.config.as_ref())?;
if let Some(server_url) = cli.server_url {
config.server_url = server_url;
}
if let Some(api_key) = cli.api_key {
config.api_key = api_key;
}
if let Some(role) = cli.role {
config.role = AgentRole::parse(&role);
}
if let Some(aw_api_base) = cli.aw_api_base {
config.aw_api_base = Some(aw_api_base.trim_end_matches('/').to_string());
}
if let Some(enabled) = cli.aw_worktime_enabled {
config.aw_worktime_enabled = enabled;
}
if cli.spool_health {
println!(
"{}",
serde_json::to_string_pretty(&spool_health(&config.spool_dir))?
);
return Ok(0);
}
let transport = TelemetryTransport::new(&config);
let aw_worktime = AwWorktimePublisher::new(&config);
if cli.flush_spool {
let telemetry_flushed = transport.flush_spool()?;
let worktime_flushed = match aw_worktime.as_ref() {
Some(publisher) => publisher.flush_spool()?,
None => 0,
};
println!(
"{}",
serde_json::json!({
"ok": true,
"flushed": telemetry_flushed + worktime_flushed,
"telemetry_flushed": telemetry_flushed,
"worktime_flushed": worktime_flushed,
})
);
return Ok(0);
}
loop {
let collector = collectors::platform_collector(config.role)?;
let record = collector.collect_all()?;
if cli.print_json {
println!("{}", serde_json::to_string_pretty(&record)?);
} else if let Err(err) = transport.send_or_spool(&record) {
eprintln!("{err:#}");
}
if !cli.print_json {
if let Some(publisher) = aw_worktime.as_ref() {
if let Err(err) = publisher.publish_or_spool(&record) {
eprintln!("{err:#}");
}
}
}
if cli.once {
break;
}
thread::sleep(Duration::from_secs(config.collect_interval_seconds));
}
Ok(0)
}
fn load_config(path: Option<&PathBuf>) -> Result<AgentConfig> {
let path = path.cloned().unwrap_or_else(default_config_path);
if path.exists() {
AgentConfig::load(&path)
} else {
AgentConfig::parse_toml_like("")
.with_context(|| format!("load default config because {} is absent", path.display()))
}
}
@@ -1,278 +0,0 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
pub const COLLECTOR_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TelemetryRecord {
pub agent_id: String,
pub hostname: String,
pub os_name: String,
pub os_version: String,
pub platform: String,
pub username: String,
pub domain: String,
pub timestamp: DateTime<Utc>,
pub uptime_seconds: u64,
pub cpu_usage_percent: f64,
pub memory_total: u64,
pub memory_used: u64,
pub active_sessions: Vec<SessionInfo>,
pub rdp_sessions: Vec<SessionInfo>,
pub ssh_sessions: Vec<SessionInfo>,
pub processes: Vec<ProcessInfo>,
pub network_interfaces: Vec<NetworkInterfaceInfo>,
pub network_connections: Vec<NetworkConnectionInfo>,
pub workforce_activity: WorkforceActivityInfo,
pub security_events: Vec<SecurityEventInfo>,
pub diagnostics: AgentDiagnostics,
pub collector_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdentityInfo {
pub agent_id: String,
pub hostname: String,
pub os_name: String,
pub os_version: String,
pub platform: String,
pub username: String,
pub domain: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResourceInfo {
pub uptime_seconds: u64,
pub cpu_usage_percent: f64,
pub memory_total: u64,
pub memory_used: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionInfo {
pub session_id: String,
pub username: String,
pub session_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_source: Option<String>,
pub remote_addr: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentDiagnostics {
pub sessions_collected_total: usize,
pub rdp_sessions_total: usize,
pub active_sessions_total: usize,
pub collector_source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub collector_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessInfo {
pub pid: u32,
pub ppid: Option<u32>,
pub name: String,
pub exe: Option<String>,
pub username: Option<String>,
pub cpu_percent: Option<f64>,
pub memory_bytes: Option<u64>,
pub started_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkInterfaceInfo {
pub name: String,
pub mac: Option<String>,
pub addresses: Vec<String>,
pub up: bool,
pub rx_bytes: Option<u64>,
pub tx_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkConnectionInfo {
pub protocol: String,
pub local_addr: String,
pub local_port: u16,
pub remote_addr: Option<String>,
pub remote_port: Option<u16>,
pub state: String,
pub pid: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WorkforceActivityInfo {
pub active_today: bool,
pub activity_index: Option<u8>,
pub department: Option<String>,
pub owner: Option<String>,
pub work_applications: Vec<String>,
pub idle_seconds: Option<u64>,
pub explanation: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SecurityEventInfo {
pub event_id: String,
pub source: String,
pub severity: String,
pub summary: String,
pub timestamp: DateTime<Utc>,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionSnapshot {
pub active_sessions: Vec<SessionInfo>,
pub rdp_sessions: Vec<SessionInfo>,
pub ssh_sessions: Vec<SessionInfo>,
pub diagnostics: AgentDiagnostics,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkSnapshot {
pub interfaces: Vec<NetworkInterfaceInfo>,
pub connections: Vec<NetworkConnectionInfo>,
}
pub trait TelemetryCollector {
fn collect_identity(&self) -> Result<IdentityInfo>;
fn collect_sessions(&self) -> Result<SessionSnapshot>;
fn collect_processes(&self) -> Result<Vec<ProcessInfo>>;
fn collect_resources(&self) -> Result<ResourceInfo>;
fn collect_network(&self) -> Result<NetworkSnapshot>;
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>>;
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo>;
fn collect_all(&self) -> Result<TelemetryRecord> {
let identity = self.collect_identity()?;
let sessions = self.collect_sessions()?;
let resources = self.collect_resources()?;
let network = self.collect_network()?;
Ok(TelemetryRecord {
agent_id: identity.agent_id,
hostname: identity.hostname,
os_name: identity.os_name,
os_version: identity.os_version,
platform: identity.platform,
username: identity.username,
domain: identity.domain,
timestamp: Utc::now(),
uptime_seconds: resources.uptime_seconds,
cpu_usage_percent: resources.cpu_usage_percent,
memory_total: resources.memory_total,
memory_used: resources.memory_used,
active_sessions: sessions.active_sessions,
rdp_sessions: sessions.rdp_sessions,
ssh_sessions: sessions.ssh_sessions,
processes: self.collect_processes()?,
network_interfaces: network.interfaces,
network_connections: network.connections,
workforce_activity: self.collect_workforce_activity()?,
security_events: self.collect_security_events()?,
diagnostics: sessions.diagnostics,
collector_version: COLLECTOR_VERSION.to_string(),
})
}
}
pub fn diagnostics_for_sessions(
active_sessions: &[SessionInfo],
rdp_sessions: &[SessionInfo],
collector_source: impl Into<String>,
collector_error: Option<String>,
) -> AgentDiagnostics {
AgentDiagnostics {
sessions_collected_total: active_sessions.len(),
rdp_sessions_total: rdp_sessions.len(),
active_sessions_total: active_sessions
.iter()
.filter(|session| session.active)
.count(),
collector_source: collector_source.into(),
collector_error,
}
}
pub fn dedupe_sessions(hostname: &str, sessions: Vec<SessionInfo>) -> Vec<SessionInfo> {
let mut seen = BTreeSet::new();
sessions
.into_iter()
.filter(|session| {
seen.insert(format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
hostname, session.username, session.session_id, session.session_type
))
})
.collect()
}
pub fn empty_workforce_activity() -> WorkforceActivityInfo {
WorkforceActivityInfo {
active_today: false,
activity_index: None,
department: None,
owner: None,
work_applications: Vec::new(),
idle_seconds: None,
explanation: vec!["activity scoring requires workstation activity events".to_string()],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn telemetry_record_serializes_required_fields() {
let record = TelemetryRecord {
agent_id: "agent-1".to_string(),
hostname: "HOST-EXAMPLE".to_string(),
os_name: "Linux".to_string(),
os_version: "test".to_string(),
platform: "linux".to_string(),
username: "user".to_string(),
domain: "".to_string(),
timestamp: Utc::now(),
uptime_seconds: 1,
cpu_usage_percent: 0.0,
memory_total: 10,
memory_used: 5,
active_sessions: Vec::new(),
rdp_sessions: Vec::new(),
ssh_sessions: Vec::new(),
processes: Vec::new(),
network_interfaces: Vec::new(),
network_connections: Vec::new(),
workforce_activity: empty_workforce_activity(),
security_events: Vec::new(),
diagnostics: diagnostics_for_sessions(&[], &[], "test", None),
collector_version: COLLECTOR_VERSION.to_string(),
};
let value = serde_json::to_value(record).unwrap();
assert_eq!(value["agent_id"], "agent-1");
assert!(value.get("network_connections").unwrap().is_array());
assert!(value.get("workforce_activity").is_some());
assert_eq!(value["diagnostics"]["collector_source"], "test");
}
#[test]
fn deduplicates_sessions_by_host_user_id_and_type() {
let session = SessionInfo {
session_id: "2".to_string(),
username: "user".to_string(),
session_type: "rdp".to_string(),
session_source: Some("wts_api".to_string()),
remote_addr: None,
started_at: None,
active: true,
};
let deduped = dedupe_sessions("HOST-EXAMPLE", vec![session.clone(), session]);
assert_eq!(deduped.len(), 1);
}
}
@@ -1,556 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use crate::config::AgentConfig;
use crate::telemetry::{SessionInfo, TelemetryRecord};
#[derive(Debug, Clone)]
pub struct TelemetryTransport {
server_url: String,
api_key: String,
spool_dir: PathBuf,
timeout: Duration,
retry_attempts: u32,
}
impl TelemetryTransport {
pub fn new(config: &AgentConfig) -> Self {
Self {
server_url: config.server_url.clone(),
api_key: config.api_key.clone(),
spool_dir: config.spool_dir.clone(),
timeout: Duration::from_secs(config.timeout_seconds),
retry_attempts: config.retry_attempts,
}
}
pub fn send_or_spool(&self, record: &TelemetryRecord) -> Result<()> {
match self.send(record) {
Ok(()) => Ok(()),
Err(err) => {
self.spool(record)?;
Err(err)
}
}
}
pub fn send(&self, record: &TelemetryRecord) -> Result<()> {
let client = Client::builder()
.timeout(self.timeout)
.build()
.context("build telemetry HTTP client")?;
let mut headers = HeaderMap::new();
headers.insert(
"x-api-key",
HeaderValue::from_str(&self.api_key).context("invalid api key header")?,
);
let mut last_error = None;
for attempt in 0..self.retry_attempts.max(1) {
let result = client
.post(&self.server_url)
.headers(headers.clone())
.json(record)
.send()
.and_then(|response| response.error_for_status())
.map(|_| ());
match result {
Ok(()) => return Ok(()),
Err(err) => {
last_error = Some(err);
let backoff = Duration::from_millis(250 * u64::from(attempt + 1));
thread::sleep(backoff);
}
}
}
Err(anyhow!(
"telemetry POST failed: {}",
last_error
.map(|err| err.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
pub fn spool(&self, record: &TelemetryRecord) -> Result<PathBuf> {
fs::create_dir_all(&self.spool_dir)
.with_context(|| format!("create spool {}", self.spool_dir.display()))?;
let file_name = format!(
"{}-{}.json",
record.timestamp.format("%Y%m%dT%H%M%S%.3fZ"),
sanitize_file_part(&record.agent_id)
);
let path = self.spool_dir.join(file_name);
fs::write(&path, serde_json::to_vec(record)?)
.with_context(|| format!("write spool {}", path.display()))?;
Ok(path)
}
pub fn flush_spool(&self) -> Result<usize> {
flush_spool_dir(&self.spool_dir, |record| self.send(record))
}
}
#[derive(Debug, Clone)]
pub struct AwWorktimePublisher {
aw_api_base: String,
spool_dir: PathBuf,
timeout: Duration,
retry_attempts: u32,
}
impl AwWorktimePublisher {
pub fn new(config: &AgentConfig) -> Option<Self> {
if !config.aw_worktime_enabled {
return None;
}
let aw_api_base = config
.aw_api_base
.as_ref()?
.trim_end_matches('/')
.to_string();
if aw_api_base.is_empty() {
return None;
}
Some(Self {
aw_api_base,
spool_dir: config.spool_dir.join("aw-worktime"),
timeout: Duration::from_secs(config.timeout_seconds),
retry_attempts: config.retry_attempts,
})
}
pub fn publish_or_spool(&self, record: &TelemetryRecord) -> Result<()> {
if let Err(err) = self.flush_spool() {
eprintln!("ActivityWatch worktime spool flush failed: {err:#}");
}
match self.publish(record) {
Ok(_) => Ok(()),
Err(err) => {
self.spool(record)?;
Err(err)
}
}
}
pub fn publish(&self, record: &TelemetryRecord) -> Result<usize> {
let client = Client::builder()
.timeout(self.timeout)
.build()
.context("build ActivityWatch HTTP client")?;
let bucket_id = format!(
"aw-worktime-sessions_{}",
sanitize_bucket_part(&record.hostname)
);
ensure_aw_bucket(
&client,
&self.aw_api_base,
&bucket_id,
"aw-worktime-session-collector",
"aw.worktime.session",
&record.hostname,
)?;
let sessions = if record.active_sessions.is_empty() {
vec![SessionInfo {
session_id: "0".to_string(),
username: record.username.clone(),
session_type: "local".to_string(),
session_source: Some("local_fallback".to_string()),
remote_addr: None,
started_at: None,
active: true,
}]
} else {
record.active_sessions.clone()
};
let mut sent = 0;
let sample_seconds = 60_i64;
for session in sessions {
let ignore_for_kpi = ignored_for_kpi(record, &session);
let active_for_kpi = session.active && !ignore_for_kpi;
let state = if ignore_for_kpi {
"IgnoredForKpi"
} else if session.active {
"Active"
} else {
"Disconnected"
};
let payload = serde_json::json!({
"timestamp": record.timestamp,
"duration": sample_seconds,
"data": {
"username": session.username,
"userId": format!("{}\\{}", record.hostname, session.username),
"sessionId": session_id_number(&session),
"sessionName": session.session_type,
"sessionSource": session.session_source,
"state": state,
"active": active_for_kpi,
"ignoredForKpi": ignore_for_kpi,
"qualityNote": if ignore_for_kpi { Some("local_fallback is diagnostics-only and is not accepted as activity proof") } else { None },
"sampleSeconds": sample_seconds,
"pollSeconds": sample_seconds,
"hostname": record.hostname,
"source": "awatch-agent-rs",
"collectorSource": record.diagnostics.collector_source,
"sessionsCollectedTotal": record.diagnostics.sessions_collected_total,
"rdpSessionsTotal": record.diagnostics.rdp_sessions_total,
"activeSessionsTotal": record.diagnostics.active_sessions_total,
"collectorError": record.diagnostics.collector_error,
}
});
post_json_with_retry(
&client,
&format!(
"{}/buckets/{}/heartbeat?pulsetime=180",
self.aw_api_base, bucket_id
),
&payload,
self.retry_attempts,
)
.context("publish ActivityWatch worktime heartbeat")?;
sent += 1;
}
Ok(sent)
}
pub fn spool(&self, record: &TelemetryRecord) -> Result<PathBuf> {
fs::create_dir_all(&self.spool_dir)
.with_context(|| format!("create worktime spool {}", self.spool_dir.display()))?;
let file_name = format!(
"{}-{}.json",
record.timestamp.format("%Y%m%dT%H%M%S%.3fZ"),
sanitize_file_part(&record.agent_id)
);
let path = self.spool_dir.join(file_name);
fs::write(&path, serde_json::to_vec(record)?)
.with_context(|| format!("write worktime spool {}", path.display()))?;
Ok(path)
}
pub fn flush_spool(&self) -> Result<usize> {
flush_spool_dir(&self.spool_dir, |record| self.publish(record).map(|_| ()))
}
}
fn ignored_for_kpi(record: &TelemetryRecord, session: &SessionInfo) -> bool {
record.diagnostics.collector_source == "local_fallback"
|| session.session_source.as_deref() == Some("local_fallback")
}
fn ensure_aw_bucket(
client: &Client,
aw_api_base: &str,
bucket_id: &str,
client_name: &str,
bucket_type: &str,
hostname: &str,
) -> Result<()> {
let bucket_url = format!("{}/buckets/{}", aw_api_base, bucket_id);
if client
.get(&bucket_url)
.send()
.and_then(|response| response.error_for_status())
.is_ok()
{
return Ok(());
}
let body = serde_json::json!({
"client": client_name,
"type": bucket_type,
"hostname": hostname,
});
post_json_with_retry(client, &bucket_url, &body, 3)
.context("create ActivityWatch worktime bucket")?;
Ok(())
}
fn post_json_with_retry(
client: &Client,
url: &str,
payload: &serde_json::Value,
retry_attempts: u32,
) -> Result<()> {
let mut last_error = None;
for attempt in 0..retry_attempts.max(1) {
let result = client
.post(url)
.json(payload)
.send()
.and_then(|response| response.error_for_status())
.map(|_| ());
match result {
Ok(()) => return Ok(()),
Err(err) => {
last_error = Some(err);
let backoff = Duration::from_millis(250 * u64::from(attempt + 1));
thread::sleep(backoff);
}
}
}
Err(anyhow!(
"HTTP POST failed: {}",
last_error
.map(|err| err.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
fn session_id_number(session: &SessionInfo) -> i64 {
session
.session_id
.split(|ch: char| !ch.is_ascii_digit())
.find(|part| !part.is_empty())
.and_then(|part| part.parse::<i64>().ok())
.unwrap_or(0)
}
fn sanitize_bucket_part(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect()
}
pub fn flush_spool_dir<F>(spool_dir: &Path, mut sender: F) -> Result<usize>
where
F: FnMut(&TelemetryRecord) -> Result<()>,
{
if !spool_dir.exists() {
return Ok(0);
}
let mut sent = 0;
let mut entries = fs::read_dir(spool_dir)
.with_context(|| format!("read spool {}", spool_dir.display()))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect::<Vec<_>>();
entries.sort();
for path in entries {
let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let record: TelemetryRecord =
serde_json::from_slice(&data).with_context(|| format!("parse {}", path.display()))?;
sender(&record)?;
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
sent += 1;
}
Ok(sent)
}
fn sanitize_file_part(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect()
}
pub fn spool_health(spool_dir: &Path) -> serde_json::Value {
let telemetry_queued = count_spool_json_files(spool_dir);
let worktime_spool_dir = spool_dir.join("aw-worktime");
let worktime_queued = count_spool_json_files(&worktime_spool_dir);
serde_json::json!({
"generated_at_utc": Utc::now(),
"spool_dir": spool_dir.display().to_string(),
"worktime_spool_dir": worktime_spool_dir.display().to_string(),
"queued": telemetry_queued,
"telemetry_queued": telemetry_queued,
"worktime_queued": worktime_queued,
"total_queued": telemetry_queued + worktime_queued,
})
}
fn count_spool_json_files(spool_dir: &Path) -> usize {
fs::read_dir(spool_dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
.count()
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
use crate::telemetry::{TelemetryRecord, diagnostics_for_sessions, empty_workforce_activity};
fn record() -> TelemetryRecord {
TelemetryRecord {
agent_id: "agent/1".to_string(),
hostname: "HOST-EXAMPLE".to_string(),
os_name: "Linux".to_string(),
os_version: "test".to_string(),
platform: "linux".to_string(),
username: "user".to_string(),
domain: "".to_string(),
timestamp: Utc::now(),
uptime_seconds: 1,
cpu_usage_percent: 0.0,
memory_total: 1,
memory_used: 1,
active_sessions: Vec::new(),
rdp_sessions: Vec::new(),
ssh_sessions: Vec::new(),
processes: Vec::new(),
network_interfaces: Vec::new(),
network_connections: Vec::new(),
workforce_activity: empty_workforce_activity(),
security_events: Vec::new(),
diagnostics: diagnostics_for_sessions(&[], &[], "test", None),
collector_version: "test".to_string(),
}
}
#[test]
fn spools_and_flushes_records() {
let dir = tempdir().unwrap();
let config = AgentConfig {
spool_dir: dir.path().to_path_buf(),
..AgentConfig::default()
};
let transport = TelemetryTransport::new(&config);
let path = transport.spool(&record()).unwrap();
assert!(path.is_file());
let mut seen = 0;
let flushed = flush_spool_dir(dir.path(), |_| {
seen += 1;
Ok(())
})
.unwrap();
assert_eq!(flushed, 1);
assert_eq!(seen, 1);
assert!(!path.exists());
}
#[test]
fn send_or_spool_preserves_record_when_server_is_unavailable() {
let dir = tempdir().unwrap();
let config = AgentConfig {
server_url: "http://127.0.0.1:9/api/telemetry".to_string(),
retry_attempts: 1,
timeout_seconds: 1,
spool_dir: dir.path().to_path_buf(),
..AgentConfig::default()
};
let transport = TelemetryTransport::new(&config);
assert!(transport.send_or_spool(&record()).is_err());
let queued = fs::read_dir(dir.path())
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
.count();
assert_eq!(queued, 1);
}
#[test]
fn flush_spool_keeps_record_when_sender_fails() {
let dir = tempdir().unwrap();
let config = AgentConfig {
spool_dir: dir.path().to_path_buf(),
..AgentConfig::default()
};
let transport = TelemetryTransport::new(&config);
let path = transport.spool(&record()).unwrap();
let result = flush_spool_dir(dir.path(), |_| anyhow::bail!("transport down"));
assert!(result.is_err());
assert!(path.exists());
}
#[test]
fn session_id_number_extracts_numeric_id() {
let session = SessionInfo {
session_id: "rdp-12-user".to_string(),
username: "user".to_string(),
session_type: "rdp".to_string(),
session_source: Some("wts_api".to_string()),
remote_addr: None,
started_at: None,
active: true,
};
assert_eq!(session_id_number(&session), 12);
}
#[test]
fn local_fallback_sessions_are_diagnostics_only_for_kpi() {
let mut wts_record = record();
wts_record.diagnostics = diagnostics_for_sessions(&[], &[], "wts_api", None);
let session = SessionInfo {
session_id: "0".to_string(),
username: "user".to_string(),
session_type: "local".to_string(),
session_source: Some("local_fallback".to_string()),
remote_addr: None,
started_at: None,
active: true,
};
assert!(ignored_for_kpi(&wts_record, &session));
let mut fallback_record = record();
fallback_record.diagnostics = diagnostics_for_sessions(&[], &[], "local_fallback", None);
let session = SessionInfo {
session_source: Some("wts_api".to_string()),
..session
};
assert!(ignored_for_kpi(&fallback_record, &session));
}
#[test]
fn worktime_publisher_is_disabled_by_default() {
assert!(AwWorktimePublisher::new(&AgentConfig::default()).is_none());
}
#[test]
fn worktime_publisher_spools_to_separate_dir() {
let dir = tempdir().unwrap();
let config = AgentConfig {
aw_api_base: Some("http://127.0.0.1:9/api/0".to_string()),
aw_worktime_enabled: true,
spool_dir: dir.path().to_path_buf(),
..AgentConfig::default()
};
let publisher = AwWorktimePublisher::new(&config).unwrap();
let path = publisher.spool(&record()).unwrap();
assert!(path.starts_with(dir.path().join("aw-worktime")));
assert!(path.is_file());
}
#[test]
fn spool_health_reports_telemetry_and_worktime_backlog() {
let dir = tempdir().unwrap();
let config = AgentConfig {
aw_api_base: Some("http://127.0.0.1:9/api/0".to_string()),
aw_worktime_enabled: true,
spool_dir: dir.path().to_path_buf(),
..AgentConfig::default()
};
let transport = TelemetryTransport::new(&config);
let publisher = AwWorktimePublisher::new(&config).unwrap();
transport.spool(&record()).unwrap();
publisher.spool(&record()).unwrap();
let health = spool_health(dir.path());
assert_eq!(health["queued"].as_u64(), Some(1));
assert_eq!(health["telemetry_queued"].as_u64(), Some(1));
assert_eq!(health["worktime_queued"].as_u64(), Some(1));
assert_eq!(health["total_queued"].as_u64(), Some(2));
}
}
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "awatch-agent"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tiny_http.workspace = true
[dev-dependencies]
tempfile.workspace = true
-174
View File
@@ -1,174 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentConfig {
pub agent_id: String,
pub host_id: String,
pub platform: String,
pub server_url: String,
pub spool_dir: PathBuf,
pub health_bind: String,
pub request_timeout_seconds: u64,
pub retry_max_attempts: u32,
pub retry_base_backoff_ms: u64,
}
impl Default for AgentConfig {
fn default() -> Self {
let hostname = local_hostname();
Self {
agent_id: uuid_from_seed(&format!("agent:{hostname}")),
host_id: uuid_from_seed(&format!("host:{hostname}")),
platform: current_platform().to_string(),
server_url: "http://127.0.0.1:9/api/agent/telemetry".to_string(),
spool_dir: default_spool_dir(),
health_bind: "127.0.0.1:8787".to_string(),
request_timeout_seconds: 10,
retry_max_attempts: 3,
retry_base_backoff_ms: 250,
}
}
}
impl AgentConfig {
pub fn load(path: &Path) -> Result<Self> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Self::parse_toml_like(&text)
}
pub fn parse_toml_like(text: &str) -> Result<Self> {
let mut config = Self::default();
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"agent_id" => config.agent_id = value.to_string(),
"host_id" => config.host_id = value.to_string(),
"platform" => config.platform = value.to_string(),
"server_url" => config.server_url = value.to_string(),
"spool_dir" => config.spool_dir = PathBuf::from(value),
"health_bind" => config.health_bind = value.to_string(),
"request_timeout_seconds" => {
config.request_timeout_seconds = value.parse().unwrap_or(10)
}
"retry_max_attempts" => config.retry_max_attempts = value.parse().unwrap_or(3),
"retry_base_backoff_ms" => {
config.retry_base_backoff_ms = value.parse().unwrap_or(250)
}
_ => {}
}
}
Ok(config)
}
pub fn request_timeout(&self) -> Duration {
Duration::from_secs(self.request_timeout_seconds)
}
}
pub fn default_config_path() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\AWatch-rus\agent\awatch-agent.toml")
} else {
PathBuf::from("/etc/awatch-agent/awatch-agent.toml")
}
}
fn default_spool_dir() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\AWatch-rus\agent\spool")
} else {
PathBuf::from("/var/lib/awatch-agent/spool")
}
}
fn current_platform() -> &'static str {
if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "freebsd") {
"freebsd"
} else {
"linux"
}
}
fn local_hostname() -> String {
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "HOST-EXAMPLE".to_string())
}
fn uuid_from_seed(seed: &str) -> String {
let digest = Sha256::digest(seed.as_bytes());
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0],
bytes[1],
bytes[2],
bytes[3],
bytes[4],
bytes[5],
bytes[6],
bytes[7],
bytes[8],
bytes[9],
bytes[10],
bytes[11],
bytes[12],
bytes[13],
bytes[14],
bytes[15]
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_config_without_external_toml_dependency() {
let config = AgentConfig::parse_toml_like(
r#"
agent_id = "00000000-0000-5000-8000-000000000001"
host_id = "00000000-0000-5000-8000-000000000002"
platform = "windows"
server_url = "https://awatch.example/api/agent/telemetry"
spool_dir = "/tmp/awatch-agent-spool"
health_bind = "127.0.0.1:8788"
request_timeout_seconds = 2
retry_max_attempts = 5
retry_base_backoff_ms = 50
"#,
)
.unwrap();
assert_eq!(config.platform, "windows");
assert_eq!(config.retry_max_attempts, 5);
assert_eq!(config.spool_dir, PathBuf::from("/tmp/awatch-agent-spool"));
}
#[test]
fn generated_ids_are_uuid_shaped() {
let id = uuid_from_seed("HOST-EXAMPLE");
assert_eq!(id.len(), 36);
assert_eq!(&id[14..15], "5");
assert!(matches!(&id[19..20], "8" | "9" | "a" | "b"));
}
}
@@ -1,77 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::config::AgentConfig;
pub const AGENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TelemetryEnvelope {
pub agent_id: String,
pub host_id: String,
pub platform: String,
pub timestamp: DateTime<Utc>,
pub records: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Heartbeat {
pub agent_version: String,
pub platform: String,
pub status: AgentStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
Online,
Degraded,
Offline,
}
impl TelemetryEnvelope {
pub fn empty(config: &AgentConfig) -> Self {
Self {
agent_id: config.agent_id.clone(),
host_id: config.host_id.clone(),
platform: config.platform.clone(),
timestamp: Utc::now(),
records: Vec::new(),
}
}
pub fn heartbeat(config: &AgentConfig) -> Self {
let heartbeat = Heartbeat {
agent_version: AGENT_VERSION.to_string(),
platform: config.platform.clone(),
status: AgentStatus::Online,
};
Self {
records: vec![serde_json::json!({
"type": "heartbeat",
"payload": heartbeat,
})],
..Self::empty(config)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heartbeat_contract_is_stable_and_contains_no_inventory() {
let config = AgentConfig::default();
let envelope = TelemetryEnvelope::heartbeat(&config);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["agent_id"], config.agent_id);
assert_eq!(value["host_id"], config.host_id);
assert_eq!(value["platform"], config.platform);
assert!(value["records"].is_array());
assert_eq!(value["records"][0]["type"], "heartbeat");
assert_eq!(value["records"][0]["payload"]["status"], "online");
assert!(value["records"][0]["payload"].get("hostname").is_none());
assert!(value["records"][0]["payload"].get("processes").is_none());
}
}
@@ -1,56 +0,0 @@
use std::net::ToSocketAddrs;
use std::time::Duration;
use anyhow::{Context, Result};
use tiny_http::{Header, Response, Server, StatusCode};
use crate::envelope::AGENT_VERSION;
use crate::metrics::AgentMetrics;
pub fn serve_health(bind: &str, metrics: AgentMetrics, max_requests: Option<usize>) -> Result<()> {
bind.to_socket_addrs()
.with_context(|| format!("parse health bind address {bind}"))?;
let server =
Server::http(bind).map_err(|err| anyhow::anyhow!("bind health endpoint: {err}"))?;
let mut served = 0_usize;
loop {
if max_requests.is_some_and(|limit| served >= limit) {
return Ok(());
}
let Some(request) = server
.recv_timeout(Duration::from_millis(250))
.map_err(|err| anyhow::anyhow!("receive health request: {err}"))?
else {
continue;
};
served += 1;
let response = match (request.method().as_str(), request.url()) {
("GET", "/healthz") => json_response(serde_json::json!({
"ok": true,
"status": "online",
"agent_version": AGENT_VERSION,
})),
("GET", "/metrics") => text_response(metrics.render_prometheus()),
_ => Response::from_string("not found").with_status_code(StatusCode(404)),
};
request
.respond(response)
.map_err(|err| anyhow::anyhow!("send health response: {err}"))?;
}
}
fn json_response(value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
let mut response = Response::from_data(serde_json::to_vec(&value).unwrap_or_default());
if let Ok(header) = Header::from_bytes("Content-Type", "application/json") {
response.add_header(header);
}
response
}
fn text_response(value: String) -> Response<std::io::Cursor<Vec<u8>>> {
let mut response = Response::from_string(value);
if let Ok(header) = Header::from_bytes("Content-Type", "text/plain; version=0.0.4") {
response.add_header(header);
}
response
}
@@ -1,43 +0,0 @@
use chrono::Utc;
use serde::Serialize;
#[derive(Debug, Serialize)]
struct LogLine<'a> {
timestamp: String,
level: &'a str,
agent_id: &'a str,
component: &'a str,
message: &'a str,
}
pub fn log_json(agent_id: &str, level: &str, component: &str, message: &str) {
let line = LogLine {
timestamp: Utc::now().to_rfc3339(),
level,
agent_id,
component,
message,
};
if let Ok(json) = serde_json::to_string(&line) {
eprintln!("{json}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structured_log_shape_is_serializable() {
let line = LogLine {
timestamp: "2026-06-07T00:00:00Z".to_string(),
level: "INFO",
agent_id: "agent-1",
component: "spool",
message: "queued",
};
let value = serde_json::to_value(line).unwrap();
assert_eq!(value["level"], "INFO");
assert_eq!(value["component"], "spool");
}
}
-143
View File
@@ -1,143 +0,0 @@
mod config;
mod envelope;
mod health;
mod logging;
mod metrics;
mod spool;
mod transport;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use config::{AgentConfig, default_config_path};
use envelope::TelemetryEnvelope;
use logging::log_json;
use spool::LocalSpool;
#[derive(Debug, Parser)]
#[command(about = "AWatch-rus Rust agent baseline scaffold")]
struct Cli {
#[arg(long, env = "AWATCH_AGENT_CONFIG")]
config: Option<PathBuf>,
#[arg(long, env = "AWATCH_AGENT_SERVER_URL")]
server_url: Option<String>,
#[arg(long, env = "AWATCH_AGENT_SPOOL_DIR")]
spool_dir: Option<PathBuf>,
#[arg(long)]
enqueue_heartbeat: bool,
#[arg(long)]
flush_spool: bool,
#[arg(long)]
metrics: bool,
#[arg(long)]
healthz: bool,
#[arg(long)]
print_envelope: bool,
#[arg(long)]
max_health_requests: Option<usize>,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let mut config = load_config(cli.config.as_ref())?;
if let Some(server_url) = cli.server_url {
config.server_url = server_url;
}
if let Some(spool_dir) = cli.spool_dir {
config.spool_dir = spool_dir;
}
let spool = LocalSpool::new(config.spool_dir.clone());
let mut metrics = spool.metrics().unwrap_or_default();
if cli.print_envelope {
println!(
"{}",
serde_json::to_string_pretty(&TelemetryEnvelope::heartbeat(&config))?
);
return Ok(0);
}
if cli.enqueue_heartbeat {
spool.enqueue(TelemetryEnvelope::heartbeat(&config))?;
metrics.heartbeat_sent = metrics.heartbeat_sent.saturating_add(1);
log_json(
&config.agent_id,
"INFO",
"heartbeat",
"heartbeat envelope queued",
);
}
if cli.flush_spool {
let summary = transport::flush_with_retry(&config, &spool, &mut metrics)?;
println!("{}", serde_json::to_string_pretty(&summary)?);
return Ok(0);
}
if cli.metrics {
let mut current = spool.metrics()?;
current.heartbeat_sent = metrics.heartbeat_sent;
current.retry_count = metrics.retry_count;
print!("{}", current.render_prometheus());
return Ok(0);
}
if cli.healthz {
health::serve_health(
&config.health_bind,
spool.metrics()?,
cli.max_health_requests,
)?;
return Ok(0);
}
if !cli.enqueue_heartbeat {
log_json(&config.agent_id, "INFO", "agent", "no action requested");
}
Ok(0)
}
fn load_config(path: Option<&PathBuf>) -> Result<AgentConfig> {
let path = path.cloned().unwrap_or_else(default_config_path);
if path.exists() {
AgentConfig::load(&path)
} else {
AgentConfig::parse_toml_like("")
.with_context(|| format!("load default config because {} is absent", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_run_has_no_monitoring_side_effect() {
let config = AgentConfig::parse_toml_like("").unwrap();
let envelope = TelemetryEnvelope::heartbeat(&config);
assert_eq!(envelope.records.len(), 1);
assert!(envelope.records[0].get("processes").is_none());
assert!(envelope.records[0].get("screenshots").is_none());
}
}
@@ -1,51 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentMetrics {
pub queued_records: usize,
pub retry_count: u64,
pub heartbeat_sent: u64,
pub spool_size: u64,
}
impl AgentMetrics {
pub fn render_prometheus(&self) -> String {
format!(
concat!(
"# HELP awatch_agent_queued_records Local spool records waiting for delivery.\n",
"# TYPE awatch_agent_queued_records gauge\n",
"awatch_agent_queued_records {}\n",
"# HELP awatch_agent_retry_count Total retry attempts performed by the agent.\n",
"# TYPE awatch_agent_retry_count counter\n",
"awatch_agent_retry_count {}\n",
"# HELP awatch_agent_heartbeat_sent Heartbeat envelopes generated by the agent.\n",
"# TYPE awatch_agent_heartbeat_sent counter\n",
"awatch_agent_heartbeat_sent {}\n",
"# HELP awatch_agent_spool_size Local spool size in bytes.\n",
"# TYPE awatch_agent_spool_size gauge\n",
"awatch_agent_spool_size {}\n"
),
self.queued_records, self.retry_count, self.heartbeat_sent, self.spool_size
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_prometheus_metrics() {
let metrics = AgentMetrics {
queued_records: 2,
retry_count: 3,
heartbeat_sent: 1,
spool_size: 512,
};
let rendered = metrics.render_prometheus();
assert!(rendered.contains("awatch_agent_queued_records 2"));
assert!(rendered.contains("awatch_agent_retry_count 3"));
assert!(rendered.contains("awatch_agent_heartbeat_sent 1"));
assert!(rendered.contains("awatch_agent_spool_size 512"));
}
}
-255
View File
@@ -1,255 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::envelope::TelemetryEnvelope;
use crate::metrics::AgentMetrics;
#[derive(Debug, Clone)]
pub struct LocalSpool {
root: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpoolItem {
pub envelope: TelemetryEnvelope,
pub enqueued_at: DateTime<Utc>,
pub retry_count: u32,
pub last_error: Option<String>,
}
impl LocalSpool {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn enqueue(&self, envelope: TelemetryEnvelope) -> Result<PathBuf> {
self.ensure_dirs()?;
let item = SpoolItem {
envelope,
enqueued_at: Utc::now(),
retry_count: 0,
last_error: None,
};
let file_name = format!(
"{}-{}.json",
item.enqueued_at.format("%Y%m%dT%H%M%S%.3fZ"),
sanitize_file_part(&item.envelope.agent_id)
);
let path = self.pending_dir().join(file_name);
write_json_atomic(&path, &item)?;
Ok(path)
}
pub fn pending_paths(&self) -> Result<Vec<PathBuf>> {
read_json_paths(&self.pending_dir())
}
#[cfg(test)]
pub fn dead_letter_paths(&self) -> Result<Vec<PathBuf>> {
read_json_paths(&self.dead_letter_dir())
}
pub fn metrics(&self) -> Result<AgentMetrics> {
let paths = self.pending_paths()?;
let spool_size = paths
.iter()
.filter_map(|path| fs::metadata(path).ok())
.map(|metadata| metadata.len())
.sum();
Ok(AgentMetrics {
queued_records: paths.len(),
spool_size,
..AgentMetrics::default()
})
}
pub fn process_pending<F>(&self, max_retry_count: u32, mut sender: F) -> Result<FlushSummary>
where
F: FnMut(&TelemetryEnvelope) -> Result<()>,
{
self.ensure_dirs()?;
let mut summary = FlushSummary::default();
for path in self.pending_paths()? {
let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let mut item = match serde_json::from_slice::<SpoolItem>(&bytes) {
Ok(item) => item,
Err(err) => {
self.move_to_dead_letter(&path, Some(format!("corrupt json: {err}")))?;
summary.corrupt += 1;
continue;
}
};
match sender(&item.envelope) {
Ok(()) => {
fs::remove_file(&path)
.with_context(|| format!("remove delivered {}", path.display()))?;
summary.delivered += 1;
}
Err(err) => {
item.retry_count = item.retry_count.saturating_add(1);
item.last_error = Some(err.to_string());
summary.retried += 1;
if item.retry_count >= max_retry_count {
write_json_atomic(&path, &item)?;
self.move_to_dead_letter(&path, item.last_error.clone())?;
summary.dead_lettered += 1;
} else {
write_json_atomic(&path, &item)?;
}
}
}
}
Ok(summary)
}
fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(self.pending_dir())
.with_context(|| format!("create {}", self.pending_dir().display()))?;
fs::create_dir_all(self.dead_letter_dir())
.with_context(|| format!("create {}", self.dead_letter_dir().display()))?;
Ok(())
}
fn pending_dir(&self) -> PathBuf {
self.root.join("pending")
}
fn dead_letter_dir(&self) -> PathBuf {
self.root.join("dead-letter")
}
fn move_to_dead_letter(&self, path: &Path, reason: Option<String>) -> Result<()> {
self.ensure_dirs()?;
let file_name = path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| format!("{}.json", Utc::now().timestamp_millis()));
let target = self.dead_letter_dir().join(file_name);
if let Some(reason) = reason {
let note_path = target.with_extension("reason.txt");
fs::write(note_path, reason)?;
}
fs::rename(path, target).or_else(|_| {
fs::copy(path, self.dead_letter_dir().join("recovered-corrupt.json"))?;
fs::remove_file(path)
})?;
Ok(())
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FlushSummary {
pub delivered: usize,
pub retried: usize,
pub dead_lettered: usize,
pub corrupt: usize,
}
fn read_json_paths(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut paths = fs::read_dir(dir)
.with_context(|| format!("read {}", dir.display()))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect::<Vec<_>>();
paths.sort();
Ok(paths)
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, serde_json::to_vec_pretty(value)?)
.with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("rename {} to {}", tmp.display(), path.display()))?;
Ok(())
}
fn sanitize_file_part(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use tempfile::tempdir;
use super::*;
use crate::config::AgentConfig;
fn envelope() -> TelemetryEnvelope {
TelemetryEnvelope::heartbeat(&AgentConfig::default())
}
#[test]
fn enqueues_and_delivers_spool_item() {
let dir = tempdir().unwrap();
let spool = LocalSpool::new(dir.path());
spool.enqueue(envelope()).unwrap();
assert_eq!(spool.pending_paths().unwrap().len(), 1);
let summary = spool.process_pending(3, |_| Ok(())).unwrap();
assert_eq!(summary.delivered, 1);
assert_eq!(spool.pending_paths().unwrap().len(), 0);
}
#[test]
fn retry_keeps_item_until_max_retry_then_dead_letters() {
let dir = tempdir().unwrap();
let spool = LocalSpool::new(dir.path());
spool.enqueue(envelope()).unwrap();
let first = spool
.process_pending(2, |_| Err(anyhow!("transport down")))
.unwrap();
assert_eq!(first.retried, 1);
assert_eq!(first.dead_lettered, 0);
assert_eq!(spool.pending_paths().unwrap().len(), 1);
let second = spool
.process_pending(2, |_| Err(anyhow!("transport down")))
.unwrap();
assert_eq!(second.dead_lettered, 1);
assert_eq!(spool.pending_paths().unwrap().len(), 0);
assert_eq!(spool.dead_letter_paths().unwrap().len(), 1);
}
#[test]
fn corrupt_spool_item_moves_to_dead_letter() {
let dir = tempdir().unwrap();
let spool = LocalSpool::new(dir.path());
fs::create_dir_all(dir.path().join("pending")).unwrap();
fs::write(dir.path().join("pending/bad.json"), b"{not-json").unwrap();
let summary = spool.process_pending(3, |_| Ok(())).unwrap();
assert_eq!(summary.corrupt, 1);
assert_eq!(spool.pending_paths().unwrap().len(), 0);
assert_eq!(spool.dead_letter_paths().unwrap().len(), 1);
}
#[test]
fn metrics_report_queue_and_size() {
let dir = tempdir().unwrap();
let spool = LocalSpool::new(dir.path());
spool.enqueue(envelope()).unwrap();
let metrics = spool.metrics().unwrap();
assert_eq!(metrics.queued_records, 1);
assert!(metrics.spool_size > 0);
}
}
@@ -1,63 +0,0 @@
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use reqwest::blocking::Client;
use crate::config::AgentConfig;
use crate::envelope::TelemetryEnvelope;
use crate::metrics::AgentMetrics;
use crate::spool::{FlushSummary, LocalSpool};
pub fn send_envelope(config: &AgentConfig, envelope: &TelemetryEnvelope) -> Result<()> {
let client = Client::builder()
.timeout(config.request_timeout())
.build()
.context("build agent telemetry HTTP client")?;
client
.post(&config.server_url)
.json(envelope)
.send()
.and_then(|response| response.error_for_status())
.map(|_| ())
.map_err(|err| anyhow!("agent telemetry POST failed: {err}"))
}
pub fn flush_with_retry(
config: &AgentConfig,
spool: &LocalSpool,
metrics: &mut AgentMetrics,
) -> Result<FlushSummary> {
let mut attempt = 0_u32;
loop {
let summary = spool.process_pending(config.retry_max_attempts, |envelope| {
send_envelope(config, envelope)
})?;
metrics.retry_count = metrics
.retry_count
.saturating_add(u64::try_from(summary.retried).unwrap_or(u64::MAX));
if summary.retried == 0 || attempt + 1 >= config.retry_max_attempts {
return Ok(summary);
}
let backoff = exponential_backoff(config.retry_base_backoff_ms, attempt);
thread::sleep(backoff);
attempt += 1;
}
}
pub fn exponential_backoff(base_ms: u64, attempt: u32) -> Duration {
let factor = 1_u64.checked_shl(attempt.min(10)).unwrap_or(1024);
Duration::from_millis(base_ms.saturating_mul(factor))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_is_exponential_and_bounded() {
assert_eq!(exponential_backoff(100, 0), Duration::from_millis(100));
assert_eq!(exponential_backoff(100, 3), Duration::from_millis(800));
assert_eq!(exponential_backoff(100, 99), Duration::from_millis(102400));
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "check-aw-data"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
serde_json.workspace = true
-555
View File
@@ -1,555 +0,0 @@
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use clap::Parser;
use reqwest::blocking::Client;
use serde_json::Value;
const DEFAULT_SERVER: &str = "http://192.0.2.13:5600";
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
const BUCKETS: &[&str] = &[
"aw-dlp-endpoint-signals",
"aw-dlp-incidents",
"aw-dlp-review",
"aw-dlp-rules",
"aw-watcher-afk",
"aw-watcher-window",
"aw-session-events",
"aw-worktime-sessions",
];
#[derive(Debug, Parser)]
#[command(about = "Check ActivityWatch data collection freshness for DetMir/AW-RUS")]
struct Cli {
#[arg(long)]
server: Option<String>,
#[arg(long)]
host: Option<String>,
#[arg(long, default_value_t = 15)]
timeout_seconds: u64,
#[arg(long, default_value_t = 15)]
bucket_timeout_seconds: u64,
#[arg(long, default_value_t = 3)]
context_timeout_seconds: u64,
#[arg(long, default_value_t = false)]
with_event_ids: bool,
#[arg(long, default_value_t = false)]
no_color: bool,
}
#[derive(Debug, Clone)]
struct Colors {
red: &'static str,
green: &'static str,
yellow: &'static str,
cyan: &'static str,
reset: &'static str,
}
impl Colors {
fn new(enabled: bool) -> Self {
if enabled {
Self {
red: "\u{1b}[0;31m",
green: "\u{1b}[0;32m",
yellow: "\u{1b}[1;33m",
cyan: "\u{1b}[0;36m",
reset: "\u{1b}[0m",
}
} else {
Self {
red: "",
green: "",
yellow: "",
cyan: "",
reset: "",
}
}
}
fn paint(&self, color: &str, text: &str) -> String {
format!("{color}{text}{}", self.reset)
}
}
#[derive(Debug, Default)]
struct ContextState {
host_inactive: bool,
guard_healthy: bool,
}
#[derive(Debug, PartialEq, Eq)]
enum BucketStatus {
Fresh,
Stale,
Dead,
Empty,
EventDriven,
Inactive,
Unknown,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let server = cli
.server
.or_else(|| env_nonempty("AW_CHECK_SERVER"))
.or_else(|| env_nonempty("AW_SERVER_URL"))
.unwrap_or_else(|| DEFAULT_SERVER.to_string())
.trim_end_matches('/')
.to_string();
let host = cli
.host
.or_else(|| env_nonempty("AW_CHECK_HOST"))
.or_else(|| env_nonempty("AW_MONITORED_HOST"))
.or_else(|| env_nonempty("AW_MONITORED_WINDOWS_HOSTNAME"))
.unwrap_or_else(|| DEFAULT_HOST.to_string());
let colors = Colors::new(!cli.no_color && env_nonempty("NO_COLOR").is_none());
let client = Client::builder()
.no_proxy()
.build()
.context("build HTTP client")?;
let now = Utc::now();
println!("=== ActivityWatch Data Check: {host} ===");
println!();
print!("Server connectivity... ");
let info_url = format!("{server}/api/0/info");
let info = match get_json(&client, &info_url, cli.timeout_seconds) {
Ok(value) => value,
Err(err) => {
eprintln!("check-aw-data: {err:#}");
println!(
"{} (cannot reach {server})",
colors.paint(colors.red, "FAILED")
);
return Ok(1);
}
};
let Some(version) = info.get("version").and_then(Value::as_str) else {
println!(
"{} (cannot reach {server})",
colors.paint(colors.red, "FAILED")
);
return Ok(1);
};
println!("{} (aw-server {version})", colors.paint(colors.green, "OK"));
println!();
let context = read_context(&server, &host, now, cli.context_timeout_seconds);
let buckets_index = get_json(
&client,
&format!("{server}/api/0/buckets"),
cli.timeout_seconds,
)
.ok();
println!("--- Buckets ---");
println!(
"{:<45} {:<8} {:<22} STATUS",
"BUCKET", "EVENTS", "LAST EVENT"
);
println!(
"{:<45} {:<8} {:<22} ------",
"---------------------------------------------", "--------", "----------------------"
);
for bucket in BUCKETS {
let bucket_full = format!("{bucket}_{host}");
let event = bucket_event(
&server,
&bucket_full,
buckets_index.as_ref(),
cli.with_event_ids,
cli.bucket_timeout_seconds,
);
let (last_id, last_ts, age, status) = render_bucket(bucket, event.as_ref(), now, &context);
println!(
"{:<45} {:<8} {:<22} {}",
bucket_full,
last_id,
format!("{last_ts} ({age})"),
render_status(&colors, status)
);
}
println!();
println!("--- CORS Check ---");
let cors_status = check_cors(&client, &server);
if cors_status == 200 {
println!("{} (HTTP 200)", colors.paint(colors.green, "CORS: OK"));
} else {
println!(
"{} (HTTP {cors_status})",
colors.paint(colors.red, "CORS: FAIL")
);
}
println!();
println!("=== Check Complete ===");
println!("Timestamp: {}", now.format("%Y-%m-%d %H:%M:%S UTC"));
Ok(0)
}
fn read_context(
server: &str,
host: &str,
now: DateTime<Utc>,
timeout_seconds: u64,
) -> ContextState {
let mut state = ContextState::default();
if let Ok(Some(event)) = get_latest_event(
server,
&format!("aw-worktime-sessions_{host}"),
timeout_seconds,
) {
if let Some(ts) = event_timestamp(&event) {
let age = (now - ts).num_seconds();
let active = event
.pointer("/data/active")
.and_then(Value::as_bool)
.unwrap_or(false);
if (0..900).contains(&age) && !active {
state.host_inactive = true;
}
}
}
if let Ok(Some(event)) = get_latest_event(
server,
&format!("aw-rus-collector-guard_{host}"),
timeout_seconds,
) {
if let Some(ts) = event_timestamp(&event) {
let age = (now - ts).num_seconds();
let status = event
.pointer("/data/status")
.and_then(Value::as_str)
.unwrap_or("");
let problems = event
.pointer("/data/problems")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
if (0..300).contains(&age) && status == "ok" && problems == 0 {
state.guard_healthy = true;
}
}
}
state
}
fn bucket_event(
server: &str,
bucket: &str,
buckets_index: Option<&Value>,
with_event_ids: bool,
timeout_seconds: u64,
) -> Option<Value> {
if with_event_ids {
get_latest_event(server, bucket, timeout_seconds)
.ok()
.flatten()
.or_else(|| metadata_event(buckets_index, bucket))
} else {
metadata_event(buckets_index, bucket)
}
}
fn render_bucket(
bucket: &str,
event: Option<&Value>,
now: DateTime<Utc>,
context: &ContextState,
) -> (String, String, String, BucketStatus) {
let Some(event) = event else {
return (
"0".to_string(),
"no events".to_string(),
"none".to_string(),
classify_bucket_no_events(bucket, context),
);
};
let last_id = event
.get("id")
.map(json_value_to_string)
.unwrap_or_else(|| "0".to_string());
let Some(ts_raw) = event.get("timestamp").and_then(Value::as_str) else {
return (
last_id,
"no events".to_string(),
"none".to_string(),
classify_bucket_no_events(bucket, context),
);
};
let Some(ts) = parse_ts(ts_raw) else {
return (
last_id,
ts_raw.to_string(),
"unknown".to_string(),
BucketStatus::Unknown,
);
};
let age = (now - ts).num_seconds().max(0);
(
last_id,
ts_raw.to_string(),
format_age(age),
classify_bucket_age(bucket, age, context),
)
}
fn classify_bucket_age(bucket: &str, age_sec: i64, context: &ContextState) -> BucketStatus {
match bucket {
"aw-watcher-window" if context.host_inactive => return BucketStatus::Inactive,
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
return BucketStatus::Inactive;
}
_ => {}
}
match bucket {
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
if age_sec < 86_400 {
BucketStatus::Fresh
} else {
BucketStatus::EventDriven
}
}
_ if age_sec < 3_600 => BucketStatus::Fresh,
_ if age_sec < 86_400 => BucketStatus::Stale,
_ => BucketStatus::Dead,
}
}
fn classify_bucket_no_events(bucket: &str, context: &ContextState) -> BucketStatus {
match bucket {
"aw-watcher-window" if context.host_inactive => BucketStatus::Inactive,
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
BucketStatus::Inactive
}
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
BucketStatus::EventDriven
}
_ => BucketStatus::Empty,
}
}
fn render_status(colors: &Colors, status: BucketStatus) -> String {
match status {
BucketStatus::Fresh => colors.paint(colors.green, "FRESH"),
BucketStatus::Stale => colors.paint(colors.yellow, "STALE"),
BucketStatus::Dead => colors.paint(colors.red, "DEAD"),
BucketStatus::Empty => colors.paint(colors.red, "EMPTY"),
BucketStatus::EventDriven => colors.paint(colors.cyan, "EVENT-DRIVEN"),
BucketStatus::Inactive => colors.paint(colors.cyan, "INACTIVE"),
BucketStatus::Unknown => colors.paint(colors.red, "?"),
}
}
fn get_latest_event(server: &str, bucket: &str, timeout_seconds: u64) -> Result<Option<Value>> {
let url = format!("{server}/api/0/buckets/{bucket}/events?limit=1");
let client = Client::builder()
.timeout(Duration::from_secs(timeout_seconds.max(1)))
.no_proxy()
.pool_max_idle_per_host(0)
.build()
.context("build timed HTTP client")?;
let value = client
.get(&url)
.header("Connection", "close")
.send()
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("GET {url} status"))?
.json::<Value>()
.with_context(|| format!("decode JSON from {url}"))?;
Ok(value.as_array().and_then(|items| items.first()).cloned())
}
fn metadata_event(buckets_index: Option<&Value>, bucket: &str) -> Option<Value> {
let bucket_info = buckets_index?.get(bucket)?;
let timestamp = bucket_info
.pointer("/metadata/end")
.or_else(|| bucket_info.get("end"))
.and_then(Value::as_str)?;
Some(serde_json::json!({
"id": 0,
"timestamp": timestamp,
"data": {},
"_source": "bucket_metadata",
}))
}
fn get_json(client: &Client, url: &str, _timeout_seconds: u64) -> Result<Value> {
client
.get(url)
.send()
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("GET {url} status"))?
.json::<Value>()
.with_context(|| format!("decode JSON from {url}"))
}
fn check_cors(_client: &Client, server: &str) -> u16 {
let origin = "http://192.0.2.13:5600";
let first = curl_status(&format!("{server}/api/0/settings/"), origin);
if first == 200 {
return first;
}
if !server.contains("127.0.0.1") && !server.contains("localhost") {
let fallback = curl_status("http://127.0.0.1:5600/api/0/settings/", origin);
if fallback != 0 {
return fallback;
}
}
first
}
fn curl_status(url: &str, origin: &str) -> u16 {
let origin_header = format!("Origin: {origin}");
let args = [
"-s",
"--connect-timeout",
"3",
"--max-time",
"5",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-H",
origin_header.as_str(),
url,
];
let output = Command::new("/usr/bin/curl")
.args(args)
.output()
.or_else(|_| Command::new("curl").args(args).output());
let Ok(output) = output else {
return 0;
};
if !output.status.success() {
return 0;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u16>()
.unwrap_or(0)
}
fn event_timestamp(event: &Value) -> Option<DateTime<Utc>> {
event
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_ts)
}
fn parse_ts(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00"))
.map(|ts| ts.with_timezone(&Utc))
.ok()
}
fn format_age(age_sec: i64) -> String {
if age_sec < 3_600 {
format!("{}m ago", age_sec / 60)
} else if age_sec < 86_400 {
format!("{}h ago", age_sec / 3_600)
} else {
format!("{}d ago", age_sec / 86_400)
}
}
fn json_value_to_string(value: &Value) -> String {
match value {
Value::String(value) => value.clone(),
other => other.to_string(),
}
}
fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_driven_buckets_do_not_become_dead_when_old() {
let context = ContextState::default();
assert_eq!(
classify_bucket_age("aw-dlp-incidents", 100_000, &context),
BucketStatus::EventDriven
);
}
#[test]
fn inactive_host_suppresses_window_stale() {
let context = ContextState {
host_inactive: true,
guard_healthy: true,
};
assert_eq!(
classify_bucket_age("aw-watcher-window", 100_000, &context),
BucketStatus::Inactive
);
assert_eq!(
classify_bucket_no_events("aw-dlp-endpoint-signals", &context),
BucketStatus::Inactive
);
}
#[test]
fn formats_age_like_legacy_script() {
assert_eq!(format_age(59), "0m ago");
assert_eq!(format_age(3_600), "1h ago");
assert_eq!(format_age(86_400), "1d ago");
}
#[test]
fn metadata_only_bucket_event_skips_missing_deep_event_read() {
let index = serde_json::json!({
"aw-watcher-window_HOST-EXAMPLE": {
"metadata": {
"end": "2026-06-02T00:00:00Z"
}
}
});
let event = bucket_event(
"http://127.0.0.1:1",
"aw-watcher-window_HOST-EXAMPLE",
Some(&index),
false,
1,
)
.expect("metadata event");
assert_eq!(
event.get("timestamp").and_then(Value::as_str),
Some("2026-06-02T00:00:00Z")
);
assert_eq!(event.get("id").and_then(Value::as_i64), Some(0));
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "check-aw-full"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
serde_json.workspace = true
-608
View File
@@ -1,608 +0,0 @@
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use clap::Parser;
use reqwest::blocking::Client;
use serde_json::Value;
const DEFAULT_SERVER: &str = "http://10.10.10.13:5600";
const DEFAULT_HOST: &str = "SHARKON2025";
const DEFAULT_RDP_HOST: &str = "192.168.100.19";
const BUCKETS: &[(&str, &str)] = &[
("aw-watcher-afk", "AFK watcher"),
("aw-watcher-window", "Window watcher"),
("aw-worktime-sessions", "Worktime sessions"),
("aw-session-events", "Session events"),
("aw-dlp-endpoint-signals", "DLP signals"),
("aw-dlp-incidents", "DLP incidents"),
("aw-dlp-review", "DLP review"),
("aw-dlp-rules", "DLP rules"),
];
#[derive(Debug, Parser)]
#[command(about = "Full read-only ActivityWatch check for server, buckets, and RDP host")]
struct Cli {
#[arg(long, default_value = DEFAULT_SERVER)]
server: String,
#[arg(long, default_value = DEFAULT_HOST)]
host: String,
#[arg(long, default_value = DEFAULT_RDP_HOST)]
rdp_host: String,
#[arg(long, default_value_t = 15)]
timeout_seconds: u64,
#[arg(long)]
no_color: bool,
#[arg(long, default_value_t = true)]
dlp_enabled: bool,
}
#[derive(Debug, Clone)]
struct Colors {
red: &'static str,
green: &'static str,
yellow: &'static str,
cyan: &'static str,
reset: &'static str,
}
impl Colors {
fn new(enabled: bool) -> Self {
if enabled {
Self {
red: "\u{1b}[0;31m",
green: "\u{1b}[0;32m",
yellow: "\u{1b}[1;33m",
cyan: "\u{1b}[0;36m",
reset: "\u{1b}[0m",
}
} else {
Self {
red: "",
green: "",
yellow: "",
cyan: "",
reset: "",
}
}
}
fn paint(&self, color: &str, text: &str) -> String {
format!("{color}{text}{}", self.reset)
}
}
#[derive(Debug, Default)]
struct ContextState {
host_inactive: bool,
guard_healthy: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BucketStatus {
Fresh,
Stale,
Dead,
Empty,
EventDriven,
Inactive,
Unknown,
}
#[derive(Debug, Clone)]
struct BucketRow {
label: &'static str,
last_id: String,
age: String,
status: BucketStatus,
}
#[derive(Debug, Default)]
struct Summary {
fresh: usize,
stale: usize,
dead: usize,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let mut cli = Cli::parse();
if cli.server == DEFAULT_SERVER {
if let Some(value) = env_nonempty("CHECK_AW_FULL_SERVER")
.or_else(|| env_nonempty("AW_SMOKE_AW_SERVER"))
.or_else(|| env_nonempty("AW_SERVER"))
{
cli.server = value;
}
}
if cli.host == DEFAULT_HOST {
if let Some(value) = env_nonempty("CHECK_AW_FULL_HOST")
.or_else(|| env_nonempty("AW_SMOKE_SOURCE_HOSTNAME"))
.or_else(|| env_nonempty("AW_LOGICAL_HOST_ID"))
.or_else(|| env_nonempty("AW_MONITORED_WINDOWS_HOSTNAME"))
{
cli.host = value;
}
}
if cli.rdp_host == DEFAULT_RDP_HOST {
if let Some(value) = env_nonempty("CHECK_AW_FULL_RDP_HOST")
.or_else(|| env_nonempty("AW_SMOKE_WINDOWS_HOST"))
.or_else(|| env_nonempty("AW_WINDOWS_HOST"))
{
cli.rdp_host = value;
}
}
if let Some(value) =
env_nonempty("AW_DLP_ENABLED").or_else(|| env_nonempty("DETMIR_DLP_ENABLED"))
{
cli.dlp_enabled = parse_env_flag(&value);
}
let server = cli.server.trim_end_matches('/').to_string();
let colors = Colors::new(!cli.no_color && std::env::var_os("NO_COLOR").is_none());
let timeout = Duration::from_secs(cli.timeout_seconds.max(1));
let client = Client::builder()
.timeout(timeout)
.no_proxy()
.pool_max_idle_per_host(0)
.build()
.context("build HTTP client")?;
let now = Utc::now();
println!(
"{}",
colors.paint(
colors.cyan,
&format!("=== ActivityWatch Full Check: {} ===", cli.host)
)
);
println!();
println!(
"{}",
colors.paint(colors.cyan, &format!("--- 1. AW Server ({server}) ---"))
);
print!(" Connectivity... ");
let info = match get_json(&client, &format!("{server}/api/0/info")) {
Ok(value) => value,
Err(_) => {
println!(" {}", colors.paint(colors.red, "FAILED"));
return Ok(1);
}
};
let Some(version) = info.get("version").and_then(Value::as_str) else {
println!(" {}", colors.paint(colors.red, "FAILED"));
return Ok(1);
};
println!(
" {} (aw-server {version})",
colors.paint(colors.green, "OK")
);
print!(" CORS... ");
let cors_code = check_cors(&client, &server);
if cors_code == 200 {
println!("{}", colors.paint(colors.green, "OK"));
} else {
println!("{} (HTTP {cors_code})", colors.paint(colors.red, "FAIL"));
}
println!();
let context = read_context(&client, &server, &cli.host, now);
println!("{}", colors.paint(colors.cyan, "--- 2. Data Buckets ---"));
println!(
" {:<42} {:<8} {:<20} STATUS",
"BUCKET", "EVENTS", "LAST EVENT"
);
println!(
" {:<42} {:<8} {:<20} ------",
"------------------------------------------", "--------", "--------------------"
);
let mut rows = Vec::new();
for (bucket, label) in BUCKETS
.iter()
.copied()
.filter(|(bucket, _)| cli.dlp_enabled || !bucket.starts_with("aw-dlp-"))
{
let row = read_bucket_row(&client, &server, &cli.host, bucket, label, now, &context);
println!(
" {:<42} {:<8} {:<20} {}",
row.label,
row.last_id,
row.age,
render_status(&colors, row.status)
);
rows.push(row);
}
if !cli.dlp_enabled {
println!(
" {:<42} {:<8} {:<20} {}",
"DLP buckets",
"-",
"disabled",
colors.paint(colors.cyan, "SKIPPED")
);
}
println!();
println!(
"{}",
colors.paint(
colors.cyan,
&format!("--- 3. RDP Host ({}) ---", cli.rdp_host)
)
);
print!(" WinRM (5985)... ");
if tcp_open(&cli.rdp_host, 5985, Duration::from_secs(5)) {
println!("{}", colors.paint(colors.green, "OK"));
} else {
println!("{}", colors.paint(colors.red, "UNREACHABLE"));
}
print!(" SSH (22)... ");
if tcp_open(&cli.rdp_host, 22, Duration::from_secs(5)) {
println!("{}", colors.paint(colors.green, "OK"));
} else {
println!(
"{} (normal for Windows)",
colors.paint(colors.yellow, "CLOSED")
);
}
println!();
let summary = summarize(&rows);
println!("{}", colors.paint(colors.cyan, "--- 4. Summary ---"));
println!(
" FRESH: {}",
colors.paint(colors.green, &summary.fresh.to_string())
);
println!(
" STALE: {}",
colors.paint(colors.yellow, &summary.stale.to_string())
);
println!(
" DEAD: {}",
colors.paint(colors.red, &summary.dead.to_string())
);
if summary.dead > 0 || summary.stale > 0 {
println!();
println!(
" {} Some collectors may need restart on RDP host",
colors.paint(colors.red, "WARNING:")
);
println!(
" Run: {}",
colors.paint(
colors.cyan,
"ansible -i ansible/inventory.ini rdp-prod -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Recovery\"'"
)
);
}
println!();
println!("{}", colors.paint(colors.cyan, "=== Check Complete ==="));
println!(" Timestamp: {}", now.format("%Y-%m-%d %H:%M:%S UTC"));
Ok(0)
}
fn read_context(client: &Client, server: &str, host: &str, now: DateTime<Utc>) -> ContextState {
let mut state = ContextState::default();
if let Ok(Some(event)) = latest_event(client, server, &format!("aw-worktime-sessions_{host}")) {
if let Some(ts) = event_timestamp(&event) {
let age = (now - ts).num_seconds();
let active = event
.pointer("/data/active")
.and_then(Value::as_bool)
.unwrap_or(false);
if (0..900).contains(&age) && !active {
state.host_inactive = true;
}
}
}
if let Ok(Some(event)) = latest_event(client, server, &format!("aw-rus-collector-guard_{host}"))
{
if let Some(ts) = event_timestamp(&event) {
let age = (now - ts).num_seconds();
let status = event
.pointer("/data/status")
.and_then(Value::as_str)
.unwrap_or("");
let problems = event
.pointer("/data/problems")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
if (0..300).contains(&age) && status == "ok" && problems == 0 {
state.guard_healthy = true;
}
}
}
state
}
fn read_bucket_row(
client: &Client,
server: &str,
host: &str,
bucket: &str,
label: &'static str,
now: DateTime<Utc>,
context: &ContextState,
) -> BucketRow {
let bucket_full = format!("{bucket}_{host}");
let event = latest_event(client, server, &bucket_full).ok().flatten();
let Some(event) = event else {
return BucketRow {
label,
last_id: "0".to_string(),
age: "none".to_string(),
status: classify_bucket_no_events(bucket, context),
};
};
let last_id = event
.get("id")
.map(json_value_to_string)
.unwrap_or_else(|| "0".to_string());
let Some(ts_raw) = event.get("timestamp").and_then(Value::as_str) else {
return BucketRow {
label,
last_id,
age: "?".to_string(),
status: BucketStatus::Unknown,
};
};
let Some(ts) = parse_ts(ts_raw) else {
return BucketRow {
label,
last_id,
age: "?".to_string(),
status: BucketStatus::Unknown,
};
};
let effective_ts = if bucket == "aw-watcher-afk" {
bucket_metadata_end(client, server, &bucket_full).unwrap_or(ts)
} else {
ts
};
let age_sec = (now - effective_ts).num_seconds().max(0);
BucketRow {
label,
last_id,
age: format_age(age_sec),
status: classify_bucket_age(bucket, age_sec, context),
}
}
fn classify_bucket_age(bucket: &str, age_sec: i64, context: &ContextState) -> BucketStatus {
match bucket {
"aw-watcher-window" if context.host_inactive => return BucketStatus::Inactive,
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
return BucketStatus::Inactive;
}
_ => {}
}
match bucket {
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
if age_sec < 86_400 {
BucketStatus::Fresh
} else {
BucketStatus::EventDriven
}
}
_ if age_sec < 3_600 => BucketStatus::Fresh,
_ if age_sec < 86_400 => BucketStatus::Stale,
_ => BucketStatus::Dead,
}
}
fn classify_bucket_no_events(bucket: &str, context: &ContextState) -> BucketStatus {
match bucket {
"aw-watcher-window" if context.host_inactive => BucketStatus::Inactive,
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
BucketStatus::Inactive
}
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
BucketStatus::EventDriven
}
_ => BucketStatus::Empty,
}
}
fn summarize(rows: &[BucketRow]) -> Summary {
let mut summary = Summary::default();
for row in rows {
match row.status {
BucketStatus::Fresh | BucketStatus::EventDriven | BucketStatus::Inactive => {
summary.fresh += 1
}
BucketStatus::Stale => summary.stale += 1,
BucketStatus::Dead | BucketStatus::Empty | BucketStatus::Unknown => summary.dead += 1,
}
}
summary
}
fn render_status(colors: &Colors, status: BucketStatus) -> String {
match status {
BucketStatus::Fresh => colors.paint(colors.green, "FRESH"),
BucketStatus::Stale => colors.paint(colors.yellow, "STALE"),
BucketStatus::Dead => colors.paint(colors.red, "DEAD"),
BucketStatus::Empty => colors.paint(colors.red, "EMPTY"),
BucketStatus::EventDriven => colors.paint(colors.cyan, "EVENT-DRIVEN"),
BucketStatus::Inactive => colors.paint(colors.cyan, "INACTIVE"),
BucketStatus::Unknown => colors.paint(colors.red, "?"),
}
}
fn latest_event(client: &Client, server: &str, bucket: &str) -> Result<Option<Value>> {
let url = format!("{server}/api/0/buckets/{bucket}/events?limit=1");
let value = get_json(client, &url)?;
Ok(value.as_array().and_then(|items| items.first()).cloned())
}
fn bucket_metadata_end(client: &Client, server: &str, bucket: &str) -> Option<DateTime<Utc>> {
let url = format!("{server}/api/0/buckets/{bucket}");
let value = get_json(client, &url).ok()?;
value
.pointer("/metadata/end")
.and_then(Value::as_str)
.and_then(parse_ts)
}
fn get_json(client: &Client, url: &str) -> Result<Value> {
client
.get(url)
.header("Connection", "close")
.send()
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("GET {url} status"))?
.json::<Value>()
.with_context(|| format!("decode JSON from {url}"))
}
fn check_cors(client: &Client, server: &str) -> u16 {
let url = format!("{server}/api/0/settings/");
client
.get(&url)
.header("Origin", server)
.send()
.map(|response| response.status().as_u16())
.unwrap_or(0)
}
fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
let Ok(mut addrs) = (host, port).to_socket_addrs() else {
return false;
};
addrs.any(|addr| TcpStream::connect_timeout(&addr, timeout).is_ok())
}
fn event_timestamp(event: &Value) -> Option<DateTime<Utc>> {
event
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_ts)
}
fn parse_ts(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00"))
.map(|ts| ts.with_timezone(&Utc))
.ok()
}
fn format_age(age_sec: i64) -> String {
if age_sec < 3_600 {
format!("{}m", age_sec / 60)
} else if age_sec < 86_400 {
format!("{}h", age_sec / 3_600)
} else {
format!("{}d", age_sec / 86_400)
}
}
fn json_value_to_string(value: &Value) -> String {
match value {
Value::String(value) => value.clone(),
other => other.to_string(),
}
}
fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn parse_env_flag(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_driven_bucket_is_not_dead_when_old() {
assert_eq!(
classify_bucket_age("aw-session-events", 100_000, &ContextState::default()),
BucketStatus::EventDriven
);
}
#[test]
fn inactive_context_suppresses_expected_idle_buckets() {
let context = ContextState {
host_inactive: true,
guard_healthy: true,
};
assert_eq!(
classify_bucket_age("aw-watcher-window", 100_000, &context),
BucketStatus::Inactive
);
assert_eq!(
classify_bucket_no_events("aw-dlp-endpoint-signals", &context),
BucketStatus::Inactive
);
}
#[test]
fn summary_treats_event_and_inactive_as_fresh_class() {
let rows = vec![
BucketRow {
label: "a",
last_id: "0".to_string(),
age: "1m".to_string(),
status: BucketStatus::Fresh,
},
BucketRow {
label: "b",
last_id: "0".to_string(),
age: "none".to_string(),
status: BucketStatus::EventDriven,
},
BucketRow {
label: "c",
last_id: "0".to_string(),
age: "none".to_string(),
status: BucketStatus::Inactive,
},
BucketRow {
label: "d",
last_id: "0".to_string(),
age: "none".to_string(),
status: BucketStatus::Empty,
},
];
let summary = summarize(&rows);
assert_eq!(summary.fresh, 3);
assert_eq!(summary.stale, 0);
assert_eq!(summary.dead, 1);
}
#[test]
fn formats_age_like_legacy_full_check() {
assert_eq!(format_age(59), "0m");
assert_eq!(format_age(3_600), "1h");
assert_eq!(format_age(86_400), "1d");
}
}
@@ -1,17 +0,0 @@
[package]
name = "check-install-kit-vs-repo"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -1,284 +0,0 @@
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde::Serialize;
use sha2::{Digest, Sha256};
const DEFAULT_KIT_DIR: &str = "install-kit-awindows-20260427-211240";
const MANIFEST_NAME: &str = "MANIFEST.txt";
const ALLOWED_KIT_ONLY_FILES: &[&str] =
&["README-INSTALL-KIT.txt", "windows/aw-windows-telemetry.exe"];
const ALLOWED_KIT_ONLY_PREFIXES: &[&str] = &["server-configs-"];
#[derive(Debug, Parser)]
#[command(about = "Compare ActivityWatch-Russian install-kit contents against the repository")]
struct Cli {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = DEFAULT_KIT_DIR)]
kit_dir: PathBuf,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Default, Serialize)]
struct Report {
compared_files: usize,
missing_in_repo: Vec<String>,
mismatches: Vec<String>,
powershell_mismatches: Vec<String>,
}
impl Report {
fn is_ok(&self) -> bool {
self.missing_in_repo.is_empty() && self.mismatches.is_empty()
}
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let report = compare_install_kit(&cli.root, &cli.kit_dir)?;
print_report(&report, cli.json)?;
Ok(if report.is_ok() { 0 } else { 1 })
}
fn compare_install_kit(root: &Path, kit_dir_arg: &Path) -> Result<Report> {
let root = root
.canonicalize()
.with_context(|| format!("canonicalize root {}", root.display()))?;
let kit_dir = if kit_dir_arg.is_absolute() {
kit_dir_arg.to_path_buf()
} else {
root.join(kit_dir_arg)
};
if !kit_dir.exists() {
bail!("Install kit directory not found: {}", kit_dir.display());
}
if !kit_dir.is_dir() {
bail!("Install kit path is not a directory: {}", kit_dir.display());
}
let mut report = Report::default();
for kit_file in collect_files(&kit_dir)? {
let rel = kit_file
.strip_prefix(&kit_dir)
.with_context(|| format!("strip kit prefix from {}", kit_file.display()))?;
let rel_str = slash_path(rel);
if rel.file_name().and_then(|name| name.to_str()) == Some(MANIFEST_NAME) {
continue;
}
let repo_file = root.join(rel);
if !repo_file.exists() {
if is_allowed_kit_only(&rel_str) {
continue;
}
report.missing_in_repo.push(rel_str);
continue;
}
if !repo_file.is_file() {
report.missing_in_repo.push(rel_str);
continue;
}
report.compared_files += 1;
if sha256_file(&kit_file)? != sha256_file(&repo_file)? {
report.mismatches.push(rel_str);
}
}
report.missing_in_repo.sort();
report.mismatches.sort();
report.powershell_mismatches = report
.mismatches
.iter()
.filter(|path| is_powershell_path(path))
.cloned()
.collect();
Ok(report)
}
fn print_report(report: &Report, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(report)?);
return Ok(());
}
println!("Compared files: {}", report.compared_files);
println!("Missing in repo: {}", report.missing_in_repo.len());
println!("Mismatched content: {}", report.mismatches.len());
if !report.missing_in_repo.is_empty() {
println!("--- Missing in repo ---");
for path in &report.missing_in_repo {
println!("{path}");
}
}
if !report.mismatches.is_empty() {
println!("--- Mismatches ---");
for path in &report.mismatches {
println!("{path}");
}
}
println!(
"PowerShell mismatches: {}",
report.powershell_mismatches.len()
);
if !report.powershell_mismatches.is_empty() {
println!("--- PowerShell mismatches ---");
for path in &report.powershell_mismatches {
println!("{path}");
}
}
Ok(())
}
fn collect_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_files_inner(root, &mut out)?;
out.sort();
Ok(out)
}
fn collect_files_inner(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(path).with_context(|| format!("read dir {}", path.display()))? {
let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?;
let entry_path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("read file type {}", entry_path.display()))?;
if file_type.is_dir() {
collect_files_inner(&entry_path, out)?;
} else if (file_type.is_file() || file_type.is_symlink()) && entry_path.is_file() {
out.push(entry_path);
}
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let file = File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buf = [0_u8; 64 * 1024];
loop {
let read = reader
.read(&mut buf)
.with_context(|| format!("read {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn is_allowed_kit_only(rel: &str) -> bool {
ALLOWED_KIT_ONLY_FILES.contains(&rel)
|| ALLOWED_KIT_ONLY_PREFIXES
.iter()
.any(|prefix| rel.starts_with(prefix))
}
fn is_powershell_path(path: &str) -> bool {
path.starts_with("windows/")
&& (path.ends_with(".ps1") || path.ends_with(".psm1") || path.ends_with(".psd1"))
}
fn slash_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
#[allow(dead_code)]
fn sorted_set(paths: &[String]) -> BTreeSet<String> {
paths.iter().cloned().collect()
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::{compare_install_kit, sorted_set};
#[test]
fn reports_clean_tree() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let kit = root.join("install-kit-awindows-20260427-211240");
fs::create_dir_all(kit.join("windows")).unwrap();
fs::write(root.join("common.txt"), "same").unwrap();
fs::write(kit.join("common.txt"), "same").unwrap();
fs::create_dir_all(root.join("windows")).unwrap();
fs::write(root.join("windows/script.ps1"), "same").unwrap();
fs::write(kit.join("windows/script.ps1"), "same").unwrap();
fs::write(kit.join("README-INSTALL-KIT.txt"), "kit-only").unwrap();
fs::write(kit.join("MANIFEST.txt"), "ignored").unwrap();
let report = compare_install_kit(root, &kit).unwrap();
assert!(report.is_ok());
assert_eq!(report.compared_files, 2);
}
#[test]
fn reports_mismatches_and_powershell_subset() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let kit = root.join("install-kit-awindows-20260427-211240");
fs::create_dir_all(root.join("windows")).unwrap();
fs::create_dir_all(kit.join("windows")).unwrap();
fs::write(root.join("windows/script.ps1"), "repo").unwrap();
fs::write(kit.join("windows/script.ps1"), "kit").unwrap();
fs::write(root.join("plain.txt"), "repo").unwrap();
fs::write(kit.join("plain.txt"), "kit").unwrap();
let report = compare_install_kit(root, &kit).unwrap();
assert!(!report.is_ok());
assert_eq!(
sorted_set(&report.mismatches),
sorted_set(&["plain.txt".to_string(), "windows/script.ps1".to_string()])
);
assert_eq!(
report.powershell_mismatches,
vec!["windows/script.ps1".to_string()]
);
}
#[test]
fn reports_unexpected_kit_only_files() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let kit = root.join("install-kit-awindows-20260427-211240");
fs::create_dir_all(&kit).unwrap();
fs::write(kit.join("unexpected.txt"), "kit").unwrap();
fs::create_dir_all(kit.join("server-configs-198.51.100.18")).unwrap();
fs::write(
kit.join("server-configs-198.51.100.18/config.deployment-config.json"),
"{}",
)
.unwrap();
let report = compare_install_kit(root, &kit).unwrap();
assert_eq!(report.missing_in_repo, vec!["unexpected.txt"]);
}
}
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "detmir-auto"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
detmir-core.workspace = true
detmir-state.workspace = true
fs2.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
-661
View File
@@ -1,661 +0,0 @@
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use chrono::{SecondsFormat, Utc};
use clap::Parser;
use detmir_core::exit_codes;
use detmir_state::write_json_atomic;
use fs2::FileExt;
use serde::Serialize;
use serde_json::Value;
const DEFAULT_STATE_DIR: &str = "/var/lib/detmir-ai";
const DEFAULT_CHECK_BIN: &str = "detmir-check";
const DEFAULT_DLP_BIN: &str = "detmir-dlp";
const DEFAULT_HEAL_BIN: &str = "detmir-heal-safe-rust";
const DEFAULT_POLLI_BIN: &str = "polli-chat";
#[derive(Debug, Parser)]
#[command(about = "DetMir autonomous check/report orchestration.")]
struct Cli {
#[arg(long, default_value = DEFAULT_STATE_DIR)]
state_dir: PathBuf,
#[arg(long)]
lock_dir: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_CHECK_BIN)]
check_bin: String,
#[arg(long, default_value = DEFAULT_DLP_BIN)]
dlp_bin: String,
#[arg(long, default_value = DEFAULT_HEAL_BIN)]
heal_bin: String,
#[arg(long, default_value = DEFAULT_POLLI_BIN)]
polli_bin: String,
#[arg(long, default_value_t = 14)]
retain_days: u64,
#[arg(long, default_value_t = 120)]
command_timeout_seconds: u64,
#[arg(long, default_value_t = 120)]
report_timeout_seconds: u64,
#[arg(long)]
no_report: bool,
#[arg(long)]
enable_heal: bool,
#[arg(long)]
no_heal: bool,
}
impl Cli {
fn apply_env(mut self) -> Self {
self.state_dir = env_path("DETMIR_AI_STATE_DIR").unwrap_or(self.state_dir);
self.lock_dir = env_path("DETMIR_AI_RUN_DIR").or(self.lock_dir);
self.check_bin = env_string("DETMIR_CHECK_BIN").unwrap_or(self.check_bin);
self.dlp_bin = env_string("DETMIR_DLP_BIN").unwrap_or(self.dlp_bin);
self.heal_bin = env_string("DETMIR_HEAL_BIN").unwrap_or(self.heal_bin);
self.polli_bin = env_string("DETMIR_POLLI_BIN").unwrap_or(self.polli_bin);
self.retain_days = env_string("DETMIR_AI_RETAIN_DAYS")
.and_then(|value| value.parse().ok())
.unwrap_or(self.retain_days);
self.report_timeout_seconds = env_string("DETMIR_REPORT_TIMEOUT_SECONDS")
.and_then(|value| value.parse().ok())
.unwrap_or(self.report_timeout_seconds);
if env_string("DETMIR_AUTO_HEAL").is_some_and(|value| value == "1") {
self.enable_heal = true;
}
if self.no_heal {
self.enable_heal = false;
}
self
}
fn lock_dir(&self) -> PathBuf {
self.lock_dir
.clone()
.unwrap_or_else(|| self.state_dir.join("locks"))
}
}
#[derive(Debug)]
struct RunPaths {
state_dir: PathBuf,
run_dir: PathBuf,
reports_dir: PathBuf,
check_file: PathBuf,
dlp_file: PathBuf,
check_rc_file: PathBuf,
dlp_rc_file: PathBuf,
heal_rc_file: PathBuf,
heal_log: PathBuf,
bundle_file: PathBuf,
report_file: PathBuf,
state_file: PathBuf,
}
#[derive(Debug, Serialize)]
struct AutoSummary {
check_rc: i32,
dlp_rc: i32,
check_ok: bool,
dlp_ok: bool,
severity: String,
needs_heal: bool,
reasons: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
detmir_summary: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
dlp_counts: Option<Value>,
}
fn env_string(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn env_path(name: &str) -> Option<PathBuf> {
env_string(name).map(PathBuf::from)
}
fn utc_stamp() -> String {
Utc::now().format("%Y%m%d-%H%M%S").to_string()
}
fn utc_iso() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn init_paths(state_dir: PathBuf) -> Result<RunPaths> {
let stamp = utc_stamp();
let run_dir = state_dir.join("runs").join(&stamp);
let reports_dir = state_dir.join("reports");
fs::create_dir_all(&run_dir)?;
fs::create_dir_all(&reports_dir)?;
fs::create_dir_all(state_dir.join("logs"))?;
Ok(RunPaths {
state_dir: state_dir.clone(),
run_dir: run_dir.clone(),
reports_dir: reports_dir.clone(),
check_file: run_dir.join("detmir-check.json"),
dlp_file: run_dir.join("detmir-dlp.json"),
check_rc_file: run_dir.join("check.rc"),
dlp_rc_file: run_dir.join("dlp.rc"),
heal_rc_file: run_dir.join("heal.rc"),
heal_log: run_dir.join("heal.log"),
bundle_file: run_dir.join("bundle.txt"),
report_file: reports_dir.join(format!("detmir-report-{stamp}.md")),
state_file: state_dir.join(format!("state-{stamp}.json")),
})
}
fn acquire_lock(lock_dir: &Path) -> Result<Option<File>> {
fs::create_dir_all(lock_dir)?;
let lock_path = lock_dir.join("detmir-auto.lock");
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("failed to open lock {}", lock_path.display()))?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(file)),
Err(err) => {
if err.kind() == std::io::ErrorKind::WouldBlock {
println!("detmir-auto: another run is active");
Ok(None)
} else {
Err(err).with_context(|| format!("failed to lock {}", lock_path.display()))
}
}
}
}
fn run_to_file(
command: &str,
args: &[&str],
output_path: &Path,
rc_path: &Path,
timeout: Duration,
) -> Result<i32> {
let stdout = File::create(output_path)
.with_context(|| format!("failed to create {}", output_path.display()))?;
let stderr_path = output_path.with_extension("stderr");
let stderr = File::create(&stderr_path)
.with_context(|| format!("failed to create {}", stderr_path.display()))?;
let mut child = Command::new(command)
.args(args)
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
.with_context(|| format!("failed to execute {command}"))?;
let started = std::time::Instant::now();
let rc = loop {
if let Some(status) = child.try_wait()? {
break status.code().unwrap_or(1);
}
if started.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
let mut stderr = OpenOptions::new().append(true).open(&stderr_path)?;
writeln!(
stderr,
"{command} timed out after {} seconds",
timeout.as_secs()
)?;
break 124;
}
std::thread::sleep(Duration::from_millis(200));
};
if fs::metadata(&stderr_path)
.map(|meta| meta.len())
.unwrap_or(0)
== 0
{
let _ = fs::remove_file(&stderr_path);
}
fs::write(rc_path, format!("{rc}\n"))?;
Ok(rc)
}
fn read_rc(path: &Path) -> i32 {
fs::read_to_string(path)
.ok()
.and_then(|text| text.trim().parse().ok())
.unwrap_or(1)
}
fn summarize(paths: &RunPaths) -> AutoSummary {
let check_rc = read_rc(&paths.check_rc_file);
let dlp_rc = read_rc(&paths.dlp_rc_file);
let mut summary = AutoSummary {
check_rc,
dlp_rc,
check_ok: false,
dlp_ok: false,
severity: if check_rc != 0 || dlp_rc != 0 {
"FAIL".to_string()
} else {
"OK".to_string()
},
needs_heal: check_rc != 0 || dlp_rc != 0,
reasons: Vec::new(),
detmir_summary: None,
dlp_counts: None,
};
match read_json(&paths.check_file) {
Ok(check) => {
summary.check_ok = check.get("ok").and_then(Value::as_bool).unwrap_or(false);
let check_summary = check.get("summary").cloned().unwrap_or(Value::Null);
if check_summary.is_object() {
if !summary.check_ok
&& (int_field(&check_summary, "bucket_dead") > 0
|| int_field(&check_summary, "bucket_stale") > 0
|| int_field(&check_summary, "service_failures") > 0)
{
summary.reasons.push(
"detmir-check has stale/dead bucket or required service failure"
.to_string(),
);
}
summary.detmir_summary = Some(check_summary);
}
}
Err(err) => summary
.reasons
.push(format!("detmir-check parse failed: {err}")),
}
match read_json(&paths.dlp_file) {
Ok(dlp) => {
summary.dlp_ok = dlp.get("ok").and_then(Value::as_bool).unwrap_or(false);
let counts = dlp.get("counts").cloned().unwrap_or(Value::Null);
if counts.is_object() {
if !summary.dlp_ok
&& (int_field(&counts, "fail") > 0 || int_field(&counts, "warn") > 0)
{
summary
.reasons
.push("dlp-health-check has warn/fail".to_string());
}
summary.dlp_counts = Some(counts);
}
}
Err(err) => summary
.reasons
.push(format!("detmir-dlp parse failed: {err}")),
}
if summary.check_ok && summary.dlp_ok {
summary.severity = "OK".to_string();
summary.needs_heal = false;
} else if summary.reasons.is_empty() {
summary.severity = "WARN".to_string();
} else {
summary.severity = "FAIL".to_string();
}
summary
}
fn read_json(path: &Path) -> Result<Value> {
let raw = fs::read_to_string(path)?;
Ok(serde_json::from_str(&raw)?)
}
fn int_field(value: &Value, key: &str) -> i64 {
value.get(key).and_then(Value::as_i64).unwrap_or(0)
}
fn write_bundle(paths: &RunPaths, summary_after: &AutoSummary) -> Result<()> {
let mut out = String::new();
out.push_str(
"Ты операторский AI-помощник DetMir. По фактам ниже дай короткий русский отчет.\n",
);
out.push_str("Структура ответа:\n");
out.push_str("1. Состояние: OK/WARN/FAIL\n");
out.push_str("2. Что важно\n");
out.push_str("3. Что уже сделал автомат\n");
out.push_str("4. Что сделать человеку, если нужно\n\n");
out.push_str("Правила:\n");
out.push_str("- Не предлагай рестарты, если факты чистые.\n");
out.push_str("- Отличай event-driven bucket от dead/stale.\n");
out.push_str("- DLP sendFailures важны только при новом sendFailuresDelta или warn/fail.\n");
out.push_str(
"- Auto-heal умеет только серверные systemd-сервисы AW/DLP; Windows/RDP не трогает.\n\n",
);
out.push_str("=== summary-before ===\n");
out.push_str(
&fs::read_to_string(paths.run_dir.join("summary-before.json")).unwrap_or_default(),
);
out.push_str("\n\n=== summary-after ===\n");
out.push_str(&serde_json::to_string_pretty(summary_after)?);
out.push_str("\n\n=== heal-log ===\n");
out.push_str(&fs::read_to_string(&paths.heal_log).unwrap_or_default());
out.push_str("\n\n=== detmir-check ===\n");
out.push_str(&truncate_file(&paths.check_file, 1600));
out.push_str("\n\n=== detmir-dlp ===\n");
out.push_str(&truncate_file(&paths.dlp_file, 1600));
fs::write(&paths.bundle_file, out)?;
Ok(())
}
fn truncate_file(path: &Path, max_lines: usize) -> String {
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.take(max_lines)
.collect::<Vec<_>>()
.join("\n")
}
fn write_report(
paths: &RunPaths,
polli_bin: &str,
no_report: bool,
report_timeout: Duration,
summary: &AutoSummary,
) -> Result<()> {
let mut report = format!(
"# DetMir Autonomous Report\n\n- generated_at_utc: {}\n- run_dir: {}\n\n",
utc_iso(),
paths.run_dir.display()
);
if no_report {
report.push_str("Pollinations report skipped; raw summary follows.\n");
report.push_str(&serde_json::to_string_pretty(summary)?);
} else {
let bundle = File::open(&paths.bundle_file)?;
match run_report_command(polli_bin, bundle, report_timeout) {
Ok(output) if output.status.success() => {
report.push_str(&String::from_utf8_lossy(&output.stdout));
}
Ok(output) => {
report.push_str("Pollinations report failed; raw summary follows.\n");
if !output.stderr.is_empty() {
report.push_str(&String::from_utf8_lossy(&output.stderr));
report.push('\n');
}
report.push_str(&serde_json::to_string_pretty(summary)?);
}
Err(err) => {
report.push_str("Pollinations report failed; raw summary follows.\n");
report.push_str(&format!("{err}\n"));
report.push_str(&serde_json::to_string_pretty(summary)?);
}
}
}
fs::write(&paths.report_file, report)?;
Ok(())
}
fn run_report_command(polli_bin: &str, bundle: File, timeout: Duration) -> Result<Output> {
let mut child = Command::new(polli_bin)
.args(["--model", "text.daily", "--max-tokens", "900"])
.stdin(Stdio::from(bundle))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to execute {polli_bin}"))?;
let started = std::time::Instant::now();
loop {
if child.try_wait()?.is_some() {
return child
.wait_with_output()
.with_context(|| format!("failed to collect {polli_bin} output"));
}
if started.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"Pollinations report timed out after {} seconds",
timeout.as_secs()
);
}
std::thread::sleep(Duration::from_millis(200));
}
}
fn update_latest(paths: &RunPaths) -> Result<()> {
update_symlink(&paths.run_dir, &paths.state_dir.join("latest-run"))?;
update_symlink(
&paths.report_file,
&paths.state_dir.join("latest-report.md"),
)?;
update_symlink(
&paths.state_file,
&paths.state_dir.join("latest-state.json"),
)?;
Ok(())
}
fn update_symlink(target: &Path, link: &Path) -> Result<()> {
let tmp = link.with_extension(format!("tmp.{}", std::process::id()));
let _ = fs::remove_file(&tmp);
symlink(target, &tmp)?;
fs::rename(&tmp, link)
.with_context(|| format!("failed to update symlink {}", link.display()))?;
Ok(())
}
fn cleanup_retention(paths: &RunPaths, retain_days: u64) -> Result<()> {
let cutoff = SystemTime::now()
.checked_sub(Duration::from_secs(retain_days * 24 * 60 * 60))
.unwrap_or(SystemTime::UNIX_EPOCH);
cleanup_old_dirs(&paths.state_dir.join("runs"), cutoff)?;
cleanup_old_files(&paths.reports_dir, "detmir-report-", Some(".md"), cutoff)?;
cleanup_old_files(&paths.state_dir, "state-", Some(".json"), cutoff)?;
Ok(())
}
fn cleanup_old_dirs(dir: &Path, cutoff: SystemTime) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() && is_old(&entry.path(), cutoff) {
fs::remove_dir_all(entry.path())?;
}
}
Ok(())
}
fn cleanup_old_files(
dir: &Path,
prefix: &str,
suffix: Option<&str>,
cutoff: SystemTime,
) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
let suffix_ok = suffix.is_none_or(|suffix| name.ends_with(suffix));
if entry.file_type()?.is_file()
&& name.starts_with(prefix)
&& suffix_ok
&& is_old(&entry.path(), cutoff)
{
fs::remove_file(entry.path())?;
}
}
Ok(())
}
fn is_old(path: &Path, cutoff: SystemTime) -> bool {
path.metadata()
.and_then(|meta| meta.modified())
.map(|modified| modified < cutoff)
.unwrap_or(false)
}
fn main() -> Result<()> {
let args = Cli::parse().apply_env();
let Some(_lock) = acquire_lock(&args.lock_dir())? else {
return Ok(());
};
let paths = init_paths(args.state_dir.clone())?;
let command_timeout = Duration::from_secs(args.command_timeout_seconds);
let report_timeout = Duration::from_secs(args.report_timeout_seconds);
run_to_file(
&args.check_bin,
&["--json"],
&paths.check_file,
&paths.check_rc_file,
command_timeout,
)?;
run_to_file(
&args.dlp_bin,
&[],
&paths.dlp_file,
&paths.dlp_rc_file,
command_timeout,
)?;
let summary_before = summarize(&paths);
write_json_atomic(paths.run_dir.join("summary-before.json"), &summary_before)?;
if summary_before.needs_heal && args.enable_heal {
fs::write(
&paths.heal_log,
format!("auto-heal started at {}\n", utc_iso()),
)?;
let heal_rc = run_to_file(
&args.heal_bin,
&["--apply", "--json"],
&paths.heal_log,
&paths.heal_rc_file,
command_timeout,
)?;
let mut heal_log = OpenOptions::new().append(true).open(&paths.heal_log)?;
writeln!(
heal_log,
"\nauto-heal finished at {} rc={heal_rc}",
utc_iso()
)?;
std::thread::sleep(Duration::from_secs(10));
run_to_file(
&args.check_bin,
&["--json"],
&paths.check_file,
&paths.check_rc_file,
command_timeout,
)?;
run_to_file(
&args.dlp_bin,
&[],
&paths.dlp_file,
&paths.dlp_rc_file,
command_timeout,
)?;
} else {
fs::write(
&paths.heal_log,
if summary_before.needs_heal {
"auto-heal skipped (disabled)\n"
} else {
"auto-heal skipped\n"
},
)?;
fs::write(&paths.heal_rc_file, "0\n")?;
}
let summary_after = summarize(&paths);
write_json_atomic(&paths.state_file, &summary_after)?;
write_bundle(&paths, &summary_after)?;
write_report(
&paths,
&args.polli_bin,
args.no_report,
report_timeout,
&summary_after,
)?;
update_latest(&paths)?;
cleanup_retention(&paths, args.retain_days)?;
print!("{}", fs::read_to_string(&paths.report_file)?);
std::io::stdout().flush().ok();
let final_check_rc = read_rc(&paths.check_rc_file);
let final_dlp_rc = read_rc(&paths.dlp_rc_file);
std::process::exit(
if summary_after.severity == "OK" && final_check_rc == 0 && final_dlp_rc == 0 {
exit_codes::OK
} else {
exit_codes::CHECK_FAILED
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summarizes_clean_inputs() {
let dir = tempfile::tempdir().unwrap();
let paths = init_paths(dir.path().to_path_buf()).unwrap();
fs::write(
&paths.check_file,
r#"{"ok": true, "summary": {"bucket_ok": 8, "bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0}}"#,
)
.unwrap();
fs::write(
&paths.dlp_file,
r#"{"ok": true, "counts": {"ok": 22, "warn": 0, "fail": 0}}"#,
)
.unwrap();
fs::write(&paths.check_rc_file, "0\n").unwrap();
fs::write(&paths.dlp_rc_file, "0\n").unwrap();
let summary = summarize(&paths);
assert_eq!(summary.severity, "OK");
assert!(!summary.needs_heal);
assert!(summary.reasons.is_empty());
}
#[test]
fn does_not_keep_reasons_when_child_reports_ok() {
let dir = tempfile::tempdir().unwrap();
let paths = init_paths(dir.path().to_path_buf()).unwrap();
fs::write(
&paths.check_file,
r#"{"ok": true, "summary": {"bucket_ok": 8, "bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0}}"#,
)
.unwrap();
fs::write(
&paths.dlp_file,
r#"{"ok": true, "counts": {"ok": 21, "warn": 1, "fail": 0}}"#,
)
.unwrap();
fs::write(&paths.check_rc_file, "0\n").unwrap();
fs::write(&paths.dlp_rc_file, "0\n").unwrap();
let summary = summarize(&paths);
assert_eq!(summary.severity, "OK");
assert!(!summary.needs_heal);
assert!(summary.reasons.is_empty());
}
}
@@ -1,15 +0,0 @@
[package]
name = "detmir-aw-client"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
detmir-core.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
-119
View File
@@ -1,119 +0,0 @@
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use detmir_core::parse_utc_rfc3339;
use reqwest::blocking::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
const DEFAULT_GET_ATTEMPTS: usize = 6;
#[derive(Debug, Clone)]
pub struct ActivityWatchClient {
api_base: String,
client: Client,
}
impl ActivityWatchClient {
pub fn new(api_base: impl Into<String>, timeout: Duration) -> Result<Self> {
let client = Client::builder()
.timeout(timeout)
.no_proxy()
.build()
.context("failed to build ActivityWatch HTTP client")?;
Ok(Self {
api_base: api_base.into().trim_end_matches('/').to_string(),
client,
})
}
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = self.url(path);
let mut last_error = None;
for attempt in 0..DEFAULT_GET_ATTEMPTS {
let result = self
.client
.get(&url)
.send()
.with_context(|| format!("ActivityWatch request failed: {url}"))
.and_then(|response| {
response.error_for_status().with_context(|| {
format!("ActivityWatch returned non-success status: {url}")
})
})
.and_then(|response| {
response
.json()
.with_context(|| format!("failed to parse ActivityWatch JSON: {url}"))
});
match result {
Ok(value) => return Ok(value),
Err(err) => last_error = Some(err),
}
if attempt + 1 < DEFAULT_GET_ATTEMPTS {
std::thread::sleep(Duration::from_millis(500 * (attempt as u64 + 1)));
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("ActivityWatch request failed: {url}")))
}
pub fn latest_event(&self, bucket_id: &str) -> Result<Option<AwEvent>> {
let path = format!("/buckets/{bucket_id}/events?limit=1");
let mut events: Vec<AwEvent> = self.get_json(&path)?;
events.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
Ok(events.into_iter().next())
}
fn url(&self, path: &str) -> String {
if path.starts_with('/') {
format!("{}{}", self.api_base, path)
} else {
format!("{}/{}", self.api_base, path)
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AwEvent {
pub timestamp: String,
#[serde(default)]
pub duration: f64,
#[serde(default)]
pub data: serde_json::Value,
}
impl AwEvent {
pub fn timestamp_utc(&self) -> Result<DateTime<Utc>> {
parse_utc_rfc3339(&self.timestamp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_paths_without_double_slashes() {
let client =
ActivityWatchClient::new("http://127.0.0.1:5600/api/0/", Duration::from_secs(1))
.unwrap();
assert_eq!(
client.url("/buckets/x/events?limit=1"),
"http://127.0.0.1:5600/api/0/buckets/x/events?limit=1"
);
}
#[test]
fn parses_event_timestamp() {
let event = AwEvent {
timestamp: "2026-05-31T10:20:30Z".to_string(),
duration: 0.0,
data: serde_json::json!({}),
};
assert_eq!(event.timestamp_utc().unwrap().timestamp(), 1_780_222_830);
}
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "detmir-check"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
detmir-aw-client.workspace = true
detmir-core.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true

Some files were not shown because too many files have changed in this diff Show More