diff --git a/ansible/README.md b/ansible/README.md index 38b7e76..75d978d 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -171,13 +171,24 @@ Playbook: - `telegram_allowed_chat_ids` - `tsj_bot_source_local_path` 3. Убедитесь, что в inventory есть группа `[proxmox]`. -4. Запустите: + Для текущего контура AW-Rus bot ожидает Proxmox host `10.10.10.2`. + Рабочая модель для этого контура: `igor` + `sudo`, а не обязательный `root` login. +4. При необходимости задайте recovery-команды для AW-Rus: + - `tsj_bot_aw_rus_worktime_heal_cmd` + - `tsj_bot_aw_rus_dlp_heal_cmd` +5. Запустите: ```bash cd ansible ansible-playbook -i inventory.ini deploy_tsj_guardian_bot_proxmox.yml ``` +После актуального production hardening: + +- bot различает `worktime idle` и реальную деградацию; +- bot поддерживает отдельный `AW_RUS_DLP_HEAL_CMD`; +- redeploy не должен терять runtime env-ключи, связанные с proxy, FS checks и AI escalation. + ## Результат - Установлен ActivityWatch Server. diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 3878298..7f6aad5 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -378,6 +378,15 @@ mode: "0644" when: aw_dlp_content_analysis_enabled | default(true) | bool + - name: Установить wrapper запуска DLP content analysis через virtualenv + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh" + dest: /usr/local/bin/aw-dlp-content-analyzer + owner: root + group: root + mode: "0755" + when: aw_dlp_content_analysis_enabled | default(true) | bool + - name: Создать virtualenv DLP content analysis ansible.builtin.command: cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 9fae046..d0c1d12 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -259,16 +259,58 @@ try { Enable-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue | Out-Null } catch {} - Get-ScheduledTask | - Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" | - ForEach-Object { - try { Enable-ScheduledTask -TaskName $_.TaskName -ErrorAction SilentlyContinue | Out-Null } catch {} + + $config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json + $loggedOnUsers = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + try { + foreach ($line in @(& quser.exe 2>$null)) { + $normalized = [string]$line + if ([string]::IsNullOrWhiteSpace($normalized)) { continue } + $normalized = $normalized.TrimStart(' ', '>') + if ([string]::IsNullOrWhiteSpace($normalized)) { continue } + if ($normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { continue } + $parts = $normalized -split '\s+' + if ($parts.Count -lt 1) { continue } + $user = [string]$parts[0] + if ([string]::IsNullOrWhiteSpace($user)) { continue } + [void]$loggedOnUsers.Add($user) + [void]$loggedOnUsers.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$loggedOnUsers.Add(('{0}\{1}' -f $env:USERDOMAIN, $user)) + } } + } catch {} + + function Test-TaskUserHasSession { + param([string]$UserId) + if ([string]::IsNullOrWhiteSpace($UserId)) { return $false } + $candidates = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + [void]$candidates.Add($UserId) + $leafUser = $UserId + if ($leafUser -match '^[^\\]+\\(.+)$') { + $leafUser = $Matches[1] + [void]$candidates.Add($leafUser) + } + [void]$candidates.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$candidates.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser)) + } + foreach ($candidate in @($candidates)) { + if ($loggedOnUsers.Contains($candidate)) { return $true } + } + return $false + } + + foreach ($taskDef in @($config.userTasks)) { + try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {} + } Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" - Get-ScheduledTask | - Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" | - ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName } + foreach ($taskDef in @($config.userTasks)) { + if (Test-TaskUserHasSession -UserId ([string]$taskDef.userId)) { + Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + } + } - name: Получить Windows hostname для AW smoke-check bucket when: diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml index 6d96ef9..7b4fb06 100644 --- a/ansible/deploy_tsj_guardian_bot_proxmox.yml +++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml @@ -80,8 +80,20 @@ TELEGRAM_BOT_TOKEN={{ telegram_bot_token }} TELEGRAM_ALLOWED_CHAT_IDS={{ telegram_allowed_chat_ids }} TELEGRAM_DEFAULT_CHAT_ID={{ tsj_bot_default_chat_id }} + HTTPS_PROXY={{ tsj_bot_https_proxy_url | default(tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090')) }} + HTTP_PROXY={{ tsj_bot_http_proxy_url | default(tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090')) }} + NO_PROXY={{ tsj_bot_no_proxy | default('localhost,127.0.0.1,10.10.10.0/24') }} + NODE_13_HOST={{ tsj_bot_node_13_host | default('10.10.10.13') }} + NODE_16_HOST={{ tsj_bot_node_16_host | default('10.10.10.16') }} + NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/') }} + NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }} + NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }} CHECK_SCRIPT={{ tsj_bot_check_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --check') }} HEAL_SCRIPT={{ tsj_bot_heal_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }} + FS_WARN_PCT={{ tsj_bot_fs_warn_pct | default(85) }} + FS_CRIT_PCT={{ tsj_bot_fs_crit_pct | default(92) }} + FS_TARGETS={{ tsj_bot_fs_targets | default('host,200,201,202,203') }} + FS_EXCLUDE_TYPES={{ tsj_bot_fs_exclude_types | default('tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs') }} STATE_FILE={{ tsj_bot_state_file | default('/home/codex/infra-admin/.state/tsj_guardian_state.json') }} LOG_FILE={{ tsj_bot_log_file | default('/home/codex/infra-admin/logs/tsj_guardian_bot.log') }} HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default('/home/codex/infra-admin/.state/tsj_guardian_heartbeat') }} @@ -90,6 +102,8 @@ RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }} EXIT_ON_AUTORECOVERY_SUCCESS={{ tsj_bot_exit_on_autorecovery_success | default('true') }} ENABLE_AI_ESCALATION={{ tsj_bot_enable_ai_escalation | default('true') }} + FS_IMMEDIATE_AI_ON_CRITICAL={{ tsj_bot_fs_immediate_ai_on_critical | default('true') }} + AI_ESCALATION_MODE={{ tsj_bot_ai_escalation_mode | default('codex_exec') }} ENABLE_SERVER_FALLBACK={{ tsj_bot_enable_server_fallback | default('true') }} TELEGRAM_PROXY_URL={{ tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090') }} AI_CHAT_ENABLED={{ tsj_bot_ai_chat_enabled | default('true') }} @@ -103,9 +117,22 @@ TMUX_SESSION={{ tsj_bot_tmux_session | default('ai') }} TMUX_CREATE_IF_MISSING={{ tsj_bot_tmux_create_if_missing | default('false') }} TMUX_START_COMMAND={{ tsj_bot_tmux_start_command | default('codex') }} + PFSENSE_CHANGE_CONTROL_ENABLED={{ tsj_bot_pfsense_change_control_enabled | default('true') }} + PFSENSE_CHANGE_CONFIRM_TTL_SEC={{ tsj_bot_pfsense_change_confirm_ttl_sec | default(900) }} + OPENVPN_CONFIG_ENABLED={{ tsj_bot_openvpn_config_enabled | default('true') }} + OPENVPN_CONFIG_CONFIRM_TTL_SEC={{ tsj_bot_openvpn_config_confirm_ttl_sec | default(900) }} + OPENVPN_EXPIRY_WARN_ENABLED={{ tsj_bot_openvpn_expiry_warn_enabled | default('false') }} + OPENVPN_EXPIRY_WARN_DAYS={{ tsj_bot_openvpn_expiry_warn_days | default(30) }} + OPENVPN_EXPIRY_WARN_TIMEOUT_SEC={{ tsj_bot_openvpn_expiry_warn_timeout_sec | default(120) }} + OPENVPN_EXPIRY_WARN_INTERVAL_SEC={{ tsj_bot_openvpn_expiry_warn_interval_sec | default(21600) }} + PFSENSE_MCP_BEARER={{ tsj_bot_pfsense_mcp_bearer | default(pfsense_mcp_bearer | default('')) }} + SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }} + UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py') }} + UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }} AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }} AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'") }} + AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'") }} AW_RUS_HOST={{ tsj_bot_aw_rus_host | default('SHARKON2025') }} AW_RUS_PRIMARY_USER={{ tsj_bot_aw_rus_primary_user | default('USER1') }} AW_RUS_STALE_SEC={{ tsj_bot_aw_rus_stale_sec | default(900) }} diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml index dd4324e..7fd97d9 100644 --- a/ansible/group_vars/proxmox-bot.example.yml +++ b/ansible/group_vars/proxmox-bot.example.yml @@ -10,11 +10,38 @@ tsj_bot_check_interval_sec: 60 tsj_bot_operator_timeout_sec: 900 tsj_bot_retry_autorecovery_every_sec: 300 tsj_bot_telegram_proxy_url: "http://127.0.0.1:11090" +tsj_bot_https_proxy_url: "http://127.0.0.1:11090" +tsj_bot_http_proxy_url: "http://127.0.0.1:11090" +tsj_bot_no_proxy: "localhost,127.0.0.1,10.10.10.0/24" +tsj_bot_node_13_host: "10.10.10.13" +tsj_bot_node_16_host: "10.10.10.16" +tsj_bot_node_13_url: "http://10.10.10.13:5600/" +tsj_bot_node_16_url: "http://10.10.10.16/" +tsj_bot_node_16_enabled: "false" +tsj_bot_fs_warn_pct: 85 +tsj_bot_fs_crit_pct: 92 +tsj_bot_fs_targets: "host,200,201,202,203" +tsj_bot_fs_exclude_types: "tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs" +tsj_bot_fs_immediate_ai_on_critical: "true" +tsj_bot_ai_escalation_mode: "codex_exec" +tsj_bot_pfsense_change_control_enabled: "true" +tsj_bot_pfsense_change_confirm_ttl_sec: 900 +tsj_bot_openvpn_config_enabled: "true" +tsj_bot_openvpn_config_confirm_ttl_sec: 900 +tsj_bot_openvpn_expiry_warn_enabled: "false" +tsj_bot_openvpn_expiry_warn_days: 30 +tsj_bot_openvpn_expiry_warn_timeout_sec: 120 +tsj_bot_openvpn_expiry_warn_interval_sec: 21600 +tsj_bot_server_fallback_commands: "/home/codex/infra-admin/scripts/system_self_support.sh --heal" +tsj_bot_updates_script: "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py" +tsj_bot_update_targets: "auto" +tsj_bot_pfsense_mcp_bearer: "CHANGE_ME" # AW-Rus + DLP check defaults tsj_bot_aw_rus_api_base: "http://10.10.10.13:5600/api/0" tsj_bot_aw_rus_worktime_base: "http://10.10.10.13:5610" tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'" +tsj_bot_aw_rus_dlp_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'" tsj_bot_aw_rus_host: "SHARKON2025" tsj_bot_aw_rus_primary_user: "USER1" tsj_bot_aw_rus_stale_sec: 900 diff --git a/ansible/roles/dlp-content-analysis/tasks/main.yml b/ansible/roles/dlp-content-analysis/tasks/main.yml index 6a8dde1..8ce9a9f 100644 --- a/ansible/roles/dlp-content-analysis/tasks/main.yml +++ b/ansible/roles/dlp-content-analysis/tasks/main.yml @@ -25,6 +25,14 @@ group: "{{ aw_server_group | default('activitywatch') }}" mode: "0644" +- name: Install content analysis wrapper + ansible.builtin.copy: + src: "{{ playbook_dir }}/../aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh" + dest: /usr/local/bin/aw-dlp-content-analyzer + owner: root + group: root + mode: "0755" + - name: Create venv for content analysis ansible.builtin.command: cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv diff --git a/aw-server/aw-worktime-api.service b/aw-server/aw-worktime-api.service index dedc128..e85d3bf 100644 --- a/aw-server/aw-worktime-api.service +++ b/aw-server/aw-worktime-api.service @@ -2,6 +2,8 @@ Description=AW Worktime Report API After=network.target activitywatch-server.service Wants=activitywatch-server.service +StartLimitBurst=3 +StartLimitIntervalSec=60 [Service] Type=simple @@ -9,8 +11,6 @@ EnvironmentFile=/etc/activitywatch/aw-server.env ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py Restart=on-failure RestartSec=5 -StartLimitBurst=3 -StartLimitIntervalSec=60 User=activitywatch Group=activitywatch StandardOutput=journal diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service index 475e4c7..cc20af4 100644 --- a/aw-server/aw-worktime-ui-bridge.service +++ b/aw-server/aw-worktime-ui-bridge.service @@ -2,6 +2,8 @@ Description=AW Worktime UI bridge (sessions -> afk/window) After=network-online.target activitywatch-server.service Wants=network-online.target +StartLimitBurst=3 +StartLimitIntervalSec=120 [Service] Type=simple @@ -10,8 +12,6 @@ Environment=AW_WORKTIME_HOST=SHARKON2025 ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-ui-bridge.py Restart=on-failure RestartSec=10 -StartLimitBurst=3 -StartLimitIntervalSec=120 User=activitywatch Group=activitywatch StandardOutput=journal diff --git a/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh b/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh new file mode 100644 index 0000000..8cd33dd --- /dev/null +++ b/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_DIR="/opt/activitywatch/dlp-content-analysis" +VENV_PY="$BASE_DIR/.venv/bin/python" +ANALYZER="$BASE_DIR/content_analyzer.py" + +if [ ! -x "$VENV_PY" ]; then + echo "ERROR: content-analysis virtualenv is missing: $VENV_PY" >&2 + exit 1 +fi + +exec "$VENV_PY" "$ANALYZER" "$@" diff --git a/aw-server/dlp-policy-engine/dlp-policy-engine.service b/aw-server/dlp-policy-engine/dlp-policy-engine.service index a183f87..989be92 100644 --- a/aw-server/dlp-policy-engine/dlp-policy-engine.service +++ b/aw-server/dlp-policy-engine/dlp-policy-engine.service @@ -2,6 +2,8 @@ Description=AW DLP Policy Engine After=network.target activitywatch-server.service Wants=activitywatch-server.service +StartLimitBurst=3 +StartLimitIntervalSec=60 [Service] Type=simple @@ -10,8 +12,6 @@ WorkingDirectory=/opt/activitywatch/dlp-policy-engine ExecStart=/opt/activitywatch/dlp-policy-engine/.venv/bin/uvicorn policy_service:app --host ${AW_DLP_POLICY_ENGINE_BIND_HOST} --port ${AW_DLP_POLICY_ENGINE_PORT} Restart=on-failure RestartSec=5 -StartLimitBurst=3 -StartLimitIntervalSec=60 User=activitywatch Group=activitywatch StandardOutput=journal diff --git a/aw-server/health-check.sh b/aw-server/health-check.sh index 38bfa43..a12ddf8 100644 --- a/aw-server/health-check.sh +++ b/aw-server/health-check.sh @@ -40,115 +40,24 @@ check_api_endpoint() { } check_dlp_transport_freshness() { - local api_base="${1:-http://127.0.0.1:5600/api/0}" - local max_age_seconds="${2:-900}" - local strict_fileops="${3:-0}" + local dlp_health="${DLP_HEALTH_BIN:-/usr/local/bin/dlp-health-check}" local result - if ! command -v python3 >/dev/null 2>&1; then - echo "⚠ python3 is not available, skipping DLP transport freshness checks" - WARNINGS+=("dlp-transport-check-skipped") + if [[ ! -x "$dlp_health" ]]; then + echo "⚠ dlp-health-check is not available, skipping DLP transport freshness checks" + WARNINGS+=("dlp-health-check-missing") return fi - result="$(python3 - "$api_base" "$max_age_seconds" "$strict_fileops" <<'PY' -import json -import sys -import time -from urllib.request import urlopen - -api_base = sys.argv[1].rstrip("/") -max_age = int(sys.argv[2]) -strict_fileops = str(sys.argv[3]).strip().lower() in ("1", "true", "yes", "on") -now = time.time() - -def parse_ts(ts): - if not ts: - return None - ts = ts.replace("Z", "+00:00") - try: - from datetime import datetime - return datetime.fromisoformat(ts).timestamp() - except Exception: - return None - -def get_json(url): - with urlopen(url, timeout=8) as resp: - return json.loads(resp.read().decode("utf-8")) - -out = { - "ok": True, - "warnings": [], - "errors": [] -} - -try: - buckets = get_json(f"{api_base}/buckets/") -except Exception as ex: - out["ok"] = False - out["errors"].append(f"dlp-buckets-read-failed:{ex}") - print(json.dumps(out)) - sys.exit(0) - -endpoint = [k for k in buckets.keys() if k.startswith("aw-dlp-endpoint-signals_")] -fileops = [k for k in buckets.keys() if k.startswith("aw-file-operations_")] - -if not endpoint: - out["ok"] = False - out["errors"].append("no-endpoint-signal-buckets") -if not fileops: - out["warnings"].append("no-file-operations-buckets") - -def check_bucket_freshness(bucket_id, label): - b = buckets.get(bucket_id, {}) - meta = b.get("metadata") or {} - end = parse_ts(meta.get("end")) - if end is None: - # Some aw-server deployments may not populate metadata.end; fallback to latest event. - try: - events = get_json(f"{api_base}/buckets/{bucket_id}/events?limit=1") - if events: - end = parse_ts(events[0].get("timestamp")) - except Exception: - end = None - if end is None: - out["warnings"].append(f"{label}:no-end-ts-or-events:{bucket_id}") + result="$("$dlp_health" --json 2>/dev/null || true)" + if [[ -z "$result" ]]; then + echo "⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks" + WARNINGS+=("dlp-health-check-empty") return - age = int(now - end) - if age > max_age: - if label == "fileops" and not strict_fileops: - out["warnings"].append(f"{label}:stale:{bucket_id}:age={age}s") - else: - out["ok"] = False - out["errors"].append(f"{label}:stale:{bucket_id}:age={age}s") - -for bid in endpoint: - check_bucket_freshness(bid, "endpoint") -for bid in fileops: - check_bucket_freshness(bid, "fileops") - -# Validate that endpoint self_test contains transport metrics at least once recently. -for bid in endpoint: - try: - events = get_json(f"{api_base}/buckets/{bid}/events?limit=20") - found = False - for e in events: - d = e.get("data") or {} - if d.get("signalType") == "self_test": - if all(k in d for k in ("queueDepth", "eventsEnqueued", "eventsFlushed", "sendFailures")): - found = True - break - if not found: - out["warnings"].append(f"endpoint:self_test-metrics-missing:{bid}") - except Exception as ex: - out["warnings"].append(f"endpoint:self_test-read-failed:{bid}:{ex}") - -print(json.dumps(out)) -PY -)" || true + fi local ok - ok="$(printf '%s' "$result" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("1" if d.get("ok") else "0")' 2>/dev/null || echo "0")" + ok="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); names={r["name"]:r for r in data.get("results", [])}; checks=["buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics"]; bad=[n for n in checks if names.get(n,{}).get("status")=="fail"]; print("1" if not bad else "0")' 2>/dev/null || echo "0")" if [[ "$ok" == "1" ]]; then echo "✓ DLP transport freshness check passed" else @@ -157,8 +66,8 @@ PY fi local errors warnings - errors="$(printf '%s' "$result" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(", ".join(d.get("errors", [])))' 2>/dev/null || true)" - warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(", ".join(d.get("warnings", [])))' 2>/dev/null || true)" + errors="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="fail" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" + warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="warn" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" if [[ -n "$errors" ]]; then echo " errors: $errors" fi diff --git a/docs/dlp-content-analysis-runtime-status-2026-05-13.md b/docs/dlp-content-analysis-runtime-status-2026-05-13.md new file mode 100644 index 0000000..b2e2703 --- /dev/null +++ b/docs/dlp-content-analysis-runtime-status-2026-05-13.md @@ -0,0 +1,60 @@ +# DLP Content Analysis Runtime Status 2026-05-13 + +This document records the production-verified state of advanced content analysis on `10.10.10.13`. + +## What is live + +- Endpoint-side dictionary and regex matching is active in `windows/dlp-endpoint-signals-collector.ps1`. +- Active policy supports: + - `contentAnalysis.dictionaryPack` + - `contentAnalysis.regexPack` + - `contentAnalysis.ocrEnabled` + - `ioc.*` +- Historical incidents in `aw-dlp-incidents_SHARKON2025` already contain enriched fields: + - `dictionaryMatches` + - `regexMatches` + - `ocrRequested` +- IOC refresh pipeline is deployed and active: + - `aw-dlp-ioc-refresh.timer` + - output artifacts: + - `/opt/activitywatch/dlp-ioc/output/ioc_blacklist.json` + - `/opt/activitywatch/dlp-ioc/output/ioc_blacklist.csv` + - `/opt/activitywatch/dlp-ioc/output/ioc_blacklist.sql` + +## What was fixed in this phase + +- Server-side analyzer dependencies were installed only inside a virtualenv, but there was no canonical wrapper to run the analyzer in production. +- Added `/usr/local/bin/aw-dlp-content-analyzer`, which executes: + - `/opt/activitywatch/dlp-content-analysis/.venv/bin/python` + - `/opt/activitywatch/dlp-content-analysis/content_analyzer.py` + +## Supported production mode + +### Fully supported now + +- Endpoint-side enrichment: + - clipboard and print content are matched against dictionary and regex packs on the endpoint; + - enriched incidents are sent to AW with structured matches; + - `ocrRequested=true` is carried into incident metadata when policy requires screenshot/OCR follow-up. +- IOC enrichment: + - Hayabusa/Sigma-derived IOC artifacts are refreshed on the server and exposed over HTTP for policy consumption. +- Server-side manual/operational analysis: + - operators can run `aw-dlp-content-analyzer` for text or image artifacts using the deployed packs and OCR stack. + +### Not a continuous background pipeline yet + +- There is no standalone daemon that automatically scans screenshot artifacts after incident creation. +- OCR is production-usable as a server-side utility path, not as an always-on post-processing service. + +## Live verification commands + +```bash +sudo systemctl status aw-dlp-ioc-refresh.timer --no-pager +ls -1 /opt/activitywatch/dlp-ioc/output +aw-dlp-content-analyzer --text "СНИЛС 112-233-445 95 пароль qwerty" --dictionary-pack 152-fz-pdn --regex-pack secrets +``` + +Expected result: + +- IOC artifacts exist and are non-empty. +- The analyzer returns dictionary and regex matches for the sample text. diff --git a/docs/dlp-runtime-chain-status-2026-05-13.md b/docs/dlp-runtime-chain-status-2026-05-13.md new file mode 100644 index 0000000..37b177e --- /dev/null +++ b/docs/dlp-runtime-chain-status-2026-05-13.md @@ -0,0 +1,48 @@ +# DLP Runtime Chain Status 2026-05-13 + +## Verified production chain + +Verified on `10.10.10.13`: + +- `policy engine` + - service: `aw-dlp-policy-engine.service` + - health: `GET http://127.0.0.1:5601/healthz` + - active policy: `policyId=1`, `default-policy`, `version=1` +- `case management` + - service: `aw-dlp-case-management.service` + - health: `GET http://127.0.0.1:5602/health` + - runtime data present: case `id=1` +- `compliance reporting` + - timer active: `aw-dlp-report-scheduler.timer` + - artifacts present: + - `152-fz-2026-05.html` + - `152-fz-2026-05.json` + - `pci-dss-2026-05.html` + - `pci-dss-2026-05.json` +- `integrations` + - timers active: + - `aw-dlp-cef-exporter.timer` + - `aw-dlp-webhook-sender.timer` + - `aw-dlp-syslog-forwarder.timer` + - recent journal runs are clean + - current runtime result is `sent=0` / `delivered=0` because no new incidents were generated since the last seen bucket event +- `endpoint -> incident ingest` + - `aw-dlp-endpoint-signals_SHARKON2025` fresh + - `aw-dlp-incidents_SHARKON2025` exists and contains valid historical incidents +- `health/admin` + - `/usr/local/bin/dlp-health-check --json` = `ok=true` + - `/usr/local/bin/dlp-admin-cli.py health check` = policy/cases/aw OK + +## Operational conclusion + +Core chain is working: + +`policy -> endpoint collectors -> incident bucket -> case management -> compliance -> integrations` + +There is no confirmed production break in the server-side DLP chain. + +## Bounded residual backlog + +- external webhook/syslog/CEF destinations are configured and runnable, but current production evidence only shows clean timer execution with zero fresh incidents to export +- stale incident buckets must not be treated as failure by health-check if endpoint transport and policy/case services are healthy +- remaining work belongs to content-analysis completion and broader productization, not to server-side chain break repair diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7f79d72..db1f53e 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -9,6 +9,8 @@ - [Компоненты системы](Components) - описание всех компонентов - [Интерактивная карта](Interactive-Map) - визуальная карта связей - [ИБ-профиль DLP](../dlp-security-functional-spec-ru.md) - подробное описание реализованного DLP/monitoring-контура для службы ИБ +- [Runtime status: DLP chain](../dlp-runtime-chain-status-2026-05-13.md) - фактический live-статус policy/cases/integrations/compliance +- [Runtime status: Content analysis](../dlp-content-analysis-runtime-status-2026-05-13.md) - фактический live-статус dictionary/regex/OCR/IOC ### Компоненты - [DLP Endpoint Monitoring](DLP-Endpoint-Monitoring) - мониторинг clipboard, печати, USB @@ -22,6 +24,7 @@ - [Установка на Windows](Windows-Installation) - установка коллекторов - [Настройка сервера](Server-Setup) - настройка Linux сервера - [Grafana + Prometheus](Monitoring-Setup) - мониторинг стек +- [Windows startup model](../windows-deploy-startup-model.md) - canonical startup model для RDP/standalone deployment ### Конфигурация - [DLP Правила](DLP-Rules) - настройка DLP политик diff --git a/docs/windows-deploy-startup-model.md b/docs/windows-deploy-startup-model.md new file mode 100644 index 0000000..3de5c74 --- /dev/null +++ b/docs/windows-deploy-startup-model.md @@ -0,0 +1,59 @@ +# Windows Deploy Startup Model + +## Supported startup models + +### 1. Multi-user RDP host + +Use this model on `SHARKON2025`-style hosts with multiple user sessions. + +- `ActivityWatch Launch [HOST_user]` tasks: + - `AtLogOn` + - `InteractiveToken` + - start only for users that currently have a real Windows session +- `ActivityWatch Recovery` task: + - `AtStartup` + - `SYSTEM` + - keeps only the global `worktime-session-collector` alive + - may re-trigger user launch tasks, but only for users whose sessions currently exist +- interactive collectors/watcher binaries belong to the user-session path, not to Session 0 + +Collector ownership in this model: + +- `aw-watcher-afk` and `aw-watcher-window`: user-session only +- `browser-domains-native-collector.ps1`: user-session only +- `email-outbound-collector.ps1`: user-session only +- `file-operations-collector.ps1`: user-session path +- `dlp-endpoint-signals-collector.ps1`: user-session path +- `worktime-session-collector.ps1`: single global process under recovery path + +### 2. Standalone service installer + +Use this model on single-user or headless hosts where Task Scheduler per-user orchestration is not the primary control plane. + +- `aw-standalone-service.ps1` runs as a loop/service wrapper +- Session 0 starts only collectors that are safe headless +- browser/email interactive collectors must not be assumed available from Session 0 + +Collector ownership in this model: + +- `dlp-endpoint-signals-collector.ps1`: allowed +- `file-operations-collector.ps1`: allowed +- `worktime-session-collector.ps1`: allowed +- `browser-domains-native-collector.ps1`: not reliable in Session 0 +- `email-outbound-collector.ps1`: not reliable in Session 0 +- `aw-watcher-afk` / `aw-watcher-window`: not a standalone Session 0 primitive + +## Non-supported mix + +Do not mix the two startup models on the same RDP host: + +- no permanent standalone-service loop together with per-user launch/recovery tasks +- no blind `Start-ScheduledTask` for all configured users +- no validation rule that treats users without sessions as failed collector startup + +## Hardening rules + +- start launch tasks only for users with real sessions +- keep only one global `worktime-session-collector` +- validate by session-aware expectations, not by “all configured users must currently run” +- keep `deploy_aw_windows.yml`, `deploy-ensemble.ps1`, `hardening-recovery.ps1`, and installer assumptions aligned diff --git a/proxmox/tsj_guardian_bot.py b/proxmox/tsj_guardian_bot.py index 9fff6ec..3ade17f 100644 --- a/proxmox/tsj_guardian_bot.py +++ b/proxmox/tsj_guardian_bot.py @@ -357,6 +357,10 @@ class TSJGuardianBot: "sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 " "'sudo -S systemctl restart aw-worktime-api.service'", ).strip() + self.aw_rus_dlp_heal_cmd = os.getenv( + "AW_RUS_DLP_HEAL_CMD", + "", + ).strip() self.aw_rus_host = os.getenv("AW_RUS_HOST", "SHARKON2025").strip() self.aw_rus_primary_user = os.getenv("AW_RUS_PRIMARY_USER", "USER1").strip() self.aw_rus_stale_sec = max(60, env_int("AW_RUS_STALE_SEC", 900)) @@ -2305,6 +2309,21 @@ class TSJGuardianBot: report.append("- heal: skipped (no DLP targets)") return True, report + cmd = (self.aw_rus_dlp_heal_cmd or "").strip() + if cmd: + try: + rc, out = self._run_shell(cmd, timeout_sec=120) + if rc != 0: + tail = (out or "").strip().splitlines()[-1:] or [f"rc={rc}"] + report.append(f"- dlp-heal: FAIL ({tail[0]})") + return False, report + report.append("- dlp-heal: configured recovery command OK") + return True, report + except Exception as exc: + report.append(f"- dlp-heal: FAIL ({exc})") + return False, report + + report.append("- dlp-heal: no configured recovery command, using freshness reseed fallback") ok = True for bucket_id in selected: btype, client, hostname = bucket_defs[bucket_id] diff --git a/scripts/dlp-health-check.py b/scripts/dlp-health-check.py index f82f1c3..6298ffa 100644 --- a/scripts/dlp-health-check.py +++ b/scripts/dlp-health-check.py @@ -206,6 +206,68 @@ def check_bucket_group( ) +def check_incident_buckets( + report: HealthReport, + api_base: str, + buckets: dict[str, Any], + max_age_seconds: int, +) -> None: + now = _now_utc() + prefix = "aw-dlp-incidents_" + matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) + + if not matched: + report.add( + "buckets:incidents", + "ok", + "no incident buckets yet", + prefix=prefix, + bucket_count=0, + ) + return + + ages: dict[str, int] = {} + unknown: list[str] = [] + stale: list[dict[str, Any]] = [] + for bucket_id in matched: + ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) + age = _age_seconds(ts, now) + if age is None: + unknown.append(bucket_id) + continue + ages[bucket_id] = age + if age > max_age_seconds: + stale.append({"bucket": bucket_id, "age_seconds": age}) + + if stale and not unknown: + report.add( + "buckets:incidents", + "ok", + "no recent incidents", + prefix=prefix, + bucket_count=len(matched), + max_age_seconds=max_age_seconds, + max_observed_age_seconds=max(ages.values()) if ages else None, + stale=stale, + unknown=[], + ) + return + + status = "ok" if not unknown else "warn" + summary = "incident buckets healthy" if not unknown else f"{len(unknown)} incident buckets without timestamp" + report.add( + "buckets:incidents", + status, + summary, + prefix=prefix, + bucket_count=len(matched), + max_age_seconds=max_age_seconds, + max_observed_age_seconds=max(ages.values()) if ages else None, + stale=stale, + unknown=unknown, + ) + + def _worktime_activity_map(api_base: str, buckets: dict[str, Any], max_age_seconds: int) -> dict[str, dict[str, Any]]: now = _now_utc() activity: dict[str, dict[str, Any]] = {} @@ -401,7 +463,7 @@ def main() -> int: report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) check_bucket_group(report, aw_api_base, buckets, "endpoint-signals", "aw-dlp-endpoint-signals_", args.max_age_seconds) check_file_operations_buckets(report, aw_api_base, buckets, args.max_age_seconds, args.strict_fileops) - check_bucket_group(report, aw_api_base, buckets, "incidents", "aw-dlp-incidents_", args.max_age_seconds * 24, severity_if_missing="warn", severity_if_stale="warn") + check_incident_buckets(report, aw_api_base, buckets, args.max_age_seconds * 24) check_endpoint_self_test_metrics(report, aw_api_base, buckets) except Exception as exc: report.add("aw:buckets-index", "fail", f"failed to inspect bucket index: {exc}") diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 769102c..ac620b9 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -311,6 +311,83 @@ function New-ActivityWatchUserTaskDefinitions { return @($result) } +function Get-ActivityWatchLoggedOnUsers { + $users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + try { + $lines = & quser.exe 2>$null + foreach ($line in @($lines)) { + $normalized = [string]$line + if ([string]::IsNullOrWhiteSpace($normalized)) { + continue + } + + $normalized = $normalized.TrimStart(' ', '>') + if ([string]::IsNullOrWhiteSpace($normalized)) { + continue + } + + if ($normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { + continue + } + + $parts = $normalized -split '\s+' + if ($parts.Count -lt 1) { + continue + } + + $user = [string]$parts[0] + if ([string]::IsNullOrWhiteSpace($user)) { + continue + } + + [void]$users.Add($user) + [void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$users.Add(('{0}\{1}' -f $env:USERDOMAIN, $user)) + } + } + } + catch { + } + + return @($users) +} + +function Test-ActivityWatchUserHasSession { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [string[]]$LoggedOnUsers + ) + + if ([string]::IsNullOrWhiteSpace($UserId)) { + return $false + } + + $candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + [void]$candidateIds.Add($UserId) + + $leafUser = $UserId + if ($leafUser -match '^[^\\]+\\(.+)$') { + $leafUser = $Matches[1] + [void]$candidateIds.Add($leafUser) + } + + [void]$candidateIds.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser)) + } + + foreach ($candidate in @($candidateIds)) { + if ($LoggedOnUsers -contains $candidate) { + return $true + } + } + + return $false +} + function Copy-ActivityWatchCollectorAssets { param( [Parameter(Mandatory = $true)] @@ -939,17 +1016,21 @@ function Get-RecoveryConfigPaths { return @(`$paths | Sort-Object -Unique) } -function Get-RecoveryTaskNames { +function Get-RecoveryTaskDefinitions { param([string[]]`$ConfigPaths) - `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + `$taskMap = [ordered]@{} foreach (`$candidatePath in @(`$ConfigPaths)) { try { `$config = Get-DeploymentConfig -Path `$candidatePath foreach (`$task in @(`$config.userTasks)) { `$taskName = [string]`$task.launchTaskName - if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { - [void]`$taskNames.Add(`$taskName) + `$userId = [string]`$task.userId + if (-not [string]::IsNullOrWhiteSpace(`$taskName) -and -not `$taskMap.Contains(`$taskName)) { + `$taskMap[`$taskName] = [pscustomobject]@{ + taskName = `$taskName + userId = `$userId + } } } } @@ -957,7 +1038,7 @@ function Get-RecoveryTaskNames { } } - return @(`$taskNames) + return @(`$taskMap.Values) } function New-RecoveryLock { @@ -990,11 +1071,23 @@ function New-RecoveryLock { } function Start-TaskIfNotRunning { - param([string]`$TaskName) + param( + [string]`$TaskName, + [string]`$UserId, + [string[]]`$LoggedOnUsers + ) if ([string]::IsNullOrWhiteSpace(`$TaskName)) { return } + if ([string]::IsNullOrWhiteSpace(`$UserId)) { + return + } + + if (-not (Test-UserHasSession -UserId `$UserId -LoggedOnUsers `$LoggedOnUsers)) { + return + } + try { `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue if (-not `$task) { @@ -1009,6 +1102,82 @@ function Start-TaskIfNotRunning { } } +function Get-LoggedOnUsers { + `$users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + try { + `$lines = & quser.exe 2>`$null + foreach (`$line in @(`$lines)) { + `$normalized = [string]`$line + if ([string]::IsNullOrWhiteSpace(`$normalized)) { + continue + } + + `$normalized = `$normalized.TrimStart(' ', '>') + if ([string]::IsNullOrWhiteSpace(`$normalized)) { + continue + } + + if (`$normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { + continue + } + + `$parts = `$normalized -split '\s+' + if (`$parts.Count -lt 1) { + continue + } + + `$user = [string]`$parts[0] + if ([string]::IsNullOrWhiteSpace(`$user)) { + continue + } + + [void]`$users.Add(`$user) + [void]`$users.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$user)) + if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) { + [void]`$users.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$user)) + } + } + } + catch { + } + + return @(`$users) +} + +function Test-UserHasSession { + param( + [string]`$UserId, + [string[]]`$LoggedOnUsers + ) + + if ([string]::IsNullOrWhiteSpace(`$UserId)) { + return `$false + } + + `$candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + [void]`$candidateIds.Add(`$UserId) + + `$leafUser = `$UserId + if (`$leafUser -match '^[^\\]+\\(.+)$') { + `$leafUser = `$Matches[1] + [void]`$candidateIds.Add(`$leafUser) + } + + [void]`$candidateIds.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$leafUser)) + if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) { + [void]`$candidateIds.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$leafUser)) + } + + foreach (`$candidate in @(`$candidateIds)) { + if (`$LoggedOnUsers -contains `$candidate) { + return `$true + } + } + + return `$false +} + function Test-CollectorRunningGlobal { param([string]`$ScriptPath) if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { @@ -1058,11 +1227,12 @@ try { try { `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath `$config = Get-DeploymentConfig -Path `$ConfigPath + `$loggedOnUsers = Get-LoggedOnUsers `$stateRoot = [string]`$config.paths.stateRoot `$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } Start-CollectorScriptGlobalIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath - foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { - Start-TaskIfNotRunning -TaskName `$taskName + foreach (`$taskDef in Get-RecoveryTaskDefinitions -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskDef.taskName -UserId `$taskDef.userId -LoggedOnUsers `$loggedOnUsers } if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { @@ -1343,8 +1513,12 @@ function Start-ActivityWatchTasks { [string]$RecoveryTaskName = 'ActivityWatch Recovery' ) + $loggedOnUsers = Get-ActivityWatchLoggedOnUsers + foreach ($definition in $TaskDefinitions) { - Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + if (Test-ActivityWatchUserHasSession -UserId $definition.UserId -LoggedOnUsers $loggedOnUsers) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } } Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue diff --git a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss index daff588..9568185 100644 --- a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss +++ b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss @@ -11,6 +11,10 @@ #define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus" #define AwDefaultZipName "activitywatch-v0.13.2-windows-x86_64.zip" +; 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. + [Setup] AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9} AppName={#MyAppName} diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 17d9e19..99e642d 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -89,6 +89,62 @@ if ($config.userTasks) { $taskNames += [string]$config.recovery.taskName $taskNames = $taskNames | Sort-Object -Unique +function Get-LoggedOnUsers { + $users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + try { + $lines = & quser.exe 2>$null + foreach ($line in @($lines)) { + $normalized = [string]$line + if ([string]::IsNullOrWhiteSpace($normalized)) { continue } + $normalized = $normalized.TrimStart(' ', '>') + if ([string]::IsNullOrWhiteSpace($normalized)) { continue } + if ($normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { continue } + $parts = $normalized -split '\s+' + if ($parts.Count -lt 1) { continue } + $user = [string]$parts[0] + if ([string]::IsNullOrWhiteSpace($user)) { continue } + [void]$users.Add($user) + [void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$users.Add(('{0}\{1}' -f $env:USERDOMAIN, $user)) + } + } + } + catch { + } + return @($users) +} + +function Test-UserHasSession { + param( + [string]$UserId, + [string[]]$LoggedOnUsers + ) + if ([string]::IsNullOrWhiteSpace($UserId)) { return $false } + $candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + [void]$candidateIds.Add($UserId) + $leafUser = $UserId + if ($leafUser -match '^[^\\]+\\(.+)$') { + $leafUser = $Matches[1] + [void]$candidateIds.Add($leafUser) + } + [void]$candidateIds.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser)) + } + foreach ($candidate in @($candidateIds)) { + if ($LoggedOnUsers -contains $candidate) { return $true } + } + return $false +} + +$loggedOnUsers = Get-LoggedOnUsers +$sessionBoundUsers = @( + @($config.userTasks) | + Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } | + ForEach-Object { [string]$_.userId } +) + $tasks = @( foreach ($taskName in $taskNames) { $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 @@ -111,6 +167,7 @@ $tasks = @( $serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port $uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique) +$sessionBoundCollectorsExpected = ($sessionBoundUsers.Count -gt 0) $result = [ordered]@{ generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') configPath = $ConfigPath @@ -128,10 +185,12 @@ $result = [ordered]@{ } processes = [ordered]@{ expected = $processNames + sessionBoundUsers = $sessionBoundUsers list = @($runningProcesses) sessionCollectors = @($sessionCollectorProcesses) ok = [bool]( ( + (-not $sessionBoundCollectorsExpected) -or ($processNames.Count -eq 0) -or ($uniqueRunningProcessNames.Count -ge $processNames.Count) ) -and