diff --git a/adk-rust/crates/aw-windows-telemetry/src/main.rs b/adk-rust/crates/aw-windows-telemetry/src/main.rs index 8bc6692..79bbb2e 100644 --- a/adk-rust/crates/aw-windows-telemetry/src/main.rs +++ b/adk-rust/crates/aw-windows-telemetry/src/main.rs @@ -6,7 +6,7 @@ use std::{ io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::Command, - sync::mpsc, + sync::{Arc, Mutex, mpsc}, time::{Duration, SystemTime}, }; @@ -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}; @@ -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) @@ -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, @@ -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(()); @@ -1599,6 +1600,7 @@ struct RustCollectorRuntime { rules_path: PathBuf, policy_path: PathBuf, incident_screenshot_enabled: bool, + ensured_buckets: Arc>>, } #[derive(Debug, Default, Clone, Serialize)] @@ -2045,6 +2047,7 @@ fn build_rust_collector_runtime( rules_path, policy_path, incident_screenshot_enabled, + ensured_buckets: Arc::new(Mutex::new(HashSet::new())), }) } @@ -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('/'), @@ -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, @@ -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( diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 20ff1e3..68e36c2 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -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: - Администратор @@ -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 @@ -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 }}" @@ -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 }}" @@ -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 }}" @@ -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 @@ -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" @@ -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 @@ -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 должен получать события) diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index 3746d53..a288fde 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -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: - Администратор diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml index 128cae6..c6ddec3 100644 --- a/ansible/group_vars/windows.example.yml +++ b/ansible/group_vars/windows.example.yml @@ -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. @@ -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 @@ -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_. -# Задайте явное значение только если watcher пишет в нестандартный bucket. +# По умолчанию smoke-check bucket вычисляется как aw-watcher-*_. +# Если logical id не задан, используется физический COMPUTERNAME как fallback. aw_windows_api_smoke_check_enabled: true aw_windows_api_smoke_check_bucket: "" aw_windows_api_smoke_check_limit: 10 diff --git a/ansible/host_vars/rdp-prod.yml b/ansible/host_vars/rdp-prod.yml index 699a411..793dd4f 100644 --- a/ansible/host_vars/rdp-prod.yml +++ b/ansible/host_vars/rdp-prod.yml @@ -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 diff --git a/docs/wiki/Windows-Collector-Suite.md b/docs/wiki/Windows-Collector-Suite.md index 37d3254..686fab1 100644 --- a/docs/wiki/Windows-Collector-Suite.md +++ b/docs/wiki/Windows-Collector-Suite.md @@ -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 по `_Administrator` не найден, recovery/deploy path обязан пробовать кириллическое имя `Администратор`. Это зафиксировано через: - default vars в `ansible/deploy_aw_windows.yml`; - `ansible/group_vars/aw_windows.yml`; @@ -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. diff --git a/docs/windows/deployment.md b/docs/windows/deployment.md index 7a1f64b..267dac6 100755 --- a/docs/windows/deployment.md +++ b/docs/windows/deployment.md @@ -191,7 +191,8 @@ Ansible playbook `ansible/deploy_aw_windows.yml` выполняет этот mig .\windows\deploy-domain-users.ps1 ` -ServerHost ` -ServerPort 5600 ` - -Domain SHARKON2025 ` + -Domain ` + -AwHostname ` -Users user2,user3,user4,user5 ` -InstallRoot 'C:\Program Files\AWatch-rus\bin' ` -StateRoot 'C:\ProgramData\AWatch-rus' ` @@ -205,7 +206,8 @@ Single-user pilot в таком же стиле: .\windows\deploy-single-user.ps1 ` -ServerHost ` -ServerPort 5600 ` - -TargetUser 'SHARKON2025\user1' ` + -TargetUser '\user1' ` + -AwHostname ` -InstallRoot 'C:\Program Files\AWatch-rus\bin' ` -StateRoot 'C:\ProgramData\AWatch-rus' ` -CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json ` diff --git a/docs/windows/validation.md b/docs/windows/validation.md index 72ae9e5..6ae54fd 100755 --- a/docs/windows/validation.md +++ b/docs/windows/validation.md @@ -40,10 +40,12 @@ Get-ScheduledTask -TaskName 'ActivityWatch*' | - по одной задаче `ActivityWatch Launch [...]` на пользователя; - одна задача `ActivityWatch Recovery`. -Точечная проверка: +Точечная проверка. В имени `ActivityWatch Launch [...]` используется stable ActivityWatch logical host id из `deployment-config.json` (`awHostname`), а не обязательно физический `COMPUTERNAME`: ```powershell -Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Launch [SHARKON2025_user1]' +$cfg = Get-Content 'C:\ProgramData\AWatch-rus\deployment-config.json' -Raw | ConvertFrom-Json +$logicalHost = if ($cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME } +Get-ScheduledTask | Where-Object TaskName -eq "ActivityWatch Launch [$($logicalHost)_user1]" Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Recovery' ``` diff --git a/windows/AWatchRusCollectorGuardService.cs b/windows/AWatchRusCollectorGuardService.cs index 3644543..11f66f4 100644 --- a/windows/AWatchRusCollectorGuardService.cs +++ b/windows/AWatchRusCollectorGuardService.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; using System.IO; using System.ServiceProcess; +using System.Threading; namespace AWatchRus { @@ -9,6 +10,12 @@ namespace AWatchRus { private Process child; private readonly ServiceOptions options; + private readonly object sync = new object(); + private bool stopping; + private string childFileName; + private string childArguments; + private DateTime restartWindowStartedUtc = DateTime.UtcNow; + private int restartCountInWindow; public CollectorGuardService(ServiceOptions options) { @@ -21,29 +28,113 @@ namespace AWatchRus protected override void OnStart(string[] args) { Directory.CreateDirectory(Path.GetDirectoryName(options.LogPath)); - File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " service starting" + Environment.NewLine); + Log("service starting"); - var fileName = string.IsNullOrWhiteSpace(options.ExecPath) ? options.PowerShellPath : options.ExecPath; - var arguments = string.IsNullOrWhiteSpace(options.ExecPath) + childFileName = string.IsNullOrWhiteSpace(options.ExecPath) ? options.PowerShellPath : options.ExecPath; + childArguments = string.IsNullOrWhiteSpace(options.ExecPath) ? string.Format( "-NoProfile -ExecutionPolicy Bypass -File \"{0}\" -ConfigPath \"{1}\" -Mode {2} -LoopSeconds {3}", options.ScriptPath, options.ConfigPath, options.Mode, options.LoopSeconds) - : options.ExecArgs; + : (options.ExecArgs ?? string.Empty); + + StartChild("initial start"); + } + + private void StartChild(string reason) + { + lock (sync) + { + if (stopping) + { + return; + } + if (child != null && !child.HasExited) + { + return; + } + } var psi = new ProcessStartInfo { - FileName = fileName, - Arguments = arguments, + FileName = childFileName, + Arguments = childArguments, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = false, RedirectStandardError = false, }; - child = Process.Start(psi); - File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " child pid=" + child.Id + " exec=" + fileName + Environment.NewLine); + var process = new Process + { + StartInfo = psi, + EnableRaisingEvents = true, + }; + process.Exited += ChildExited; + process.Start(); + lock (sync) + { + child = process; + } + Log("child pid=" + process.Id + " exec=" + childFileName + " reason=" + reason); + } + + private void ChildExited(object sender, EventArgs args) + { + var process = sender as Process; + var exitCode = "unknown"; + try + { + if (process != null) + { + exitCode = process.ExitCode.ToString(); + } + } + catch + { + } + + lock (sync) + { + if (stopping) + { + Log("child exited during stop exitCode=" + exitCode); + return; + } + } + + Log("child exited unexpectedly exitCode=" + exitCode); + if (!RegisterChildRestart()) + { + Log("child restart budget exhausted; exiting service for SCM recovery"); + Environment.Exit(1); + return; + } + + var restartThread = new Thread(new ThreadStart(delegate + { + Thread.Sleep(Math.Max(1, options.ChildRestartDelaySeconds) * 1000); + StartChild("child-exit restart"); + })); + restartThread.IsBackground = true; + restartThread.Start(); + } + + private bool RegisterChildRestart() + { + lock (sync) + { + var now = DateTime.UtcNow; + if ((now - restartWindowStartedUtc).TotalSeconds > options.ChildRestartWindowSeconds) + { + restartWindowStartedUtc = now; + restartCountInWindow = 0; + } + restartCountInWindow++; + Log("child restart budget count=" + restartCountInWindow + " windowSeconds=" + options.ChildRestartWindowSeconds); + return restartCountInWindow <= options.MaxChildRestartsInWindow; + } } protected override void OnStop() @@ -60,24 +151,35 @@ namespace AWatchRus { try { - File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " " + reason + Environment.NewLine); - if (child != null && !child.HasExited) + Log(reason); + Process process; + lock (sync) { - child.Kill(); - child.WaitForExit(10000); + stopping = true; + process = child; + } + if (process != null && !process.HasExited) + { + process.Kill(); + process.WaitForExit(10000); } } catch (Exception ex) { try { - File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " stop error: " + ex.Message + Environment.NewLine); + Log("stop error: " + ex.Message); } catch { } } } + + private void Log(string message) + { + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " " + message + Environment.NewLine); + } } public sealed class ServiceOptions @@ -91,6 +193,9 @@ namespace AWatchRus public string LogPath = @"C:\ProgramData\AWatch-rus\logs\collector-guard-service.log"; public string ExecPath = null; public string ExecArgs = null; + public int ChildRestartDelaySeconds = 5; + public int MaxChildRestartsInWindow = 5; + public int ChildRestartWindowSeconds = 600; } internal static class Program diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index a12ce54..1ae7b02 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -312,11 +312,6 @@ function Get-ActivityWatchBuiltInAdministratorName { catch { } - if ([string]$env:COMPUTERNAME -ieq 'SHARKON2025') { - $script:ActivityWatchBuiltInAdministratorName = 'Администратор' - return $script:ActivityWatchBuiltInAdministratorName - } - $script:ActivityWatchBuiltInAdministratorName = 'Administrator' return $script:ActivityWatchBuiltInAdministratorName } diff --git a/windows/aw-collector-guard.ps1 b/windows/aw-collector-guard.ps1 index e1bc05c..b6bac2c 100644 --- a/windows/aw-collector-guard.ps1 +++ b/windows/aw-collector-guard.ps1 @@ -322,18 +322,18 @@ function Invoke-GuardSelfTest { $oldComputerName = $env:COMPUTERNAME try { - $env:COMPUTERNAME = 'SHARKON2025' + $env:COMPUTERNAME = 'HOST-EXAMPLE' $sessionRecords = @( [pscustomobject]@{ SessionName = 'USER5'; UserName = 'USER5'; SessionId = 2; State = 'Disc'; IsLive = $false }, [pscustomobject]@{ SessionName = 'console'; UserName = ''; SessionId = 1; State = 'Conn'; IsLive = $true } ) $taskDefs = @( - [pscustomobject]@{ taskName = 'ActivityWatch Launch [SHARKON2025_user5]'; userId = 'SHARKON2025\user5' } + [pscustomobject]@{ taskName = 'ActivityWatch Launch [HOST-EXAMPLE_user5]'; userId = 'HOST-EXAMPLE\user5' } ) - if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) { + if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'HOST-EXAMPLE\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) { throw 'expected disconnected managed session to match task user' } - if (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeLive) { + if (Test-ActivityWatchUserHasManagedSession -UserId 'HOST-EXAMPLE\user5' -SessionRecords $sessionRecords -IncludeLive) { throw 'disconnected managed session should not match live-only filter' } $managed = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeDisconnected) diff --git a/windows/install-collector-guard-service.ps1 b/windows/install-collector-guard-service.ps1 index 1c6dcb7..bdc82a5 100644 --- a/windows/install-collector-guard-service.ps1 +++ b/windows/install-collector-guard-service.ps1 @@ -79,6 +79,7 @@ else { New-Service -Name $ServiceName -BinaryPathName $binPath -DisplayName 'AWatch-rus Collector Guard' -StartupType Automatic | Out-Null sc.exe description $ServiceName "Session-aware ActivityWatch collector guard for AWatch-rus" | Out-Null sc.exe failure $ServiceName reset= 300 actions= restart/5000/restart/15000/restart/60000 | Out-Null +sc.exe failureflag $ServiceName 1 | Out-Null if ($DisableRecoveryTask) { Write-Warning 'DisableRecoveryTask is deprecated and ignored: ActivityWatch Recovery must remain enabled as collector guard fallback.' diff --git a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss index 720d69b..3fe6431 100644 --- a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss +++ b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss @@ -5,7 +5,7 @@ #define AwDefaultServerHost "aw-server" #define AwDefaultServerPort "5600" #define AwDefaultWorktimeReportBase "http://aw-server:5610" -#define AwDefaultWorktimeHost "SHARKON2025" +#define AwDefaultWorktimeHost "HOST-EXAMPLE" #define AwDefaultUsers "user1,user2,user3,user4,user5" #define AwDefaultInstallRoot "C:\\Program Files\\AWatch-rus\\bin" #define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus" @@ -13,7 +13,7 @@ ; This installer wraps the standalone-service path. ; It is suitable for standalone/headless deployment and must not be treated -; as the canonical multi-user RDP deployment path used on SHARKON2025. +; as the canonical multi-user RDP deployment path used on the configured logical host id. [Setup] AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9} diff --git a/windows/run-user1-probe.ps1 b/windows/run-user1-probe.ps1 index 641250a..a813104 100644 --- a/windows/run-user1-probe.ps1 +++ b/windows/run-user1-probe.ps1 @@ -1,6 +1,9 @@ [CmdletBinding()] param( - [string]$UserId = 'SHARKON2025\user1' + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$User = 'user1', + [string]$UserId, + [string]$AwHostname ) Set-StrictMode -Version Latest @@ -13,13 +16,30 @@ Start-Sleep -Seconds 10 Get-Process notepad -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue '@ | Set-Content -LiteralPath $probeScriptPath -Encoding UTF8 -schtasks /Run /TN 'ActivityWatch Launch [SHARKON2025_user1]' | Out-Null +$config = $null +if (Test-Path -LiteralPath $ConfigPath) { + $config = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json +} +$logicalHost = if (-not [string]::IsNullOrWhiteSpace($AwHostname)) { + $AwHostname +} +elseif ($config -and $config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { + [string]$config.awHostname +} +else { + [string]$env:COMPUTERNAME +} +$accountDomain = if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { [string]$env:USERDOMAIN } else { [string]$env:COMPUTERNAME } +$effectiveUserId = if (-not [string]::IsNullOrWhiteSpace($UserId)) { $UserId } else { '{0}\{1}' -f $accountDomain, $User } +$launchTaskName = 'ActivityWatch Launch [{0}_{1}]' -f $logicalHost, $User + +schtasks /Run /TN $launchTaskName | Out-Null Start-Sleep -Seconds 3 $taskName = 'AW User1 Notepad Probe' Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -Argument "-NoProfile -ExecutionPolicy Bypass -File $probeScriptPath" -$principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Highest +$principal = New-ScheduledTaskPrincipal -UserId $effectiveUserId -LogonType Interactive -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings | Out-Null Start-ScheduledTask -TaskName $taskName