Stabilize Windows logical host guard
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s

This commit is contained in:
igor04091968
2026-07-01 06:06:42 +03:00
parent fe87c85a31
commit e658510442
14 changed files with 314 additions and 68 deletions
+118 -13
View File
@@ -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
@@ -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
}
+4 -4
View File
@@ -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)
@@ -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.'
@@ -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}
+23 -3
View File
@@ -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