Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 56 additions & 18 deletions adk-rust/crates/aw-windows-telemetry/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::Command,
sync::mpsc,
sync::{Arc, Mutex, mpsc},
time::{Duration, SystemTime},
};

Expand All @@ -16,7 +16,7 @@ use chrono::{DateTime, Timelike, Utc};
use clap::{Parser, Subcommand};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::RenameMode};
use regex::Regex;
use reqwest::blocking::Client;
use reqwest::{StatusCode, blocking::Client};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -1462,13 +1462,10 @@ fn ensure_aw_bucket(
hostname: &str,
) -> Result<()> {
let url = format!("{}/buckets/{bucket_id}", api_base.trim_end_matches('/'));
if client
.get(&url)
.send()
.map(|response| response.status().is_success())
.unwrap_or(false)
{
return Ok(());
if let Ok(response) = client.get(&url).send() {
if aw_bucket_status_accepts_existing(response.status()) {
return Ok(());
}
}
let response = client
.post(&url)
Expand All @@ -1479,7 +1476,7 @@ fn ensure_aw_bucket(
}))
.send()
.with_context(|| format!("POST {url}"))?;
if !response.status().is_success() {
if !aw_bucket_status_accepts_existing(response.status()) {
bail!(
"bucket create failed {} status={}",
bucket_id,
Expand All @@ -1489,6 +1486,10 @@ fn ensure_aw_bucket(
Ok(())
}

fn aw_bucket_status_accepts_existing(status: StatusCode) -> bool {
status.is_success() || status == StatusCode::NOT_MODIFIED
}

fn append_file_ops_log(runtime: &FileOpsRuntime, message: &str) -> Result<()> {
if !runtime.local_logs_enabled {
return Ok(());
Expand Down Expand Up @@ -1599,6 +1600,7 @@ struct RustCollectorRuntime {
rules_path: PathBuf,
policy_path: PathBuf,
incident_screenshot_enabled: bool,
ensured_buckets: Arc<Mutex<HashSet<String>>>,
}

#[derive(Debug, Default, Clone, Serialize)]
Expand Down Expand Up @@ -2045,6 +2047,7 @@ fn build_rust_collector_runtime(
rules_path,
policy_path,
incident_screenshot_enabled,
ensured_buckets: Arc::new(Mutex::new(HashSet::new())),
})
}

Expand Down Expand Up @@ -3127,14 +3130,7 @@ fn send_collector_aw_event(
return Ok(());
}
let client = Client::builder().timeout(Duration::from_secs(15)).build()?;
ensure_aw_bucket(
&client,
&runtime.api_base,
bucket_id,
client_name,
bucket_type,
&runtime.hostname,
)?;
ensure_runtime_aw_bucket(runtime, &client, bucket_id, client_name, bucket_type)?;
let url = format!(
"{}/buckets/{bucket_id}/heartbeat?pulsetime={}",
runtime.api_base.trim_end_matches('/'),
Expand All @@ -3155,6 +3151,37 @@ fn send_collector_aw_event(
Ok(())
}

fn ensure_runtime_aw_bucket(
runtime: &RustCollectorRuntime,
client: &Client,
bucket_id: &str,
client_name: &str,
bucket_type: &str,
) -> Result<()> {
if runtime
.ensured_buckets
.lock()
.map(|cache| cache.contains(bucket_id))
.unwrap_or(false)
{
return Ok(());
}

ensure_aw_bucket(
client,
&runtime.api_base,
bucket_id,
client_name,
bucket_type,
&runtime.hostname,
)?;

if let Ok(mut cache) = runtime.ensured_buckets.lock() {
cache.insert(bucket_id.to_string());
}
Ok(())
}

fn collector_state(
schema: &str,
runtime: &RustCollectorRuntime,
Expand Down Expand Up @@ -6029,6 +6056,17 @@ Connect=Srvr="srv";Ref="x";
assert_eq!(value.get("ok").and_then(Value::as_bool), Some(true));
}

#[test]
fn bucket_status_accepts_only_success_or_not_modified() {
assert!(aw_bucket_status_accepts_existing(StatusCode::OK));
assert!(aw_bucket_status_accepts_existing(StatusCode::NO_CONTENT));
assert!(aw_bucket_status_accepts_existing(StatusCode::NOT_MODIFIED));
assert!(!aw_bucket_status_accepts_existing(StatusCode::NOT_FOUND));
assert!(!aw_bucket_status_accepts_existing(
StatusCode::SERVICE_UNAVAILABLE
));
}

#[test]
fn dlp_evidence_sync_accepts_only_dlp_screenshot_names() {
assert!(is_dlp_evidence_screenshot_name(
Expand Down
82 changes: 73 additions & 9 deletions ansible/deploy_aw_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@
aw_windows_package_version: "v0.13.2"
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025"
# Windows account domain is the physical/local Windows logon domain. It may
# change when the RDP server is renamed. Do not use it as the ActivityWatch
# bucket identity.
aw_windows_domain: ""
# Stable ActivityWatch identity used in bucket ids, dashboards and reports.
# Keep this stable across Windows/RDP host renames.
aw_windows_logical_host_id: ""
aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users:
- Администратор
Expand Down Expand Up @@ -143,13 +149,44 @@
)
}}

- name: Получить Windows COMPUTERNAME для account-domain fallback
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
register: aw_windows_computername_result
changed_when: false

- name: Вычислить effective Windows account domain
ansible.builtin.set_fact:
aw_windows_domain_effective: >-
{{
aw_windows_domain
if (
(aw_windows_domain | default('') | string | length) > 0
and (aw_windows_domain | string) != 'HOST-EXAMPLE'
)
else (aw_windows_computername_result.stdout | trim)
}}

- name: Вычислить effective File1C upload principal
ansible.builtin.set_fact:
aw_windows_file_1c_auto_upload_run_as_user_effective: >-
{{
aw_windows_file_1c_auto_upload_run_as_user
if (
(aw_windows_file_1c_auto_upload_run_as_user | default('') | string | length) > 0
and 'HOST-EXAMPLE' not in (aw_windows_file_1c_auto_upload_run_as_user | string)
and not ((aw_windows_file_1c_auto_upload_run_as_user | string).startswith('\\'))
)
else (aw_windows_domain_effective ~ '\\' ~ aw_windows_builtin_administrator_name)
}}

- name: Проверить обязательные переменные
ansible.builtin.assert:
that:
- aw_windows_server_host_effective | length > 0
- aw_windows_server_port is defined
- aw_windows_server_scheme is defined
- aw_windows_domain is defined
- aw_windows_domain_effective is defined
- aw_windows_domain_effective | string | length > 0
- aw_windows_builtin_administrator_name is defined
- aw_windows_builtin_administrator_name | length > 0
- aw_windows_users_effective | length > 0
Expand All @@ -158,6 +195,25 @@
- (not (aw_windows_file_1c_auto_upload_enabled | bool)) or (aw_windows_file_1c_target_host_effective | length > 0)
fail_msg: "Не заданы обязательные переменные Windows-развёртывания."

- name: Вычислить stable ActivityWatch host identity
ansible.builtin.set_fact:
aw_windows_logical_host_id_effective: >-
{{
aw_windows_logical_host_id
if (
(aw_windows_logical_host_id | default('') | string | length) > 0
and (aw_windows_logical_host_id | string) != 'HOST-EXAMPLE'
)
else (
aw_windows_hostname_override
if (
(aw_windows_hostname_override | default('') | string | length) > 0
and (aw_windows_hostname_override | string) != 'HOST-EXAMPLE'
)
else ''
)
}}

- name: Нормализовать effective флаги collector'ов и smoke-check
ansible.builtin.set_fact:
aw_windows_afk_enabled_effective: "{{ (aw_windows_afk_enabled | default(aw_windows_afk_enabled_default)) | bool }}"
Expand Down Expand Up @@ -286,7 +342,7 @@
ServerHost = "{{ aw_windows_server_host_effective }}"
ServerPort = {{ aw_windows_server_port }}
Version = "{{ aw_windows_package_version }}"
Domain = "{{ aw_windows_domain }}"
Domain = "{{ aw_windows_domain_effective }}"
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
Expand Down Expand Up @@ -316,7 +372,7 @@
File1CAutoUploadEnabled = {{ '$true' if (aw_windows_file_1c_auto_upload_enabled | bool) else '$false' }}
File1CAutoUploadIntervalMinutes = {{ aw_windows_file_1c_auto_upload_interval_minutes | int }}
File1CAutoUploadTaskName = "{{ aw_windows_file_1c_auto_upload_task_name }}"
File1CAutoUploadRunAsUser = "{{ aw_windows_file_1c_auto_upload_run_as_user }}"
File1CAutoUploadRunAsUser = "{{ aw_windows_file_1c_auto_upload_run_as_user_effective }}"
File1CTargetHost = "{{ aw_windows_file_1c_target_host_effective }}"
File1CTargetUser = "{{ aw_windows_file_1c_target_user }}"
File1CRegistryWorkbookPath = "{{ aw_windows_file_1c_registry_workbook_path }}"
Expand All @@ -336,8 +392,8 @@
{% endfor %}
)
{% endif %}
{% if (aw_windows_hostname_override | default('') | string | length) > 0 %}
$params.AwHostname = "{{ aw_windows_hostname_override }}"
{% if (aw_windows_logical_host_id_effective | default('') | string | length) > 0 %}
$params.AwHostname = "{{ aw_windows_logical_host_id_effective }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
Expand Down Expand Up @@ -465,7 +521,7 @@
}
& "{{ aw_windows_deploy_root }}\windows\install-collector-guard-service.ps1" @guardParams

- name: Получить Windows hostname для AW smoke-check bucket
- name: Получить Windows hostname для fallback AW smoke-check bucket
when:
- aw_windows_api_smoke_check_enabled | bool
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
Expand All @@ -481,7 +537,11 @@
{{
aw_windows_api_smoke_check_bucket
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
else 'aw-worktime-sessions_' ~ (aw_windows_hostname_result.stdout | trim)
else 'aw-worktime-sessions_' ~ (
aw_windows_logical_host_id_effective
if (aw_windows_logical_host_id_effective | default('') | string | length) > 0
else (aw_windows_hostname_result.stdout | trim)
)
}}

- name: Вычислить AW Window smoke-check bucket
Expand All @@ -495,7 +555,11 @@
{{
aw_windows_api_smoke_check_window_bucket
if (aw_windows_api_smoke_check_window_bucket | default('') | string | length) > 0
else 'aw-watcher-window_' ~ (aw_windows_hostname_result.stdout | trim)
else 'aw-watcher-window_' ~ (
aw_windows_logical_host_id_effective
if (aw_windows_logical_host_id_effective | default('') | string | length) > 0
else (aw_windows_hostname_result.stdout | trim)
)
}}

- name: Выполнить AW API smoke-check (worktime bucket должен получать события)
Expand Down
4 changes: 4 additions & 0 deletions ansible/group_vars/aw_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases
aw_windows_package_zip_path: ""

aw_windows_domain: "HOST-EXAMPLE"
# Stable ActivityWatch host identity for bucket ids, Grafana variables and
# ClickHouse workforce keys. This is separate from aw_windows_domain.
# Production host_vars must override both values explicitly.
aw_windows_logical_host_id: "HOST-EXAMPLE"
aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users:
- Администратор
Expand Down
11 changes: 8 additions & 3 deletions ansible/group_vars/windows.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ aw_windows_package_version: "v0.13.2"
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
aw_windows_package_zip_path: ""
aw_windows_domain: "HOST-EXAMPLE"
# Stable ActivityWatch host identity for bucket ids and dashboards.
# This is not necessarily the Windows computer name. Keep it stable across
# Windows/RDP host renames, and change aw_windows_domain separately when the
# local Windows logon domain changes.
aw_windows_logical_host_id: "HOST-EXAMPLE"
# Localized name of the built-in local Administrator account (SID ending in -500).
# On the current Russian Windows host this must stay "Администратор";
# do not replace it with "Administrator" unless the target OS account is actually named that way.
Expand All @@ -33,7 +38,7 @@ aw_windows_extra_users: []
# Единые Windows/RDP пути: те же, что использует InnoSetup.
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: "" # Например: HOST-EXAMPLE
aw_windows_hostname_override: "" # Legacy alias; prefer aw_windows_logical_host_id.
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true
Expand Down Expand Up @@ -74,8 +79,8 @@ aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2"
aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json"

# По умолчанию AFK bucket вычисляется как aw-watcher-afk_<COMPUTERNAME>.
# Задайте явное значение только если watcher пишет в нестандартный bucket.
# По умолчанию smoke-check bucket вычисляется как aw-watcher-*_<aw_windows_logical_host_id>.
# Если logical id не задан, используется физический COMPUTERNAME как fallback.
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
16 changes: 13 additions & 3 deletions ansible/host_vars/rdp-prod.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
---
# SHARKON2025 uses aw-windows-telemetry browser-domains-collector as the
# per-user currentwindow source. The legacy aw-watcher-window process emits
# no-user duplicate rows in this RDP setup, so keep it disabled for this host.
# Production DetMir keeps SHARKON2025 as the stable ActivityWatch logical host
# id for historical buckets, Grafana variables and ClickHouse workforce keys.
# This value is intentionally independent from the physical Windows computer
# name, which may change during RDP server maintenance.
aw_windows_logical_host_id: "SHARKON2025"
aw_windows_hostname_override: "{{ aw_windows_logical_host_id }}"

# Set aw_windows_domain to the current Windows local/domain logon prefix after
# a server rename. Do not use aw_windows_logical_host_id as a logon domain.

# This host uses aw-windows-telemetry browser-domains-collector as the per-user
# currentwindow source. The legacy aw-watcher-window process emits no-user
# duplicate rows in this RDP setup, so keep it disabled for this host.
aw_windows_window_enabled: false
8 changes: 4 additions & 4 deletions docs/wiki/Windows-Collector-Suite.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ aw_windows_builtin_administrator_name: "Администратор"

Назначение: явно фиксировать локализованное имя встроенной учетной записи Administrator с SID `*-500`.

Для текущего Windows host `SHARKON2025` task name должен строиться как:
Task name должен строиться из stable ActivityWatch logical host id и локализованного имени пользователя. Для текущего DetMir production historical logical id остаётся `SHARKON2025`, даже если физический `COMPUTERNAME` RDP-сервера изменён:

```text
ActivityWatch Launch [SHARKON2025_Администратор]
```

Если task по `SHARKON2025_Administrator` не найден, recovery/deploy path обязан пробовать кириллическое имя `Администратор`. Это зафиксировано через:
Если task по `<logical-host>_Administrator` не найден, recovery/deploy path обязан пробовать кириллическое имя `Администратор`. Это зафиксировано через:

- default vars в `ansible/deploy_aw_windows.yml`;
- `ansible/group_vars/aw_windows.yml`;
Expand All @@ -60,9 +60,9 @@ ActivityWatch Launch [SHARKON2025_Администратор]

`ActivityWatch.Windows.Common.psm1` усилил recovery path:

- `Get-ActivityWatchBuiltInAdministratorName` сначала смотрит env override, затем SID-500 lookup, затем host-specific fallback `SHARKON2025 -> Администратор`;
- `Get-ActivityWatchBuiltInAdministratorName` сначала смотрит env override, затем SID-500 lookup, затем общий fallback `Administrator`;
- `Normalize-ActivityWatchUsers` стабилизирован для pipeline/list cases;
- удаление scheduled tasks стало устойчивее к частично удаленным task definitions;
- recovery task может ориентироваться на live interactive session и запускаться в interactive logon context, когда это безопаснее для watcher'ов.

Операционный вывод: для RDP/console telemetry нельзя полагаться на task name с английским `Administrator` на русифицированной Windows. Локализованное имя должно быть частью deploy vars.
Операционный вывод: для RDP/console telemetry нельзя полагаться на task name с английским `Administrator` на русифицированной Windows. Локализованное имя должно быть частью deploy vars, а `awHostname` должен оставаться stable logical id и не обязан совпадать с физическим именем Windows.
Loading
Loading