diff --git a/ansible/audit_cryptopro_windows.yml b/ansible/audit_cryptopro_windows.yml new file mode 100644 index 0000000..e286fb3 --- /dev/null +++ b/ansible/audit_cryptopro_windows.yml @@ -0,0 +1,71 @@ +--- +- name: Audit CryptoPro signer readiness on Windows/RDP host + hosts: aw_windows + gather_facts: false + + vars: + aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus" + aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" + aw_windows_cryptopro_audit_remote_path: "{{ aw_windows_state_root }}\\cryptopro_audit_ansible.json" + aw_windows_cryptopro_audit_local_dir: "/tmp/aw-rus-cryptopro-audit-{{ lookup('env','USER') | default('ansible', true) }}" + aw_windows_expected_signers: + - Администратор + - user1 + - user2 + - user3 + - user4 + - user5 + + tasks: + - name: Создать каталог toolkit на Windows host + ansible.windows.win_file: + path: "{{ aw_windows_deploy_root }}\\windows" + state: directory + + - name: Загрузить CryptoPro audit script + ansible.windows.win_copy: + src: "{{ playbook_dir | dirname }}/windows/audit-cryptopro.ps1" + dest: "{{ aw_windows_deploy_root }}\\windows\\audit-cryptopro.ps1" + + - name: Нормализовать кодировку PowerShell audit script + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $path = "{{ aw_windows_deploy_root }}\windows\audit-cryptopro.ps1" + $encIn = New-Object System.Text.UTF8Encoding($false) + $encOut = New-Object System.Text.UTF8Encoding($true) + $text = [System.IO.File]::ReadAllText($path, $encIn) + [System.IO.File]::WriteAllText($path, $text, $encOut) + + - name: Выполнить CryptoPro audit и сохранить JSON report + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $expected = @( + {% for user in aw_windows_expected_signers %} + "{{ user }}"{% if not loop.last %},{% endif %} + {% endfor %} + ) + $report = & "{{ aw_windows_deploy_root }}\windows\audit-cryptopro.ps1" ` + -ExpectedUsers $expected ` + -IncludeUnexpectedProfiles + $report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_cryptopro_audit_remote_path }}" -Encoding utf8 + + - name: Создать локальный каталог для CryptoPro audit reports + ansible.builtin.file: + path: "{{ aw_windows_cryptopro_audit_local_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + + - name: Забрать CryptoPro audit report + ansible.builtin.fetch: + src: "{{ aw_windows_cryptopro_audit_remote_path }}" + dest: "{{ aw_windows_cryptopro_audit_local_dir }}/{{ inventory_hostname }}-cryptopro-audit.json" + flat: true + + - name: Показать путь к CryptoPro audit report + ansible.builtin.debug: + msg: + - "CryptoPro audit completed on {{ inventory_hostname }}." + - "Report: {{ aw_windows_cryptopro_audit_local_dir }}/{{ inventory_hostname }}-cryptopro-audit.json" diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index e7e596f..46f3eb7 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -63,6 +63,8 @@ - "{{ aw_server_data_dir }}/.config/activitywatch" - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" - "{{ aw_server_data_dir }}/backups" + - "{{ aw_rus_health_state_dir }}" + - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" - /etc/activitywatch - "{{ aw_bootstrap_dir }}" @@ -88,6 +90,8 @@ - "{{ aw_server_data_dir }}/.config/activitywatch" - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" - "{{ aw_server_data_dir }}/backups" + - "{{ aw_rus_health_state_dir }}" + - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" - name: (Check mode) Пропустить установку релиза ActivityWatch @@ -334,6 +338,10 @@ AW_EXPECT_ALWAYS_ACTIVE_PATTERN={{ aw_server_always_active_pattern | default('') }} AW_EXPECT_LANDINGPAGE={{ aw_server_landingpage | default('') }} AW_HEALTH_STRICT_FILEOPS={{ aw_health_strict_fileops | default(0) }} + AW_MONITORED_WINDOWS_HOST={{ aw_monitored_windows_host }} + AW_MONITORED_WINDOWS_HOSTNAME={{ aw_monitored_windows_hostname }} + AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} + AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} - name: Создать каталог DLP policy engine ansible.builtin.file: @@ -707,6 +715,14 @@ group: root mode: "0755" + - name: Установить скрипт aw-rus-healthd + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.py" + dest: /usr/local/bin/aw-rus-healthd.py + owner: root + group: root + mode: "0755" + - name: Установить systemd unit AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service" @@ -739,6 +755,22 @@ group: root mode: "0644" + - name: Установить systemd unit AW-RUS healthd + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.service" + dest: /etc/systemd/system/aw-rus-healthd.service + owner: root + group: root + mode: "0644" + + - name: Установить systemd timer AW-RUS healthd + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.timer" + dest: /etc/systemd/system/aw-rus-healthd.timer + owner: root + group: root + mode: "0644" + - name: Установить systemd unit AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.service" @@ -779,6 +811,12 @@ ansible.builtin.systemd: daemon_reload: true + - name: Включить таймер AW-RUS healthd + ansible.builtin.systemd: + name: aw-rus-healthd.timer + enabled: true + state: started + - name: Включить и перезапустить DLP policy engine ansible.builtin.systemd: name: aw-dlp-policy-engine.service @@ -1524,31 +1562,21 @@ executable: /bin/bash changed_when: false - - name: Post-deploy health gate (aw-health-check) + - name: Post-deploy health gate (aw-rus-healthd) when: - not ansible_check_mode - aw_server_post_deploy_health_check_enabled | default(true) | bool block: - - name: Запустить /usr/local/bin/aw-health-check + - name: Запустить /usr/local/bin/aw-rus-healthd.py --json ansible.builtin.command: - cmd: /usr/local/bin/aw-health-check + cmd: /usr/local/bin/aw-rus-healthd.py --json register: aw_post_deploy_health changed_when: false - - name: Показать результат aw-health-check + - name: Показать результат aw-rus-healthd ansible.builtin.debug: msg: "{{ aw_post_deploy_health.stdout }}" - - name: Запустить /usr/local/bin/dlp-health-check --json - ansible.builtin.command: - cmd: /usr/local/bin/dlp-health-check --json - register: dlp_post_deploy_health - changed_when: false - - - name: Показать результат dlp-health-check - ansible.builtin.debug: - msg: "{{ dlp_post_deploy_health.stdout }}" - handlers: - name: Перезагрузить systemd ansible.builtin.systemd: diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index c50b6eb..52e924d 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -107,6 +107,7 @@ - deploy-ensemble.ps1 - hardening-recovery.ps1 - rebuild-worktime-tasks.ps1 + - audit-cryptopro.ps1 - validate-deployment.ps1 - web-category-rules.example.json - dlp-policy.example.json diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index bdac96e..fada2ca 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -25,6 +25,10 @@ aw_dlp_influx_hosts: "SHARKON2025" aw_dlp_influx_lookback_days: 30 aw_dlp_influx_event_limit: 2000 aw_dlp_influx_token: "" +aw_monitored_windows_host: "192.168.100.18" +aw_monitored_windows_hostname: "SHARKON2025" +aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" +aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" aw_repo_root: "{{ playbook_dir | dirname }}" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index cf62c70..b0362ba 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -23,6 +23,10 @@ aw_dlp_influx_bucket: "aw_metrics" aw_dlp_influx_hosts: "SHARKON2025" aw_dlp_influx_lookback_days: 30 aw_dlp_influx_event_limit: 2000 +aw_monitored_windows_host: "192.168.100.18" +aw_monitored_windows_hostname: "SHARKON2025" +aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" +aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" aw_repo_root: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian" diff --git a/ansible/inventory.example.ini b/ansible/inventory.example.ini index 62669da..8c8b1b0 100644 --- a/ansible/inventory.example.ini +++ b/ansible/inventory.example.ini @@ -6,7 +6,7 @@ aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22 [aw_windows] # Примечание: в русифицированных Windows часто нужен "Администратор", а не "Administrator". -win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore +win-node1 ansible_host=192.168.100.18 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore [aw_pfsense_pollers] # pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22 diff --git a/ansible/inventory.ini b/ansible/inventory.ini index e883a31..47d8860 100644 --- a/ansible/inventory.ini +++ b/ansible/inventory.ini @@ -1,5 +1,8 @@ +[proxmox] +pve-detmir ansible_host=10.10.10.2 ansible_user=igor ansible_password=04091968 ansible_connection=ssh ansible_become=true ansible_become_method=sudo ansible_become_password=04091968 ansible_port=22 + [aw_server] aw-prod ansible_host=10.10.10.13 ansible_user=igor ansible_password=04091968 ansible_connection=ssh ansible_become=true ansible_become_method=sudo ansible_become_password=04091968 [aw_windows] -rdp-prod ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=Sergei2009@ ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore +rdp-prod ansible_host=192.168.100.18 ansible_user=Администратор ansible_password=Sergei2009@ ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore diff --git a/aw-server/apply_webui_ru_patch.sh b/aw-server/apply_webui_ru_patch.sh index 6160cce..343ee06 100755 --- a/aw-server/apply_webui_ru_patch.sh +++ b/aw-server/apply_webui_ru_patch.sh @@ -122,15 +122,15 @@ content = re.sub( ) content = content.replace( "", - f'', + ( + f'' + f'' + ), 1, ) content = content.replace( "", - ( - f'' - f'' - ), + f'', 1, ) if 'id="aw-report-links"' not in content: diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index 8538e81..0e7b585 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -1,6 +1,10 @@ (function () { - window.__awRuPatchVersion = "template-v12-activity-heading-ru"; - document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru"); + if (window.__awRuPatchBootstrapped) { + return; + } + window.__awRuPatchBootstrapped = true; + window.__awRuPatchVersion = "template-v13-category-builder-early-fix"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-category-builder-early-fix"); const exact = new Map([ ["ActivityWatch", "АктивВотч"], @@ -1687,6 +1691,7 @@ .filter(Boolean) .filter(function (host) { return !/^unknown$/i.test(host); }); if (isLikelyClientHost(settingsHost) && hosts.indexOf(settingsHost) >= 0) return settingsHost; + if (isLikelyClientHost(settingsHost) && !hosts.length) return settingsHost; hosts.sort(); return hosts[0] || ""; } @@ -1749,20 +1754,25 @@ if (networkPatchesInstalled) return; networkPatchesInstalled = true; - const originalFetch = window.fetch ? window.fetch.bind(window) : null; - if (originalFetch) { - window.fetch = function (input, init) { + const originalFetch = window.fetch; + if (typeof originalFetch === "function" && !originalFetch.__awRuCategoryBuilderPatched) { + const patchedFetch = function (input, init) { + let nextInput = input; + let nextInit = init; try { - const url = typeof input === "string" ? input : String(input && input.url || ""); - if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") { - init = Object.assign({}, init, { - body: rewriteUnknownCategoryBuilderQueryBody(init.body) + const url = typeof nextInput === "string" ? nextInput : String(nextInput && nextInput.url || ""); + if (/\/api\/0\/query\/?$/i.test(url) && nextInit && typeof nextInit.body === "string") { + nextInit = Object.assign({}, nextInit, { + body: rewriteUnknownCategoryBuilderQueryBody(nextInit.body) }); } } catch (error) { } - return originalFetch(input, init); + return originalFetch.call(this, nextInput, nextInit); }; + patchedFetch.__awRuCategoryBuilderPatched = true; + patchedFetch.__awRuOriginalFetch = originalFetch; + window.fetch = patchedFetch; } if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) { @@ -1838,6 +1848,14 @@ } } + function primeCategoryBuilderEarlyFix() { + const hash = window.location.hash || ""; + if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(hash)) return; + ensureSettingsHost(); + ensureHostGroupsData().catch(function () {}); + normalizeCategoryBuilderUnknownHostRefs(); + } + function patchActivityHeading(root) { const heading = root.querySelector("h3"); if (!heading) return; @@ -1920,6 +1938,9 @@ scheduleApplyPatch(); }); + installCategoryBuilderNetworkPatch(); + primeCategoryBuilderEarlyFix(); + window.addEventListener("load", function () { applyPatch(); attachObserver(); diff --git a/aw-server/aw-rus-healthd.py b/aw-server/aw-rus-healthd.py new file mode 100644 index 0000000..0423403 --- /dev/null +++ b/aw-server/aw-rus-healthd.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib import request + + +ENV_FILE = Path("/etc/activitywatch/aw-server.env") + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'").strip('"') + os.environ.setdefault(key, value) + + +def env(name: str, default: str) -> str: + value = os.environ.get(name) + return value if value not in (None, "") else default + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +def parse_ts(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + except ValueError: + return None + + +def age_seconds(ts: datetime | None, now: datetime) -> int | None: + if ts is None: + return None + return max(0, int((now - ts).total_seconds())) + + +def http_json(url: str, timeout: int = 10) -> Any: + with request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def run_command(cmd: list[str]) -> tuple[int, str]: + proc = subprocess.run( + cmd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return proc.returncode, proc.stdout.strip() + + +def tcp_connect(host: str, port: int, timeout: float) -> tuple[bool, str]: + try: + with socket.create_connection((host, port), timeout=timeout): + return True, "connected" + except OSError as exc: + return False, str(exc) + + +@dataclass +class CheckResult: + name: str + status: str + summary: str + details: dict[str, Any] + + +class Report: + def __init__(self) -> None: + self.results: list[CheckResult] = [] + + def add(self, name: str, status: str, summary: str, **details: Any) -> None: + self.results.append(CheckResult(name=name, status=status, summary=summary, details=details)) + + @property + def ok(self) -> bool: + return not any(item.status == "fail" for item in self.results) + + def as_dict(self) -> dict[str, Any]: + counts = {"ok": 0, "warn": 0, "fail": 0} + for item in self.results: + counts[item.status] = counts.get(item.status, 0) + 1 + return { + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + "ok": self.ok, + "counts": counts, + "results": [ + { + "name": item.name, + "status": item.status, + "summary": item.summary, + "details": item.details, + } + for item in self.results + ], + } + + def render_text(self) -> str: + icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"} + lines = ["=== AW-RUS Health ===", f"Timestamp: {now_utc().isoformat().replace('+00:00', 'Z')}", ""] + for item in self.results: + lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}") + lines.append("") + payload = self.as_dict() + lines.append( + "Counts: ok={ok} warn={warn} fail={fail}".format( + ok=payload["counts"]["ok"], + warn=payload["counts"]["warn"], + fail=payload["counts"]["fail"], + ) + ) + lines.append(f"Overall: {'OK' if payload['ok'] else 'FAIL'}") + return "\n".join(lines) + + +def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: + events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20") + if isinstance(events, list) and events: + events = [item for item in events if isinstance(item, dict)] + if not events: + return None + events.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return events[0] + return None + + +def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]: + now = now_utc() + if not event: + return {"fresh": False, "active": False, "age_seconds": None, "timestamp": None} + ts = parse_ts(event.get("timestamp")) + age = age_seconds(ts, now) + data = event.get("data") or {} + is_fresh = age is not None and age <= max_age_seconds + is_active = bool(is_fresh and data.get("active")) + return { + "fresh": bool(is_fresh), + "active": bool(is_active), + "age_seconds": age, + "timestamp": event.get("timestamp"), + "data": data, + } + + +def bucket_health( + api_base: str, + bucket_id: str, + max_age_seconds: int, + missing_status: str, + stale_status: str, +) -> tuple[str, str, dict[str, Any]]: + try: + event = latest_bucket_event(api_base, bucket_id) + except Exception as exc: + return "fail", f"bucket query failed: {exc}", {"bucket": bucket_id} + + if not event: + return missing_status, "no events", {"bucket": bucket_id} + + ts = parse_ts(event.get("timestamp")) + age = age_seconds(ts, now_utc()) + details = {"bucket": bucket_id, "timestamp": event.get("timestamp"), "age_seconds": age} + if age is None: + return "warn", "timestamp parse failed", details + if age > max_age_seconds: + return stale_status, f"stale ({age}s)", details + return "ok", f"fresh ({age}s)", details + + +def latest_validation_report(validation_dir: Path) -> Path | None: + candidates = sorted( + (path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + return candidates[0] if candidates else None + + +def write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: + handle.write(content) + tmp_name = handle.name + os.replace(tmp_name, path) + + +def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = False) -> None: + if not Path(cmd[0]).exists(): + report.add(name, "warn", "binary missing", command=cmd) + return + rc, output = run_command(cmd) + details: dict[str, Any] = {"command": cmd, "returncode": rc} + if json_mode: + try: + details["payload"] = json.loads(output) if output else {} + except json.JSONDecodeError: + details["raw_output"] = output + report.add(name, "fail", "invalid JSON output", **details) + return + else: + details["output"] = output + report.add(name, "ok" if rc == 0 else "fail", "passed" if rc == 0 else "failed", **details) + + +def main() -> int: + load_env_file(ENV_FILE) + + parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator") + parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600")) + parser.add_argument("--worktime-api", default=env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")) + parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18")) + parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025")) + parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) + parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation")) + parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) + parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) + parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "604800"))) + parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) + parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + report = Report() + aw_api_base = args.aw_server.rstrip("/") + if not aw_api_base.endswith("/api/0"): + aw_api_base = aw_api_base.rstrip("/") + "/api/0" + + check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"]) + check_wrapper(report, "wrapper:dlp-health-check", ["/usr/local/bin/dlp-health-check", "--json"], json_mode=True) + + try: + info = http_json(f"{aw_api_base}/info") + report.add("http:aw-server", "ok", "activitywatch API responded", version=info.get("version")) + except Exception as exc: + report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info") + + try: + payload = http_json(args.worktime_api.rstrip("/") + "/reports/worktime/today") + rows = len(payload) if isinstance(payload, list) else None + report.add("http:worktime-api", "ok", "worktime API responded", rows=rows) + except Exception as exc: + report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api) + + for port, label in ((5985, "winrm"), (3389, "rdp")): + ok, message = tcp_connect(args.rdp_host, port, args.tcp_timeout_seconds) + report.add(f"tcp:{label}", "ok" if ok else "fail", message if ok else f"unreachable: {message}", host=args.rdp_host, port=port) + + try: + buckets = http_json(f"{aw_api_base}/buckets") + if not isinstance(buckets, dict): + raise RuntimeError("bucket index is not a dict") + report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) + except Exception as exc: + report.add("aw:buckets-index", "fail", f"failed to load bucket index: {exc}") + buckets = {} + + host = args.rdp_hostname + worktime_bucket = f"aw-worktime-sessions_{host}" + worktime_event = None + if buckets: + try: + worktime_event = latest_bucket_event(aw_api_base, worktime_bucket) + except Exception: + worktime_event = None + activity = host_activity_from_worktime(worktime_event, args.session_max_age_seconds) + if worktime_event: + status, summary, details = bucket_health( + aw_api_base, + worktime_bucket, + args.session_max_age_seconds, + missing_status="fail", + stale_status="fail", + ) + details["host_activity"] = activity + report.add("bucket:worktime-sessions", status, summary, **details) + else: + report.add("bucket:worktime-sessions", "fail", "no events", bucket=worktime_bucket, host_activity=activity) + + interactive_required = bool(activity["active"]) + for bucket_name, label in ( + ("aw-watcher-afk", "bucket:afk"), + ("aw-watcher-window", "bucket:window"), + ("aw-dlp-endpoint-signals", "bucket:endpoint-signals"), + ): + status, summary, details = bucket_health( + aw_api_base, + f"{bucket_name}_{host}", + args.interactive_max_age_seconds, + missing_status="fail" if interactive_required else "warn", + stale_status="fail" if interactive_required else "warn", + ) + details["interactive_required"] = interactive_required + details["host_activity"] = activity + report.add(label, status, summary, **details) + + session_status, session_summary, session_details = bucket_health( + aw_api_base, + f"aw-session-events_{host}", + args.session_events_max_age_seconds, + missing_status="fail", + stale_status="warn", + ) + report.add("bucket:session-events", session_status, session_summary, **session_details) + + validation_dir = Path(args.validation_dir) + validation_report = latest_validation_report(validation_dir) + if validation_report is None: + report.add("validation:windows", "warn", "no validation report snapshot", directory=str(validation_dir)) + else: + try: + payload = json.loads(validation_report.read_text(encoding="utf-8-sig")) + age = age_seconds(datetime.fromtimestamp(validation_report.stat().st_mtime, tz=UTC), now_utc()) + if age is not None and age > args.validation_max_age_seconds: + report.add( + "validation:windows", + "warn", + f"validation snapshot is stale ({age}s)", + path=str(validation_report), + overall_ok=payload.get("overallOk"), + failed_sections=payload.get("summary", {}).get("failedSections", []), + ) + elif payload.get("overallOk") is True: + report.add("validation:windows", "ok", "validation snapshot OK", path=str(validation_report), age_seconds=age) + else: + report.add( + "validation:windows", + "fail", + "validation snapshot reports failure", + path=str(validation_report), + age_seconds=age, + failed_sections=payload.get("summary", {}).get("failedSections", []), + ) + except Exception as exc: + report.add("validation:windows", "fail", f"invalid validation snapshot: {exc}", path=str(validation_report)) + + payload = report.as_dict() + state_dir = Path(args.state_dir) + write_atomic(state_dir / "aw-rus-health.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + write_atomic(state_dir / "aw-rus-health.txt", report.render_text() + "\n") + + if args.json: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(report.render_text()) + return 0 if payload["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aw-server/aw-rus-healthd.service b/aw-server/aw-rus-healthd.service new file mode 100644 index 0000000..015a906 --- /dev/null +++ b/aw-server/aw-rus-healthd.service @@ -0,0 +1,14 @@ +[Unit] +Description=AW-RUS unified health orchestrator +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-rus-healthd.py +User=root +Group=root +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-rus-healthd diff --git a/aw-server/aw-rus-healthd.timer b/aw-server/aw-rus-healthd.timer new file mode 100644 index 0000000..bc6f25b --- /dev/null +++ b/aw-server/aw-rus-healthd.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AW-RUS unified health orchestrator every 2 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=2min +Unit=aw-rus-healthd.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 14fc306..1d43206 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -37,6 +37,10 @@ AW_EXPECT_START_OF_DAY=00:00 AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ AW_HEALTH_STRICT_FILEOPS=0 +AW_MONITORED_WINDOWS_HOST=192.168.100.18 +AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 +AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health +AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation # Integration Test Configuration AW_INTEGRATION_TEST_ENABLED=false diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh index 4ac7a30..6a28f35 100755 --- a/aw-server/install_aw_server.sh +++ b/aw-server/install_aw_server.sh @@ -29,6 +29,9 @@ WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service" WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service" WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer" +HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py" +HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service" +HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer" for var_name in "${required_vars[@]}"; do if [[ -z "${!var_name:-}" ]]; then @@ -54,6 +57,7 @@ install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/release install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR" install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR" install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR" +install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR/health/windows-validation" tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT @@ -124,6 +128,23 @@ if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true fi +if [[ -f "$HEALTHD_SRC" ]]; then + install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py +fi + +if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then + install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service +fi + +if [[ -f "$HEALTHD_TIMER_SRC" ]]; then + install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer + systemctl daemon-reload + systemctl enable aw-rus-healthd.timer + systemctl restart aw-rus-healthd.timer + systemctl start aw-rus-healthd.service || true + systemctl --no-pager --full status aw-rus-healthd.timer || true +fi + for _ in $(seq 1 20); do if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then break diff --git a/aw-server/test_aw_rus_healthd.py b/aw-server/test_aw_rus_healthd.py new file mode 100644 index 0000000..c679602 --- /dev/null +++ b/aw-server/test_aw_rus_healthd.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +import importlib.util +import sys +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("aw-rus-healthd.py") +SPEC = importlib.util.spec_from_file_location("aw_rus_healthd", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +def test_host_activity_marks_recent_active_session(): + MODULE.now_utc = lambda: MODULE.parse_ts("2026-05-18T10:05:00Z") + activity = MODULE.host_activity_from_worktime( + { + "timestamp": "2026-05-18T10:00:00Z", + "data": {"active": True}, + }, + max_age_seconds=900, + ) + assert activity["active"] is True + assert activity["fresh"] is True + + +def test_bucket_health_reports_missing_events_without_crash(monkeypatch): + monkeypatch.setattr(MODULE, "latest_bucket_event", lambda api_base, bucket_id: None) + status, summary, details = MODULE.bucket_health( + "http://127.0.0.1:5600/api/0", + "aw-watcher-window_SHARKON2025", + 900, + missing_status="warn", + stale_status="fail", + ) + assert status == "warn" + assert summary == "no events" + assert details["bucket"] == "aw-watcher-window_SHARKON2025" diff --git a/check-aw-data.sh b/check-aw-data.sh index 4d0f960..7099697 100644 --- a/check-aw-data.sh +++ b/check-aw-data.sh @@ -1,7 +1,7 @@ #!/bin/bash # check-aw-data.sh - Проверка сбора данных ActivityWatch с RDP-сервера SHARKON2025 # Сервер: 10.10.10.13:5600 -# Хост-источник: 192.168.100.21 (SHARKON2025) +# Хост-источник: 192.168.100.18 (SHARKON2025) SERVER="http://10.10.10.13:5600" HOSTNAME_FILTER="SHARKON2025" diff --git a/check-aw-full.sh b/check-aw-full.sh index ce31168..e24d83e 100644 --- a/check-aw-full.sh +++ b/check-aw-full.sh @@ -1,11 +1,11 @@ #!/bin/bash # check-aw-full.sh - Полная проверка ActivityWatch: сервер + RDP-хост # Сервер: 10.10.10.13:5600 -# RDP-хост: 192.168.100.21 (SHARKON2025) +# RDP-хост: 192.168.100.18 (SHARKON2025) SERVER="http://10.10.10.13:5600" HOSTNAME_FILTER="SHARKON2025" -RDP_HOST="192.168.100.21" +RDP_HOST="192.168.100.18" NOW=$(date -u +%s) # Цвета diff --git a/docs/runbook.md b/docs/runbook.md index 56aab50..ff51b21 100755 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -106,7 +106,7 @@ curl -fsS 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-incidents_SHARKON2025/even Цель: подтвердить, что при недоступности AW API события не теряются, а буферизуются в локальной очереди и автоматически отправляются после восстановления связи. -На RDP-хосте (`192.168.100.21`) в PowerShell под администратором: +На RDP-хосте (`192.168.100.18`) в PowerShell под администратором: 1) Проверить/обнулить очереди: @@ -207,14 +207,14 @@ chmod 600 /tmp/sharkon_ru.auth 3. Запустить recovery task: ```sh -wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \ +wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.18 \ "powershell -NoProfile -Command \"Start-ScheduledTask -TaskName 'ActivityWatch Recovery'\"" ``` 4. Запустить все launch tasks: ```sh -wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \ +wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.18 \ "powershell -NoProfile -Command \"Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch Launch *' | ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }\"" ``` @@ -284,3 +284,30 @@ systemctl restart activitywatch-server.service - UI открывается; - русификация присутствует; - rollback-путь понятен оператору. + +## Аудит CryptoPro и готовности подписантов + +Для повторяемой проверки сертификатов подписантов и встроенных лицензий CryptoPro: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible +ansible-playbook -i inventory.ini audit_cryptopro_windows.yml +``` + +Итоговый JSON-отчёт сохраняется локально в: + +```text +/tmp/aw-rus-cryptopro-audit-/rdp-prod-cryptopro-audit.json +``` + +Отчёт содержит матрицу по профилям: + +- `requestedUser` +- `profileUser` +- `thumbprint` +- `subject` +- `hasPrivateKey` +- `embeddedLicenseOk` +- `embeddedLicenseStatus` +- `container` если виден +- `actionNeeded` diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz index 1e4c1c9..967b47c 100644 Binary files a/install-kit-awindows-20260427-211240.tar.gz and b/install-kit-awindows-20260427-211240.tar.gz differ diff --git a/install-kit-awindows-20260427-211240.zip b/install-kit-awindows-20260427-211240.zip index 02d4637..5d503e4 100644 Binary files a/install-kit-awindows-20260427-211240.zip and b/install-kit-awindows-20260427-211240.zip differ diff --git a/install-kit-awindows-20260427-211240/MANIFEST.txt b/install-kit-awindows-20260427-211240/MANIFEST.txt index 253acb5..6f2a380 100644 --- a/install-kit-awindows-20260427-211240/MANIFEST.txt +++ b/install-kit-awindows-20260427-211240/MANIFEST.txt @@ -1,41 +1,47 @@ -0754dcba7c651d67a40e09446868d2fcae623a100d4fb01794dd96272d353b49 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt -089595753398c8b82980919d230dafac548c3ba36817f5c96a68051582f9faa3 install-kit-awindows-20260427-211240/ansible/README.md +f3dde1e6d1532804379faf7e395deaf95cf3e0b97769d425f8a69f4572de2a2f install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt +a08ccceada7a21e4396a640e54a354e4d4d760980ec3f18f1bc7f543cd8f4cc6 install-kit-awindows-20260427-211240/ansible/README.md 412bb766bbf0791c3593f38daa771d5d0aa58cc1f2d3c9010fcd4588d0fe87df install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml -90ac38a33918fcd3620f078f51fbf7c6a9d7f8fd1a34d16b38cb3ac45678b0d7 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml -a649eeb57472fb259d248983c486f0474bfb7317600feb98f76a80e7af7e4549 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml -531bfec24f86d28a06e5c0d73005489a818e2c7b1cce1d98f524a3f76802b8ee install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +00d16de62df9d91cd375cbe70034ec97da14601ab5285ca93e5b297150f02d34 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +ce1cbc35087006292e93a7e0d1706bc0ac71f8d9f2e60bbdc1c3a8ecea0d34f0 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +eedb12a2be920c6bec267162c91e106365af5c009426cefe84c032c2f9d9339d install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml 95696c243ab331f06e77a40a9800c4b6668de77675ebbdf2ef54ae49e1b18874 install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml 35a33c8a1c75ded5e85c6b79e0b3efde07959ff61ee5f66d83b7e0c2abe87fc5 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox.example.yml -7c468f252e328fd3bb7ee776a45feea88efc4b438dfef55b832da4eea867aaf2 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml -195e7dbdb91f4e77db3263bd0812301ddc912a37ba1519688768bb64a2887567 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml -2e4e94d90143923fefd3ec1257d0ec57daa3e96450d85471bc2c418aae37e105 install-kit-awindows-20260427-211240/ansible/inventory.example.ini -d9e43352fd6bdb647db9754ab2c557b6bb27f88f51b2bf23d8c19227e535e7b9 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml -f3d34547f345ad1c635ee44613a60e7f08479f9ae4d1111810bc7e5968bfb084 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml -ef4ed198745777bd1227170241b2db21dc853685f962d6116241c55216b466d6 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml -a50dbadbf619342c2178e255b68f69a36503756daf80eedd9170311c63964f2e install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service -2dbf55d4a8f204ebdc97af926d90435932c0aad7a2431e2b9987e47c5abf71a9 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh -07d4e583f6e9757a11f01558e1f15cfd73c4d82695f1768204f2f50621712168 install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json -09605da1754abb0dc0446825580b57ebad6e646dc670f9f072fce1489e88dd43 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js -7c5952f8f0a8590e849ea8381bfcd7059b138250bca8551bd5625f395eb66cd8 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +5da847b74fac52e0fec2f60e134f4377cfa1581e026b291d3d3ac362371f6e49 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +7189b5205bd25313db54e5be027b0d066199e6ae34ad74be2095a1691adaf5e3 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml +fea0574d7eb98ce24a1e7025afb9837c6241180095d21eaa74892225305d05a3 install-kit-awindows-20260427-211240/ansible/inventory.example.ini +8a5e4923c0f581dd4fbb32549ee7ab45ba506260056da923ba86d9f1b1081714 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml +18928adcaef5d01b4c621b48f5559383c8b749ef182fd3710f10b222a164f8b7 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +a74a49371e889dc3ea404534a939f32f2dac940d8902d20770590951ab67d532 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml +ba16fe9e267194459a6082045a387acb828a1d39a98e66b401ece5069ea62e64 install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service +bed7495c277970a37d1c467e81592417f955914d4c714ac21397083b56f1bba8 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh +db81f6209e14f2efd123cd91fac74e37a68e5ef54d00b8b6f6612404a78ffdaa install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json +6eafd2d7a43a9fd146fdc6686ae306cb92abfa2c273bbb1ed03b67fb1277defe install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js +9e6254c726dd4a26578a60b6bf5acee84066f931ae395115a80cf622a6ff2732 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py +05b04b5f49e9c7783917e0861e3edd63f5d6db8638d6b33dbfd4dc0f1c16040f install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service +bb0f1de91862da66b0b6d9bd41e8dfe181710196141cb43b00d9b41dab6caf96 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer +0d2b978833b27a2a041508b49ffd07ca045127ddf3b09c71d3787d0bf1224473 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example 98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js -dce731fdfdcfd773c154d12dbd6b9e621a0bff17ced5a05a65f0fdcb1adcb70f install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh -1856e9f44636030b0cb9ece37ba2a0618eb5187fa82c7969976c1bb5f10fc622 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json -ff07b90cb6a7f09b27d522307cf55b0359e136a2e695190b8564e859f14f9204 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json -1654cf688560465fcce629468a0be869b0c81b056179e1b5e9bcc7a2d5ed6ce0 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-admin.deployment-config.json -ac022b9a074c542ade66d18af8db385f6c14376ac4eadd54ef033dfa7f60fb50 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-u2u5.deployment-config.json -98cf4c54d494318b74cfbd3c8892830a34928bd76a2296d5333e5e176c1f3f49 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-user1.deployment-config.json +d8d6be450a726f51f87415eaebd67396468c73ddda9b942481d56b4d33d68bb7 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +f764e566d70952acc1b4f2baeccd6b7888905bc6b98b36333336c0dc77c66694 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service +8af41d20f01dfffe6b8c64bd5dbe24468f297035760050647bfeb53b704c0d4d install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js +3262b356dc4cd940b66f27d47cfe437dcd26d8d2cb49d1dc0a00b933e380a376 install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +aedffecfa24834968742cb2477faef80bf794345275a9679ac12c5a1f609acc2 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json +38fd98fe5816fb87055a9ad1fc570a65052785c829ecd8359fe8305949ab32ca install-kit-awindows-20260427-211240/aw-server/settings/views-default.json +dd2389e9cb199ed86d219120294fdaa64415cfbc683004d5f5db5d52aa758a92 install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-admin.deployment-config.json +5279f9d677faed76a5f0248f9217ecc29eac977ab752552cab852f5b4b6715df install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-u2u5.deployment-config.json +333fe336e51f9c69bd2559d18763da2b83df400fa374e540ed128ffb6765ab7e install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-user1.deployment-config.json 33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 -0cb9cd8d8b612429f79899c126f4141ab4fbbb6d919425c0b8fd8d0a74b1bd44 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 -7db2d3767ae81c877e8f04ecbc77a2fc26b2d9bbbf77d0e774fba0a7956bd0a6 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 -b497400a1ba57cddf28dc8e217115dc85eccb67150cbdbb6a81abd804ed20109 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 -973db51854fc744539a7b75e13c6749e822a08a79f47447485611f07e8f902a8 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 -0d66dcb551889e6b7bc21b29d53b77e46f41d61dd2e4e0d9913dbf0f8bd5eb18 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 -8857f3e17f3f3ed6f211ce7f0a0c46c586a2548541078401ee3befaa20924b3d install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 -7362e1aecb56d8863b8b2542f262f28827febf9b25a2a14b382750dc6368664f install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 -aef0032edd9b1e0c54f7b575664ed511dfc6cb53364e7496cbc95e137678e11a install-kit-awindows-20260427-211240/windows/dlp-policy.example.json -f03886caf56c6838e8a163d6b48d1f229e83a5682aeeb447c3a65f13d62dbca4 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 -71911cd53ad0abd8bf83994f8a79bbbfe2c0eaf4f4c5f6c2d636dd7786191c2f install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 -5dcf249742bd82fa0c803c878bfa0a1344b7b14df85c05f12e8c205663aea158 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +0e5ac8bc0571f154190202504e02710ac931b8015cb01ec93e82defed9fc2f4f install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +130ae4c137f7d6951cd8c247c0a8fbf999f4c244e879c180e1441b781e758228 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 +831edc097f0621ae940db5064c949b4a83ed6e4f25265875f0cdd6bba0ba4c51 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +f9992b3c9c075755e6ffcf82385b9abd01e768a0c3fb7fe01afae5d7b65d04db install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +f940b40600d57f2d12f44a32aa88e5591b7569ce01c06911ff8f4e27b0e1649f install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +35a0dcc90459d4af39998c8a8bd534826e0b463dfbf3f8547444a96204f0ef4b install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +a4dad0745da95a69ee55b0216d4bde39c58acef8642380092938465653c61cf4 install-kit-awindows-20260427-211240/windows/dlp-policy.example.json +863727465497b474d13d2270d443ff96ccb6076f90a5ce3eb270bdf8088e02dc install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 +e29fd9ed3510429372126d94c54beb30dc6424b5b22eb012829b99c3cb07ea60 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +5ef21a25d5e2da4eeaef17126e60f96f195f90f9dc17a776f8629334b904d096 install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 +d01edd14b2c839ae171006fd3345dbddf1b683b1adcac885b6eabc52e3baeb79 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json -41171f0d7ed1e8b00dd0faf1a4b75c9cb063fd09aba8d333851a7a31fb297de1 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 +945ccfffd56697ed328b82e82d1cffbc83fa4e50a6120b27e3948f3d13fa8a33 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 diff --git a/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt b/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt index c59015e..8435107 100644 --- a/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt +++ b/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt @@ -3,8 +3,8 @@ ActivityWatch DetMir Windows Install Kit Includes: - windows/* (deploy scripts, collectors, common module, configs/examples) - ansible/* (Windows and AW server playbooks, examples, inventory, tasks) -- aw-server/* (server installer, RU patch loader, host groups, default settings) -- server-configs-192.168.100.21/* (working Windows/RDP config snapshots) +- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) +- server-configs-192.168.100.18/* (working Windows/RDP config snapshots) Source: - Local project snapshot at build time. diff --git a/install-kit-awindows-20260427-211240/ansible/README.md b/install-kit-awindows-20260427-211240/ansible/README.md index b22fc6c..b8fbe87 100644 --- a/install-kit-awindows-20260427-211240/ansible/README.md +++ b/install-kit-awindows-20260427-211240/ansible/README.md @@ -14,6 +14,8 @@ - `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый полный playbook (несколько CT). - `ansible/deploy_aw_windows.yml` — WinRM playbook для развёртывания Windows/RDP collector'ов. - `ansible/deploy_aw_pfsense_poller.yml` — развёртывание pfSense poller'а. +- `ansible/deploy_grafana_dashboards.yml` — импорт version-controlled Grafana dashboard'ов через HTTP API. +- `ansible/deploy_tsj_guardian_bot_proxmox.yml` — развёртывание TSJ Guardian Telegram Bot на Proxmox host. - `ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов). - `ansible/inventory.example.ini` — шаблон inventory. - `ansible/group_vars/*.example.yml` — шаблоны переменных. @@ -31,6 +33,15 @@ cd ansible ansible-playbook -i inventory.ini deploy_aw_server.yml ``` +## Секреты (пароли) безопасно + +Рекомендуемый способ не хранить пароли в репозитории — перед запуском экспортировать их в переменные окружения: + +- Linux `aw_server` (SSH пароль root): `AW_SSH_PASSWORD` +- Windows `aw_windows` (WinRM пароль): `AW_WINRM_PASSWORD` + +В `group_vars/aw_server.yml` и `group_vars/windows.yml` они читаются через `lookup('env', ...)`. + ## Полный установочный playbook (всё за один запуск) Если нужно прогнать полный цикл одной командой: @@ -45,7 +56,8 @@ ansible-playbook -i inventory.ini install_full_stack.yml - `provision_proxmox_ct_and_deploy_aw.yml` (если есть хосты в группе `[proxmox]`); - `deploy_aw_server.yml` (группа `[aw_server]`); - `deploy_aw_windows.yml` (группа `[aw_windows]`); -- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`). +- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`); +- `deploy_grafana_dashboards.yml` (группа `[grafana]`). Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен. @@ -87,18 +99,29 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml ```bash cd ansible -ansible-playbook -i inventory.ini deploy_aw_windows.yml +AW_WINRM_PASSWORD='...' bash ./run_deploy_aw_windows.sh ``` +`run_deploy_aw_windows.sh` автоматически: +- очищает proxy env (`http_proxy/https_proxy/...`), чтобы WinRM не уходил в локальный прокси; +- включает OpenSSL legacy provider, если на хосте отключён `MD4` (нужно для NTLM в pywinrm). +- перезапускает `ansible-playbook` при временных WinRM/NTLM сбоях (по умолчанию 5 попыток, пауза 30 сек). + +Параметры retry: +- `AW_DEPLOY_RETRIES` (по умолчанию `5`); +- `AW_DEPLOY_RETRY_DELAY_SEC` (по умолчанию `30`). + Playbook: - выгружает полный `windows/*` toolkit на целевой хост в InnoSetup-compatible каталог `C:\Program Files\AWatch-rus\windows`, включая DLP и `worktime-session-collector.ps1`; - если найден legacy config `C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`, выполняет безопасную миграцию через `migrate-awatch-rus-paths.ps1`: backup, остановка задач, перенос данных, переписывание путей, пересоздание scheduled tasks и validation; - выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с policy/rules из AWatch-rus toolkit; - после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи; -- выполняет API smoke-check bucket `aw-watcher-afk_` и ожидает свежие `not-afk` события; +- включает (`Enable-ScheduledTask`) `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи перед запуском (иначе WebUI может показывать `Active time: 0s`); +- выполняет API smoke-check bucket `aw-watcher-afk_` и ожидает свежие события; +- выполняет API smoke-check bucket `aw-watcher-window_` и ожидает свежие события (по умолчанию включено); - запускает `validate-deployment.ps1`; -- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию). +- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation-` по умолчанию). Дополнительные флаги: @@ -116,6 +139,9 @@ Playbook: - `aw_windows_migration_report_remote_path` — JSON-отчёт о миграции на Windows-хосте; - `aw_windows_package_version`, `aw_windows_package_url`, `aw_windows_package_zip_path` — версия и источник Windows-пакета ActivityWatch; - `aw_windows_api_smoke_check_bucket: ""` — автоматически использовать `aw-watcher-afk_`; +- `aw_windows_api_smoke_check_window_enabled: true` — включить дополнительный smoke-check `aw-watcher-window_`; +- `aw_windows_api_smoke_check_window_bucket: ""` — переопределить bucket для window smoke-check; +- `aw_windows_api_smoke_check_min_events: 1` — минимум событий, ожидаемых в smoke-check; - `aw_windows_fail_on_validation_error: true` — завершать playbook ошибкой, если `validate-deployment.ps1` возвращает `overallOk=false`; - `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта. @@ -138,6 +164,68 @@ Playbook: - пишет `/etc/aw-pfsense/poller.json`; - поднимает `aw-pfsense-poller.service`. +## Импорт Grafana dashboard'ов + +1. Подготовьте inventory и vars: + - `cp ansible/inventory.example.ini ansible/inventory.ini` + - `cp ansible/group_vars/grafana.example.yml ansible/group_vars/grafana.yml` +2. Укажите в inventory группу `[grafana]` и переменную `grafana_url`. +3. Экспортируйте пароль Grafana API: + +```bash +export GRAFANA_ADMIN_PASSWORD='...' +``` + +4. Запустите: + +```bash +cd ansible +ansible-playbook -i inventory.ini deploy_grafana_dashboards.yml +``` + +Playbook: + +- проверяет `GET /api/health`; +- создает или актуализирует folder `AWatch-rus` в Grafana; +- импортирует dashboard JSON из каталога `grafana/`; +- верифицирует доступность dashboard'ов по `uid` через Grafana API. + +По умолчанию импортируются: + +- `DetMir: Работа пользователей в RDP` +- `DetMir: DLP и ИБ обзор` +- `DetMir: ИБ сводка для руководства` +- `AW-rus: DLP обзор` + +Подробная документация: `docs/GRAFANA_DASHBOARDS_RU.md` + +## Развёртывание TSJ Guardian Bot на Proxmox + +1. Подготовьте vars: + - `cp ansible/group_vars/proxmox-bot.example.yml ansible/group_vars/proxmox-bot.yml` +2. Заполните минимум: + - `telegram_bot_token` + - `telegram_allowed_chat_ids` + - `tsj_bot_source_local_path` +3. Убедитесь, что в inventory есть группа `[proxmox]`. + Для текущего контура 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. @@ -149,3 +237,13 @@ Playbook: - Для полного сценария CT создаётся автоматически через `pct create`. - На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector. - Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`. + +## Prod rollout одной командой + +Для ручного запуска с dry-run и логированием используйте: + +```bash +bash scripts/prod_rollout.sh +``` + +Скрипт попросит `AW_SSH_PASSWORD` и `AW_WINRM_PASSWORD` интерактивно (ввод скрыт) и сложит логи в `.rollout-logs/`. diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml index b136df7..46f3eb7 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml @@ -20,7 +20,11 @@ ansible.builtin.apt: name: - curl + - python3-venv + - python3-pip - rsync + - tesseract-ocr + - tesseract-ocr-rus - unzip state: present update_cache: true @@ -54,6 +58,13 @@ - "{{ aw_server_webui_dir }}" - "{{ aw_server_webui_dir }}/js" - "{{ aw_server_data_dir }}" + - "{{ aw_server_db_path | dirname }}" + - "{{ aw_server_data_dir }}/.config" + - "{{ aw_server_data_dir }}/.config/activitywatch" + - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" + - "{{ aw_server_data_dir }}/backups" + - "{{ aw_rus_health_state_dir }}" + - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" - /etc/activitywatch - "{{ aw_bootstrap_dir }}" @@ -74,100 +85,149 @@ - "{{ aw_server_webui_dir }}" - "{{ aw_server_webui_dir }}/js" - "{{ aw_server_data_dir }}" + - "{{ aw_server_db_path | dirname }}" + - "{{ aw_server_data_dir }}/.config" + - "{{ aw_server_data_dir }}/.config/activitywatch" + - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" + - "{{ aw_server_data_dir }}/backups" + - "{{ aw_rus_health_state_dir }}" + - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" - - name: Скачать архив релиза ActivityWatch - ansible.builtin.get_url: - url: "{{ aw_server_download_url }}" - dest: "{{ aw_archive_path }}" - mode: "0644" + - name: (Check mode) Пропустить установку релиза ActivityWatch + ansible.builtin.debug: + msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped." + when: ansible_check_mode - - name: Распаковать релиз ActivityWatch - ansible.builtin.unarchive: - src: "{{ aw_archive_path }}" - dest: "{{ aw_release_dir }}" - remote_src: true - extra_opts: ["-o"] + - name: Установить релиз ActivityWatch (download/unarchive/install) + when: not ansible_check_mode + block: + - name: Скачать архив релиза ActivityWatch + ansible.builtin.get_url: + url: "{{ aw_server_download_url }}" + dest: "{{ aw_archive_path }}" + mode: "0644" - - name: Найти распакованный каталог ActivityWatch - ansible.builtin.find: - paths: "{{ aw_release_dir }}" - file_type: directory - patterns: "activitywatch*" - register: aw_release_find + - name: Распаковать релиз ActivityWatch + ansible.builtin.unarchive: + src: "{{ aw_archive_path }}" + dest: "{{ aw_release_dir }}" + remote_src: true + extra_opts: ["-o"] - - name: Найти бинарный файл AW server - ansible.builtin.find: - paths: "{{ aw_release_dir }}" - file_type: file - patterns: - - aw-server-rust - - aw-server - register: aw_server_binary_find + - name: Найти распакованный каталог ActivityWatch + ansible.builtin.find: + paths: "{{ aw_release_dir }}" + recurse: true + file_type: directory + patterns: "activitywatch*" + register: aw_release_find - - name: Найти каталог WebUI - ansible.builtin.find: - paths: "{{ aw_release_dir }}" - file_type: directory - patterns: - - aw-webui - - webui - register: aw_webui_dir_find + - name: Найти бинарный файл AW server + ansible.builtin.find: + paths: "{{ aw_release_dir }}" + recurse: true + file_type: file + patterns: + - aw-server-rust + - aw-server + register: aw_server_binary_find - - name: Сохранить пути распакованного релиза - ansible.builtin.set_fact: - aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" - aw_server_binary_path: "{{ (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" - aw_webui_source_path: "{{ (aw_webui_dir_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" + - name: Найти index.html WebUI + ansible.builtin.find: + paths: "{{ aw_release_dir }}" + recurse: true + file_type: file + patterns: + - index.html + register: aw_webui_index_find - - name: Проверить, что компоненты релиза найдены - ansible.builtin.assert: - that: - - aw_release_extracted is defined - - aw_release_extracted | length > 0 - - aw_server_binary_path is defined - - aw_server_binary_path | length > 0 - - aw_webui_source_path is defined - - aw_webui_source_path | length > 0 - fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch." + - name: Сохранить пути распакованного релиза (binary + webui index) + ansible.builtin.set_fact: + aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" + aw_server_binary_path: >- + {{ + ( + ( + (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list) + | select('match', '.*/aw-server-rust$') | list | first + ) + | default( + ( + (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) + ), + true + ) + ) | default('') + }} + aw_webui_index_path: >- + {{ + ( + ( + (aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list) + | select('search', '/static/index\\.html$') | list | first + ) + | default( + ( + (aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) + ), + true + ) + ) | default('') + }} - - name: Создать каталог установленного релиза - ansible.builtin.file: - path: "{{ aw_release_install_dir }}" - state: directory - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" + - name: Сохранить каталог WebUI (dirname index.html) + ansible.builtin.set_fact: + aw_webui_source_path: "{{ aw_webui_index_path | dirname }}" - - name: Установить бинарный файл AW server - ansible.builtin.copy: - remote_src: true - src: "{{ aw_server_binary_path }}" - dest: "{{ aw_release_install_dir }}/aw-server-rust" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" + - name: Проверить, что компоненты релиза найдены + ansible.builtin.assert: + that: + - aw_release_extracted is defined + - aw_release_extracted | length > 0 + - aw_server_binary_path is defined + - aw_server_binary_path | length > 0 + - aw_webui_source_path is defined + - aw_webui_source_path | length > 0 + fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch." - - name: Создать ссылку на активный бинарный файл AW server - ansible.builtin.file: - src: "{{ aw_release_install_dir }}/aw-server-rust" - dest: /opt/activitywatch/bin/aw-server-rust - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - state: link - force: true + - name: Создать каталог установленного релиза + ansible.builtin.file: + path: "{{ aw_release_install_dir }}" + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" - - name: Синхронизировать WebUI в RU каталог - ansible.builtin.command: - cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/" + - name: Установить бинарный файл AW server + ansible.builtin.copy: + remote_src: true + src: "{{ aw_server_binary_path }}" + dest: "{{ aw_release_install_dir }}/aw-server-rust" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" - - name: Настроить владельца файлов /opt/activitywatch - ansible.builtin.file: - path: /opt/activitywatch - state: directory - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - recurse: true + - name: Создать ссылку на активный бинарный файл AW server + ansible.builtin.file: + src: "{{ aw_release_install_dir }}/aw-server-rust" + dest: /opt/activitywatch/bin/aw-server-rust + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + state: link + force: true + + - name: Синхронизировать WebUI в RU каталог + ansible.builtin.command: + cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/" + + - name: Настроить владельца файлов /opt/activitywatch + ansible.builtin.file: + path: /opt/activitywatch + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + recurse: true - name: Установить systemd service из шаблона репозитория ansible.builtin.copy: @@ -184,143 +244,1106 @@ - Перезагрузить systemd - Перезапустить activitywatch - - name: Скопировать RU patch файлы WebUI из репозитория - ansible.builtin.copy: - src: "{{ item.src }}" - dest: "{{ item.dest }}" - mode: "{{ item.mode }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - loop: - - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" } - - - name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh - ansible.builtin.file: - path: /root/bootstrap - state: directory - mode: "0755" - - - name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков) - ansible.builtin.copy: - src: "{{ item.src }}" - dest: "{{ item.dest }}" - mode: "{{ item.mode }}" - loop: - - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" } - - - name: Скопировать apply_webui_ru_patch.sh скрипт - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh" - dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh - mode: "0755" - - - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами - ansible.builtin.copy: - dest: /etc/activitywatch/aw-server.env - mode: "0640" - owner: root - group: root - content: | - AW_SERVER_BIND_HOST={{ aw_server_bind_host }} - AW_SERVER_PORT={{ aw_server_port }} - AW_SERVER_DATA_DIR={{ aw_server_data_dir }} - AW_SERVER_DB_PATH={{ aw_server_db_path }} - AW_SERVER_LOG_DIR={{ aw_server_log_dir }} - AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }} - AW_SERVER_USER={{ aw_server_user }} - AW_SERVER_GROUP={{ aw_server_group }} - AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} - AW_WORKTIME_TZ={{ aw_worktime_timezone }} - XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share - XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config - - - name: Установить скрипт AW worktime API - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" - dest: /usr/local/bin/aw-worktime-api.py - owner: root - group: root - mode: "0755" - - - name: Установить systemd unit AW worktime API - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service" - dest: /etc/systemd/system/aw-worktime-api.service - owner: root - group: root - mode: "0644" - - - name: Перезагрузить systemd после установки AW worktime API - ansible.builtin.systemd: - daemon_reload: true - - - name: Включить и перезапустить AW worktime API - ansible.builtin.systemd: - name: aw-worktime-api.service - enabled: true - state: restarted - - - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) - ansible.builtin.command: - cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh" - register: apply_ru_patch_result - failed_when: false - - - name: Вывести результат применения хотфиксов + - name: (Check mode) Пропустить WebUI patch и запуск сервиса ansible.builtin.debug: - msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}" + msg: "ansible_check_mode=true: WebUI patch + service start + API checks are skipped." + when: ansible_check_mode - - name: Проверить наличие index.html после копирования - ansible.builtin.stat: - path: "{{ aw_server_webui_dir }}/index.html" - register: aw_webui_ru_index + - name: Применить WebUI RU patch и запустить сервис + when: not ansible_check_mode + block: + - name: Скопировать RU patch файлы WebUI из репозитория + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + mode: "{{ item.mode }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + loop: + - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" } - - name: Проверить, что index.html доступен для RU patch - ansible.builtin.assert: - that: - - aw_webui_ru_index.stat.exists - fail_msg: "Не найден index.html WebUI для применения RU patch." + - name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh + ansible.builtin.file: + path: /root/bootstrap + state: directory + mode: "0755" - - name: Удалить старые теги RU patch из index.html - ansible.builtin.replace: - path: "{{ aw_server_webui_dir }}/index.html" - regexp: ']+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*>' - replace: '' + - name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков) + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + mode: "{{ item.mode }}" + loop: + - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" } - - name: Добавить cleanup script RU patch в index.html - ansible.builtin.replace: - path: "{{ aw_server_webui_dir }}/index.html" - regexp: '' - replace: '' + - name: Скопировать apply_webui_ru_patch.sh скрипт + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh" + dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh + mode: "0755" - - name: Добавить загрузчик RU patch перед закрытием body - ansible.builtin.replace: - path: "{{ aw_server_webui_dir }}/index.html" - regexp: '' - replace: '' + - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами + ansible.builtin.copy: + dest: /etc/activitywatch/aw-server.env + mode: "0640" + owner: root + group: root + content: | + AW_SERVER_BIND_HOST={{ aw_server_bind_host }} + AW_SERVER_PORT={{ aw_server_port }} + AW_SERVER_DATA_DIR={{ aw_server_data_dir }} + AW_SERVER_DB_PATH={{ aw_server_db_path }} + AW_SERVER_LOG_DIR={{ aw_server_log_dir }} + AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }} + AW_SERVER_USER={{ aw_server_user }} + AW_SERVER_GROUP={{ aw_server_group }} + AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} + AW_WORKTIME_TZ={{ aw_worktime_timezone }} + AW_DLP_IOC_DIR={{ aw_dlp_ioc_workdir }}/output + AW_DLP_POLICY_ENGINE_BIND_HOST={{ aw_dlp_policy_engine_bind_host }} + AW_DLP_POLICY_ENGINE_PORT={{ aw_dlp_policy_engine_port }} + AW_DLP_POLICY_ENGINE_DB_PATH={{ aw_dlp_policy_engine_db_path }} + AW_DLP_CASE_BIND_HOST={{ aw_dlp_case_bind_host }} + AW_DLP_CASE_PORT={{ aw_dlp_case_port }} + AW_DLP_CASE_DB_PATH={{ aw_dlp_case_db_path }} + AW_DLP_COMPLIANCE_REPORT_DIR={{ aw_dlp_compliance_report_dir }} + AW_DLP_COMPLIANCE_TEMPLATE={{ aw_dlp_compliance_template_path }} + AW_SERVER_URL=http://127.0.0.1:5600 + XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share + XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config + AW_WORKTIME_INFLUX_ENABLED={{ 'true' if (aw_worktime_influx_enabled | default(false) | bool) else 'false' }} + AW_WORKTIME_INFLUX_URL={{ aw_worktime_influx_url | default('') }} + AW_WORKTIME_INFLUX_ORG={{ aw_worktime_influx_org | default('proxmox') }} + AW_WORKTIME_INFLUX_BUCKET={{ aw_worktime_influx_bucket | default('aw_metrics') }} + AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('SHARKON2025') }} + AW_WORKTIME_INFLUX_DAYS={{ aw_worktime_influx_days | default('today,yesterday') }} + AW_WORKTIME_INFLUX_TOKEN={{ aw_worktime_influx_token | default('') }} + AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }} + AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} + AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} + AW_DLP_INFLUX_BUCKET={{ aw_dlp_influx_bucket | default('aw_metrics') }} + AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default('SHARKON2025') }} + AW_DLP_INFLUX_LOOKBACK_DAYS={{ aw_dlp_influx_lookback_days | default(30) }} + AW_DLP_INFLUX_EVENT_LIMIT={{ aw_dlp_influx_event_limit | default(2000) }} + AW_DLP_INFLUX_TOKEN={{ aw_dlp_influx_token | default('') }} + AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0 + AW_DLP_CASE_API_BASE=http://127.0.0.1:5602/api/0/dlp/cases + AW_EXPECT_START_OF_DAY={{ aw_worktime_start_of_day | default('') }} + AW_EXPECT_ALWAYS_ACTIVE_PATTERN={{ aw_server_always_active_pattern | default('') }} + AW_EXPECT_LANDINGPAGE={{ aw_server_landingpage | default('') }} + AW_HEALTH_STRICT_FILEOPS={{ aw_health_strict_fileops | default(0) }} + AW_MONITORED_WINDOWS_HOST={{ aw_monitored_windows_host }} + AW_MONITORED_WINDOWS_HOSTNAME={{ aw_monitored_windows_hostname }} + AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} + AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} - - name: Включить и запустить сервис - ansible.builtin.systemd: - name: activitywatch-server.service - enabled: true - state: restarted - daemon_reload: true + - name: Создать каталог DLP policy engine + ansible.builtin.file: + path: /opt/activitywatch/dlp-policy-engine + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_dlp_policy_engine_enabled | default(false) | bool - - name: Дождаться ответа API + - name: Скопировать файлы DLP policy engine + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/{{ item }}" + dest: "/opt/activitywatch/dlp-policy-engine/{{ item }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + loop: + - policy_service.py + - policy_schema.py + - policy_storage.py + - policy_distributor.py + - requirements.txt + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Создать virtualenv DLP policy engine + ansible.builtin.command: + cmd: python3 -m venv /opt/activitywatch/dlp-policy-engine/.venv + args: + creates: /opt/activitywatch/dlp-policy-engine/.venv/bin/python + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Установить зависимости DLP policy engine + ansible.builtin.pip: + requirements: /opt/activitywatch/dlp-policy-engine/requirements.txt + virtualenv: /opt/activitywatch/dlp-policy-engine/.venv + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Установить systemd unit DLP policy engine + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/dlp-policy-engine.service" + dest: /etc/systemd/system/aw-dlp-policy-engine.service + owner: root + group: root + mode: "0644" + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Создать каталог DLP content analysis + ansible.builtin.file: + path: /opt/activitywatch/dlp-content-analysis + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_dlp_content_analysis_enabled | default(true) | bool + + - name: Скопировать файлы DLP content analysis + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-content-analysis/" + dest: /opt/activitywatch/dlp-content-analysis/ + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + 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 + args: + creates: /opt/activitywatch/dlp-content-analysis/.venv/bin/python + when: aw_dlp_content_analysis_enabled | default(true) | bool + + - name: Установить зависимости DLP content analysis + ansible.builtin.pip: + requirements: /opt/activitywatch/dlp-content-analysis/requirements.txt + virtualenv: /opt/activitywatch/dlp-content-analysis/.venv + when: aw_dlp_content_analysis_enabled | default(true) | bool + + - name: Создать каталог DLP integrations + ansible.builtin.file: + path: /opt/activitywatch/dlp-integrations + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Скопировать файлы DLP integrations + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/{{ item }}" + dest: "/opt/activitywatch/dlp-integrations/{{ item }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + loop: + - cef_exporter.py + - syslog_forwarder.py + - webhook_sender.py + - cef-config.yaml + - syslog-forwarder-config.yaml + - webhook-config.yaml + - requirements.txt + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Выставить executable для python scripts DLP integrations + ansible.builtin.file: + path: "/opt/activitywatch/dlp-integrations/{{ item }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + loop: + - cef_exporter.py + - syslog_forwarder.py + - webhook_sender.py + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Создать virtualenv DLP integrations + ansible.builtin.command: + cmd: python3 -m venv /opt/activitywatch/dlp-integrations/.venv + args: + creates: /opt/activitywatch/dlp-integrations/.venv/bin/python + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить зависимости DLP integrations + ansible.builtin.pip: + requirements: /opt/activitywatch/dlp-integrations/requirements.txt + virtualenv: /opt/activitywatch/dlp-integrations/.venv + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Создать state каталог DLP integrations + ansible.builtin.file: + path: /var/lib/activitywatch/dlp-integrations + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить systemd unit CEF exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/cef-exporter.service" + dest: /etc/systemd/system/aw-dlp-cef-exporter.service + owner: root + group: root + mode: "0644" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить systemd timer CEF exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/cef-exporter.timer" + dest: /etc/systemd/system/aw-dlp-cef-exporter.timer + owner: root + group: root + mode: "0644" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить systemd unit syslog forwarder + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/syslog-forwarder.service" + dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service + owner: root + group: root + mode: "0644" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить systemd timer syslog forwarder + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/syslog-forwarder.timer" + dest: /etc/systemd/system/aw-dlp-syslog-forwarder.timer + owner: root + group: root + mode: "0644" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить systemd unit webhook sender + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/webhook-sender.service" + dest: /etc/systemd/system/aw-dlp-webhook-sender.service + owner: root + group: root + mode: "0644" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить systemd timer webhook sender + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-integrations/webhook-sender.timer" + dest: /etc/systemd/system/aw-dlp-webhook-sender.timer + owner: root + group: root + mode: "0644" + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Создать каталог DLP case management + ansible.builtin.file: + path: /opt/activitywatch/dlp-case-management + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Скопировать файлы DLP case management + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-case-management/{{ item }}" + dest: "/opt/activitywatch/dlp-case-management/{{ item }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + loop: + - case_service.py + - case_rules.py + - case_schema.py + - case_storage.py + - evidence_chain.py + - requirements.txt + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Выставить executable для case_service + ansible.builtin.file: + path: /opt/activitywatch/dlp-case-management/case_service.py + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Создать virtualenv DLP case management + ansible.builtin.command: + cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv + args: + creates: /opt/activitywatch/dlp-case-management/.venv/bin/python + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Установить зависимости DLP case management + ansible.builtin.pip: + requirements: /opt/activitywatch/dlp-case-management/requirements.txt + virtualenv: /opt/activitywatch/dlp-case-management/.venv + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Установить systemd unit DLP case management + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-case-management/case-service.service" + dest: /etc/systemd/system/aw-dlp-case-management.service + owner: root + group: root + mode: "0644" + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Создать каталоги DLP compliance + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + loop: + - /opt/activitywatch/dlp-compliance + - /opt/activitywatch/dlp-compliance/templates + - "{{ aw_dlp_compliance_report_dir }}" + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Скопировать файлы DLP compliance + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/dlp-compliance/{{ item.src }}" + dest: "{{ item.dest }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "{{ item.mode }}" + loop: + - { src: "report_generator.py", dest: "/opt/activitywatch/dlp-compliance/report_generator.py", mode: "0755" } + - { src: "compliance_scheduler.py", dest: "/opt/activitywatch/dlp-compliance/compliance_scheduler.py", mode: "0755" } + - { src: "requirements.txt", dest: "/opt/activitywatch/dlp-compliance/requirements.txt", mode: "0644" } + - { src: "templates/152-fz-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html", mode: "0644" } + - { src: "templates/pci-dss-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/pci-dss-report.html", mode: "0644" } + - { src: "report-scheduler.service", dest: "/etc/systemd/system/aw-dlp-report-scheduler.service", mode: "0644" } + - { src: "report-scheduler.timer", dest: "/etc/systemd/system/aw-dlp-report-scheduler.timer", mode: "0644" } + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Создать virtualenv DLP compliance + ansible.builtin.command: + cmd: python3 -m venv /opt/activitywatch/dlp-compliance/.venv + args: + creates: /opt/activitywatch/dlp-compliance/.venv/bin/python + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Установить зависимости DLP compliance + ansible.builtin.pip: + requirements: /opt/activitywatch/dlp-compliance/requirements.txt + virtualenv: /opt/activitywatch/dlp-compliance/.venv + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Установить dlp-admin-cli + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/dlp-admin-cli.py" + dest: /usr/local/bin/dlp-admin-cli.py + owner: root + group: root + mode: "0755" + + - name: Установить скрипт AW worktime API + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" + dest: /usr/local/bin/aw-worktime-api.py + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit AW worktime API + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service" + dest: /etc/systemd/system/aw-worktime-api.service + owner: root + group: root + mode: "0644" + + - name: Установить скрипт AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py" + dest: /usr/local/bin/aw-worktime-ui-bridge.py + owner: root + group: root + mode: "0755" + + - name: Установить скрипт AW worktime autoheal + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.sh" + dest: /usr/local/bin/aw-worktime-autoheal.sh + owner: root + group: root + mode: "0755" + + - name: Установить скрипт AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.py" + dest: /usr/local/bin/aw-worktime-influx-exporter.py + owner: root + group: root + mode: "0755" + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Установить скрипт AW DLP Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.py" + dest: /usr/local/bin/aw-dlp-influx-exporter.py + owner: root + group: root + mode: "0755" + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Установить скрипт aw-health-check + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/health-check.sh" + dest: /usr/local/bin/aw-health-check + owner: root + group: root + mode: "0755" + + - name: Установить скрипт dlp-health-check + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/dlp-health-check.py" + dest: /usr/local/bin/dlp-health-check + owner: root + group: root + mode: "0755" + + - name: Установить скрипт aw-rus-healthd + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.py" + dest: /usr/local/bin/aw-rus-healthd.py + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service" + dest: /etc/systemd/system/aw-worktime-ui-bridge.service + owner: root + group: root + mode: "0644" + + - name: Установить systemd timer AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer" + dest: /etc/systemd/system/aw-worktime-ui-bridge.timer + owner: root + group: root + mode: "0644" + + - name: Установить systemd unit AW worktime autoheal + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.service" + dest: /etc/systemd/system/aw-worktime-autoheal.service + owner: root + group: root + mode: "0644" + + - name: Установить systemd timer AW worktime autoheal + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.timer" + dest: /etc/systemd/system/aw-worktime-autoheal.timer + owner: root + group: root + mode: "0644" + + - name: Установить systemd unit AW-RUS healthd + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.service" + dest: /etc/systemd/system/aw-rus-healthd.service + owner: root + group: root + mode: "0644" + + - name: Установить systemd timer AW-RUS healthd + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.timer" + dest: /etc/systemd/system/aw-rus-healthd.timer + owner: root + group: root + mode: "0644" + + - name: Установить systemd unit AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.service" + dest: /etc/systemd/system/aw-worktime-influx-exporter.service + owner: root + group: root + mode: "0644" + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Установить systemd timer AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.timer" + dest: /etc/systemd/system/aw-worktime-influx-exporter.timer + owner: root + group: root + mode: "0644" + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Установить systemd unit AW DLP Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.service" + dest: /etc/systemd/system/aw-dlp-influx-exporter.service + owner: root + group: root + mode: "0644" + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Установить systemd timer AW DLP Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.timer" + dest: /etc/systemd/system/aw-dlp-influx-exporter.timer + owner: root + group: root + mode: "0644" + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Перезагрузить systemd после установки AW worktime API + ansible.builtin.systemd: + daemon_reload: true + + - name: Включить таймер AW-RUS healthd + ansible.builtin.systemd: + name: aw-rus-healthd.timer + enabled: true + state: started + + - name: Включить и перезапустить DLP policy engine + ansible.builtin.systemd: + name: aw-dlp-policy-engine.service + enabled: true + state: restarted + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Включить и перезапустить timer CEF exporter + ansible.builtin.systemd: + name: aw-dlp-cef-exporter.timer + enabled: true + state: restarted + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Включить и перезапустить timer syslog forwarder + ansible.builtin.systemd: + name: aw-dlp-syslog-forwarder.timer + enabled: true + state: restarted + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Включить и перезапустить timer webhook sender + ansible.builtin.systemd: + name: aw-dlp-webhook-sender.timer + enabled: true + state: restarted + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Включить и перезапустить DLP case management + ansible.builtin.systemd: + name: aw-dlp-case-management.service + enabled: true + state: restarted + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Включить и перезапустить timer DLP compliance report + ansible.builtin.systemd: + name: aw-dlp-report-scheduler.timer + enabled: true + state: restarted + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Выполнить разовый прогон DLP compliance report + ansible.builtin.systemd: + name: aw-dlp-report-scheduler.service + state: started + failed_when: false + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Включить и перезапустить AW worktime API + ansible.builtin.systemd: + name: aw-worktime-api.service + enabled: true + state: restarted + + - name: Отключить legacy timer aw-worktime-afk-bridge (если есть) + ansible.builtin.systemd: + name: aw-worktime-afk-bridge.timer + enabled: false + state: stopped + failed_when: false + + - name: Включить и перезапустить AW worktime UI bridge timer + ansible.builtin.systemd: + name: aw-worktime-ui-bridge.timer + enabled: true + state: restarted + + - name: Выполнить разовый прогон AW worktime UI bridge + ansible.builtin.systemd: + name: aw-worktime-ui-bridge.service + state: started + failed_when: false + + - name: Включить и перезапустить AW worktime autoheal timer + ansible.builtin.systemd: + name: aw-worktime-autoheal.timer + enabled: true + state: restarted + + - name: Выполнить разовый прогон AW worktime autoheal + ansible.builtin.systemd: + name: aw-worktime-autoheal.service + state: started + failed_when: false + + - name: Включить и перезапустить AW worktime Influx exporter timer + ansible.builtin.systemd: + name: aw-worktime-influx-exporter.timer + enabled: true + state: restarted + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Выполнить разовый прогон AW worktime Influx exporter + ansible.builtin.systemd: + name: aw-worktime-influx-exporter.service + state: started + failed_when: false + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Включить и перезапустить AW DLP Influx exporter timer + ansible.builtin.systemd: + name: aw-dlp-influx-exporter.timer + enabled: true + state: restarted + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Выполнить разовый прогон AW DLP Influx exporter + ansible.builtin.systemd: + name: aw-dlp-influx-exporter.service + state: started + failed_when: false + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) + ansible.builtin.command: + cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh" + register: apply_ru_patch_result + failed_when: false + + - name: Вывести результат применения хотфиксов + ansible.builtin.debug: + msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}" + + - name: Проверить наличие index.html после копирования + ansible.builtin.stat: + path: "{{ aw_server_webui_dir }}/index.html" + register: aw_webui_ru_index + + - name: Проверить, что index.html доступен для RU patch + ansible.builtin.assert: + that: + - aw_webui_ru_index.stat.exists + - (aw_webui_ru_index.stat.size | default(0) | int) > 0 + fail_msg: "Не найден index.html WebUI для применения RU patch." + + - name: Проверить, что index.html не опустел после применения RU patch + ansible.builtin.stat: + path: "{{ aw_server_webui_dir }}/index.html" + register: aw_webui_ru_index_after_patch + + - name: Подтвердить, что index.html остался непустым + ansible.builtin.assert: + that: + - aw_webui_ru_index_after_patch.stat.exists + - (aw_webui_ru_index_after_patch.stat.size | default(0) | int) > 0 + fail_msg: "index.html WebUI пуст после RU patch." + + - name: Удалить старые теги RU patch из index.html + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: ']+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*>' + replace: '' + + - name: Добавить cleanup script RU patch в index.html + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: '' + replace: '' + + - name: Добавить загрузчик RU patch перед закрытием body + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: '' + replace: '' + + - name: Скопировать merge script AW DB на сервер + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/merge_aw_server_dbs.py" + dest: /usr/local/bin/merge_aw_server_dbs.py + owner: root + group: root + mode: "0755" + + - name: Проверить наличие legacy root DB + ansible.builtin.stat: + path: /root/.local/share/activitywatch/aw-server-rust/sqlite.db + register: aw_legacy_root_db + + - name: Проверить наличие target DB + ansible.builtin.stat: + path: "{{ aw_server_db_path }}" + register: aw_target_db + + - name: Остановить сервис перед merge server DB + ansible.builtin.systemd: + name: activitywatch-server.service + state: stopped + when: aw_legacy_root_db.stat.exists | default(false) + + - name: Создать backup каталоги server DB + ansible.builtin.file: + path: "{{ aw_server_data_dir }}/backups/db" + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_legacy_root_db.stat.exists | default(false) + + - name: Backup target DB перед merge + ansible.builtin.copy: + remote_src: true + src: "{{ aw_server_db_path }}" + dest: "{{ aw_server_data_dir }}/backups/db/target-before-merge-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + + - name: Backup legacy root DB перед merge + ansible.builtin.copy: + remote_src: true + src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db + dest: "{{ aw_server_data_dir }}/backups/db/legacy-root-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: aw_legacy_root_db.stat.exists | default(false) + + - name: Merge legacy root DB в target DB + ansible.builtin.command: + argv: + - python3 + - /usr/local/bin/merge_aw_server_dbs.py + - --base + - /root/.local/share/activitywatch/aw-server-rust/sqlite.db + - --overlay + - "{{ aw_server_db_path }}" + - --output + - "{{ aw_server_db_path }}.merged" + register: aw_merge_result + failed_when: false + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + + - name: Показать результат merge legacy root DB + ansible.builtin.debug: + msg: "{{ aw_merge_result.stdout | default(aw_merge_result.stderr | default('merge not executed')) }}" + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + + - name: Install merged DB as active target DB + ansible.builtin.copy: + remote_src: true + src: "{{ aw_server_db_path }}.merged" + dest: "{{ aw_server_db_path }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + - aw_merge_result is defined + - (aw_merge_result.rc | default(1) | int) == 0 + + - name: Скопировать legacy root DB в target DB если target ещё не существует + ansible.builtin.copy: + remote_src: true + src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db + dest: "{{ aw_server_db_path }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: + - aw_legacy_root_db.stat.exists | default(false) + - not (aw_target_db.stat.exists | default(false)) + + - name: Записать aw-server-rust config.toml с разрешёнными CORS origin + ansible.builtin.copy: + dest: "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust/config.toml" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + content: | + cors = [ + {% for origin in aw_server_cors_origins | default([]) %} + "{{ origin }}"{% if not loop.last %},{% endif %} + {% endfor %} + ] + + - name: Включить и запустить сервис + ansible.builtin.systemd: + name: activitywatch-server.service + enabled: true + state: restarted + daemon_reload: true + + - name: Дождаться ответа API + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info" + method: GET + status_code: 200 + register: aw_api + retries: 10 + delay: 3 + until: aw_api.status == 200 + + - name: Считать текущие server-side settings ansible.builtin.uri: - url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info" + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/" method: GET status_code: 200 - register: aw_api - retries: 10 - delay: 3 - until: aw_api.status == 200 + register: aw_settings_current + when: aw_apply_worktime_settings | default(false) | bool + + - name: Считать текущие server-side views + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views" + method: GET + status_code: 200 + register: aw_views_current + when: aw_apply_worktime_settings | default(false) | bool + + - name: Считать текущие server-side classes + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes" + method: GET + status_code: 200 + register: aw_classes_current + when: aw_apply_worktime_settings | default(false) | bool + + - name: Создать backup текущих server-side settings/views/classes + ansible.builtin.copy: + dest: "{{ aw_server_data_dir }}/backups/{{ item.name }}-{{ ansible_date_time.iso8601_basic_short }}.json" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + content: "{{ item.payload | to_nice_json }}" + loop: + - name: settings + payload: "{{ aw_settings_current.json | default({}) }}" + - name: views + payload: "{{ aw_views_current.json | default(none) }}" + - name: classes + payload: "{{ aw_classes_current.json | default(none) }}" + when: aw_apply_worktime_settings | default(false) | bool + + - name: Настроить DLP Aggregator (Phase 2) + block: + - name: Создать каталог для скриптов + ansible.builtin.file: + path: "/opt/activitywatch/scripts" + state: directory + owner: root + group: root + mode: "0755" + + - name: Скопировать агрегатор событий DLP + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/aggregate_dlp_events.py" + dest: "/opt/activitywatch/scripts/aggregate_dlp_events.py" + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit для агрегатора + ansible.builtin.copy: + dest: /etc/systemd/system/activitywatch-dlp-aggregator.service + content: | + [Unit] + Description=ActivityWatch DLP Event Aggregator + After=activitywatch-server.service + + [Service] + Type=oneshot + User={{ aw_server_user }} + WorkingDirectory={{ aw_server_data_dir }} + ExecStart=/usr/bin/python3 /opt/activitywatch/scripts/aggregate_dlp_events.py \ + --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ + --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ + --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json + + [Install] + WantedBy=multi-user.target + + - name: Установить systemd timer для агрегатора + ansible.builtin.copy: + dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer + content: | + [Unit] + Description=Run ActivityWatch DLP Aggregator every 5 minutes + + [Timer] + OnBootSec=1min + OnUnitActiveSec=5min + AccuracySec=1s + + [Install] + WantedBy=timers.target + + - name: Включить и запустить таймер агрегатора + ansible.builtin.systemd: + name: activitywatch-dlp-aggregator.timer + enabled: true + state: started + daemon_reload: true + + - name: Настроить IOC enrichment из Hayabusa Sigma + when: aw_dlp_ioc_enabled | default(false) | bool + block: + - name: Установить зависимости IOC refresh + ansible.builtin.apt: + name: + - python3-yaml + state: present + update_cache: true + + - name: Создать каталог IOC enrichment + ansible.builtin.file: + path: "{{ aw_dlp_ioc_workdir }}/{{ item }}" + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + loop: + - "" + - output + - tmp + + - name: Установить экстрактор IOC из Sigma + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/extract_ioc_from_sigma.py" + dest: /usr/local/bin/aw-extract-ioc-from-sigma.py + owner: root + group: root + mode: "0755" + + - name: Установить wrapper refresh IOC + ansible.builtin.copy: + dest: /usr/local/bin/aw-dlp-ioc-refresh.sh + owner: root + group: root + mode: "0755" + content: | + #!/usr/bin/env bash + set -euo pipefail + WORKDIR="{{ aw_dlp_ioc_workdir }}" + OUTDIR="${WORKDIR}/output" + TMPDIR="${WORKDIR}/tmp" + RULES_DIR="${TMPDIR}/hayabusa-rules" + ZIP_PATH="${TMPDIR}/hayabusa-rules.zip" + + mkdir -p "${OUTDIR}" "${TMPDIR}" + rm -rf "${RULES_DIR}" + curl -fsSL -o "${ZIP_PATH}" "{{ aw_dlp_ioc_rules_zip_url }}" + unzip -q -o "${ZIP_PATH}" -d "${TMPDIR}" + + EXTRACTED_DIR=$(find "${TMPDIR}" -maxdepth 1 -type d -name 'hayabusa-rules-*' | head -n1) + if [ -z "${EXTRACTED_DIR}" ]; then + echo "ERROR: hayabusa-rules archive extraction failed" >&2 + exit 1 + fi + mv "${EXTRACTED_DIR}" "${RULES_DIR}" + + /usr/bin/python3 /usr/local/bin/aw-extract-ioc-from-sigma.py \ + --rules-root "${RULES_DIR}" \ + --out-dir "${OUTDIR}" + + ln -sfn "${OUTDIR}" "${WORKDIR}/latest" + + - name: Установить systemd unit IOC refresh + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-ioc-refresh.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Refresh DLP IOC blacklist from Hayabusa Sigma rules + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + ExecStart=/usr/local/bin/aw-dlp-ioc-refresh.sh + + - name: Установить systemd timer IOC refresh + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-ioc-refresh.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Run DLP IOC refresh from Hayabusa rules + + [Timer] + OnBootSec={{ aw_dlp_ioc_refresh_on_boot_sec }} + OnUnitActiveSec={{ aw_dlp_ioc_refresh_interval }} + Persistent=true + Unit=aw-dlp-ioc-refresh.service + + [Install] + WantedBy=timers.target + + - name: Включить и запустить IOC refresh timer + ansible.builtin.systemd: + name: aw-dlp-ioc-refresh.timer + enabled: true + state: started + daemon_reload: true + + - name: Выполнить принудительный refresh IOC + ansible.builtin.systemd: + name: aw-dlp-ioc-refresh.service + state: started + + - name: Проверить наличие IOC артефактов после refresh + ansible.builtin.stat: + path: "{{ aw_dlp_ioc_workdir }}/output/{{ item }}" + register: aw_dlp_ioc_artifacts + loop: + - ioc_blacklist.json + - ioc_blacklist.csv + - ioc_blacklist.sql + + - name: Assert по IOC артефактам + ansible.builtin.assert: + that: + - item.stat.exists + - (item.stat.size | int) > 100 + fail_msg: "Не сгенерирован IOC артефакт: {{ item.stat.path | default('unknown') }}" + loop: "{{ aw_dlp_ioc_artifacts.results }}" - name: Применить базовые worktime settings (classes) ansible.builtin.uri: @@ -342,16 +1365,12 @@ - name: Вычислить worktime durationDefault из aw_worktime_from/to ansible.builtin.set_fact: - aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}" - aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}" - aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}" - aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}" aw_worktime_duration_default_derived: >- {{ ( ( - ((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) - - ((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int)) + (((aw_worktime_to | default('17:00')).split(':')[0] | int) * 60 + ((aw_worktime_to | default('17:00')).split(':')[1] | int)) - + (((aw_worktime_from | default('00:00')).split(':')[0] | int) * 60 + ((aw_worktime_from | default('00:00')).split(':')[1] | int)) ) * 60 ) }} @@ -379,20 +1398,185 @@ ansible.builtin.uri: url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay" method: POST - body: "{{ aw_worktime_start_of_day }}" - body_format: json - status_code: 200 + body: "\"{{ aw_worktime_start_of_day }}\"" + headers: + Content-Type: application/json + status_code: [200, 201] when: aw_apply_worktime_settings | default(false) | bool - name: Применить базовый период worktime (durationDefault seconds) ansible.builtin.uri: url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault" method: POST - body: "{{ aw_worktime_duration_default_effective }}" - body_format: json - status_code: 200 + body: "{{ aw_worktime_duration_default_effective | string }}" + headers: + Content-Type: application/json + status_code: [200, 201] when: aw_apply_worktime_settings | default(false) | bool + - name: Применить always_active_pattern для fallback без AFK + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/always_active_pattern" + method: POST + body: "\"{{ aw_server_always_active_pattern }}\"" + headers: + Content-Type: application/json + status_code: [200, 201] + when: + - aw_apply_worktime_settings | default(false) | bool + - (aw_server_always_active_pattern | default('') | string | length) > 0 + + - name: Применить landingpage профиля + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/landingpage" + method: POST + body: "\"{{ aw_server_landingpage }}\"" + headers: + Content-Type: application/json + status_code: [200, 201] + when: + - aw_apply_worktime_settings | default(false) | bool + - (aw_server_landingpage | default('') | string | length) > 0 + + - name: Обновить IOC blacklist из hayabusa (если включено) + when: + - aw_hayabusa_ioc_refresh_enabled | default(false) | bool + - aw_hayabusa_rules_root is defined + - aw_hayabusa_ioc_output_dir is defined + block: + - name: Создать выходную директорию для IOC + ansible.builtin.file: + path: "{{ aw_hayabusa_ioc_output_dir }}" + state: directory + mode: "0755" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + + - name: Обновить IOC blacklist из hayabusa правил + ansible.builtin.shell: + cmd: | + set -euo pipefail + {{ aw_repo_root }}/scripts/build_dlp_ioc_from_hayabusa.sh \ + "{{ aw_hayabusa_rules_root }}" \ + "{{ aw_hayabusa_ioc_output_dir }}" + args: + chdir: "{{ aw_repo_root }}" + register: aw_hayabusa_ioc_refresh_result + changed_when: "'IOC artifacts generated' in aw_hayabusa_ioc_refresh_result.stdout" + + - name: Показать результат обновления IOC + ansible.builtin.debug: + msg: "{{ aw_hayabusa_ioc_refresh_result.stdout }}" + when: aw_hayabusa_ioc_refresh_result.stdout is defined + + - name: Установить server-side Hayabusa runner + when: aw_hayabusa_runner_enabled | default(false) | bool + block: + - name: Создать каталоги Hayabusa + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ aw_hayabusa_root }}" + - "{{ aw_hayabusa_root }}/releases" + - "{{ aw_hayabusa_release_dir }}" + - "{{ aw_hayabusa_reports_dir }}" + - "{{ aw_hayabusa_state_dir }}" + - "{{ aw_hayabusa_inbox_dir }}" + - "{{ aw_hayabusa_archive_dir }}" + - "{{ aw_hayabusa_incoming_dir }}" + - "{{ aw_hayabusa_staging_dir }}" + - "{{ aw_hayabusa_archive_packages_dir }}" + - "{{ aw_hayabusa_archive_extracted_dir }}" + - "{{ aw_hayabusa_logs_dir }}" + + - name: Скачать архив Hayabusa + ansible.builtin.get_url: + url: "{{ aw_hayabusa_download_url }}" + dest: "{{ aw_hayabusa_archive_path }}" + mode: "0644" + + - name: Распаковать pinned Hayabusa release + ansible.builtin.unarchive: + src: "{{ aw_hayabusa_archive_path }}" + dest: "{{ aw_hayabusa_release_dir }}" + remote_src: true + creates: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}" + + - name: Нормализовать права release Hayabusa + ansible.builtin.file: + path: "{{ aw_hayabusa_release_dir }}" + state: directory + owner: root + group: root + mode: "0755" + recurse: true + + - name: Сделать бинарь Hayabusa исполняемым + ansible.builtin.file: + path: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}" + owner: root + group: root + mode: "0755" + state: file + + - name: Создать canonical symlink на текущий релиз Hayabusa + ansible.builtin.file: + src: "{{ aw_hayabusa_release_dir }}" + dest: "{{ aw_hayabusa_current_link }}" + state: link + force: true + + - name: Создать canonical symlink на бинарь Hayabusa + ansible.builtin.file: + src: "{{ aw_hayabusa_binary_name }}" + dest: "{{ aw_hayabusa_release_dir }}/hayabusa" + state: link + force: true + + - name: Установить wrapper aw-hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa.sh" + dest: /usr/local/bin/aw-hayabusa + owner: root + group: root + mode: "0755" + + - name: Проверить server-side runner через doctor + ansible.builtin.command: + cmd: /usr/local/bin/aw-hayabusa doctor + changed_when: false + + - name: Проверить загрузку profiles Hayabusa + ansible.builtin.shell: /usr/local/bin/aw-hayabusa profiles > /tmp/aw-hayabusa-profiles.txt + args: + executable: /bin/bash + changed_when: false + + - name: Проверить наличие standard profile у Hayabusa + ansible.builtin.shell: "grep -q 'standard:' /tmp/aw-hayabusa-profiles.txt" + args: + executable: /bin/bash + changed_when: false + + - name: Post-deploy health gate (aw-rus-healthd) + when: + - not ansible_check_mode + - aw_server_post_deploy_health_check_enabled | default(true) | bool + block: + - name: Запустить /usr/local/bin/aw-rus-healthd.py --json + ansible.builtin.command: + cmd: /usr/local/bin/aw-rus-healthd.py --json + register: aw_post_deploy_health + changed_when: false + + - name: Показать результат aw-rus-healthd + ansible.builtin.debug: + msg: "{{ aw_post_deploy_health.stdout }}" + handlers: - name: Перезагрузить systemd ansible.builtin.systemd: diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml index c78830c..c50b6eb 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml @@ -4,6 +4,8 @@ gather_facts: false vars: + ansible_winrm_operation_timeout_sec: 120 + ansible_winrm_read_timeout_sec: 180 aw_windows_repo_root: "{{ playbook_dir | dirname }}" aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus" aw_windows_server_scheme: "http" @@ -14,6 +16,7 @@ aw_windows_package_zip_path: "" aw_windows_domain: "SHARKON2025" aw_windows_users: + - Администратор - user1 - user2 - user3 @@ -23,24 +26,35 @@ aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}" aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin" aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" - aw_windows_afk_enabled: true - aw_windows_window_enabled: true + aw_windows_policy_mode: "server" + aw_windows_policy_refresh_seconds: 300 + aw_windows_policy_engine_enabled: true + aw_windows_policy_engine_host: "{{ aw_windows_server_host }}" + aw_windows_policy_engine_port: 5601 + aw_windows_policy_engine_scheme: "http" + aw_windows_afk_enabled_default: true + aw_windows_window_enabled_default: true + aw_windows_file_ops_enabled: true aw_windows_local_agent_logs_enabled: false aw_windows_incident_capture_enabled: true aw_windows_incident_screenshot_enabled: true aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" + aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" aw_windows_logon_marker_enabled: true aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json" - aw_windows_validation_local_dir: "/tmp/aw-rus-validation" + aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_launch_task_pattern: "ActivityWatch Launch *" aw_windows_recovery_task_name: "ActivityWatch Recovery" aw_windows_force_task_restart: true aw_windows_api_smoke_check_enabled: true aw_windows_api_smoke_check_bucket: "" aw_windows_api_smoke_check_limit: 10 + aw_windows_api_smoke_check_window_enabled_default: true + aw_windows_api_smoke_check_window_bucket: "" + aw_windows_api_smoke_check_min_events: 1 aw_windows_fail_on_validation_error: true aw_windows_migration_enabled: true aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2" @@ -60,6 +74,12 @@ - aw_windows_state_root is defined fail_msg: "Не заданы обязательные переменные Windows-развёртывания." + - 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 }}" + aw_windows_window_enabled_effective: "{{ (aw_windows_window_enabled | default(aw_windows_window_enabled_default)) | bool }}" + aw_windows_api_smoke_check_window_enabled_effective: "{{ (aw_windows_api_smoke_check_window_enabled | default(aw_windows_api_smoke_check_window_enabled_default)) | bool }}" + - name: Создать каталоги развёртывания ansible.windows.win_file: path: "{{ item }}" @@ -77,16 +97,32 @@ - ActivityWatch.Windows.Common.psm1 - browser-domains-native-collector.ps1 - dlp-endpoint-signals-collector.ps1 + - dlp-policy-client.ps1 - email-outbound-collector.ps1 + - file-operations-collector.ps1 - worktime-session-collector.ps1 + - export-evtx-for-hayabusa.ps1 - migrate-awatch-rus-paths.ps1 - deploy-domain-users.ps1 - deploy-ensemble.ps1 - hardening-recovery.ps1 + - rebuild-worktime-tasks.ps1 - validate-deployment.ps1 - web-category-rules.example.json - dlp-policy.example.json + - name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell) + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $toolkitDir = "{{ aw_windows_deploy_root }}\windows" + $encIn = New-Object System.Text.UTF8Encoding($false) + $encOut = New-Object System.Text.UTF8Encoding($true) + Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object { + $text = [System.IO.File]::ReadAllText($_.FullName, $encIn) + [System.IO.File]::WriteAllText($_.FullName, $text, $encOut) + } + - name: Загрузить список пользователей для доменного развёртывания ansible.windows.win_copy: dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt" @@ -129,13 +165,22 @@ UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt" InstallRoot = "{{ aw_windows_install_root }}" StateRoot = "{{ aw_windows_state_root }}" - AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }} - WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }} + AfkEnabled = {{ '$true' if (aw_windows_afk_enabled_effective | bool) else '$false' }} + WindowEnabled = {{ '$true' if (aw_windows_window_enabled_effective | bool) else '$false' }} + FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }} LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }} IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }} IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }} IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}" + EvtxExportRoot = "{{ aw_windows_forensics_root }}" + EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }} LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }} + PolicyMode = "{{ aw_windows_policy_mode }}" + PolicyEngineEnabled = {{ '$true' if (aw_windows_policy_engine_enabled | bool) else '$false' }} + PolicyEngineHost = "{{ aw_windows_policy_engine_host }}" + PolicyEnginePort = {{ aw_windows_policy_engine_port }} + PolicyEngineScheme = "{{ aw_windows_policy_engine_scheme }}" + PolicyRefreshSeconds = {{ aw_windows_policy_refresh_seconds }} CustomRulesPath = "{{ aw_windows_rules_path }}" CustomPolicyPath = "{{ aw_windows_policy_path }}" } @@ -145,89 +190,230 @@ {% if (aw_windows_package_zip_path | default('') | string | length) > 0 %} $params.PackageZipPath = "{{ aw_windows_package_zip_path }}" {% endif %} + {% if (aw_windows_evtx_channels | default([]) | length) > 0 %} + $params.EvtxChannels = @( + {% for channel in aw_windows_evtx_channels %} + "{{ channel }}"{% if not loop.last %},{% endif %} + {% endfor %} + ) + {% endif %} + {% if (aw_windows_hostname_override | default('') | string | length) > 0 %} + $params.AwHostname = "{{ aw_windows_hostname_override }}" + {% endif %} {% if aw_windows_skip_hardening | bool %} $params.SkipHardening = $true {% endif %} + {% if aw_windows_integration_test_enabled | bool %} + $params.IntegrationTestEnabled = $true + {% endif %} & "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params + - name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json + $desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName }) + foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) { + if ($desired -notcontains [string]$task.TaskName) { + Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null + } + } + - name: Принудительно запустить ActivityWatch recovery и launch tasks when: aw_windows_force_task_restart | bool ansible.windows.win_powershell: script: | $ErrorActionPreference = 'Stop' - 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 } + + function Get-CollectorKey { + param([string]$CommandLine) + if (-not $CommandLine) { return $null } + $cl = $CommandLine.ToLowerInvariant() + if ($cl -like '*browser-domains-native-collector.ps1*') { return 'browser' } + if ($cl -like '*file-operations-collector.ps1*') { return 'fileops' } + if ($cl -like '*dlp-endpoint-signals-collector.ps1*') { return 'endpoint' } + if ($cl -like '*email-outbound-collector.ps1*') { return 'email' } + if ($cl -like '*worktime-session-collector.ps1*') { return 'worktime' } + return $null + } + + $collectorProcs = Get-CimInstance Win32_Process | + Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine } | + ForEach-Object { + $key = Get-CollectorKey -CommandLine $_.CommandLine + if ($key) { + $groupKey = if ($key -eq 'worktime') { 'worktime::global' } else { '{0}::{1}' -f $key, ([int]$_.SessionId) } + [pscustomobject]@{ + ProcessId = [int]$_.ProcessId + SessionId = [int]$_.SessionId + CreationDate = $_.CreationDate + CollectorKey = $key + GroupKey = $groupKey + } + } + } | + Where-Object { $_ -ne $null } + + # Keep only one process per collector scope: worktime collector is global, others stay per-session. + foreach ($group in ($collectorProcs | Group-Object GroupKey)) { + $ordered = @($group.Group | Sort-Object CreationDate -Descending) + if ($ordered.Count -le 1) { continue } + foreach ($dup in $ordered | Select-Object -Skip 1) { + Stop-Process -Id $dup.ProcessId -Force -ErrorAction SilentlyContinue + } + } + + # Force managed collectors/watchers to reload the freshly deployed scripts. + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ( + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match 'C:\\ProgramData\\AWatch-rus\\' -and + $_.CommandLine -match '(collector|launch-watchers|recovery-loop)\.ps1' + ) -or + ($_.Name -ieq 'aw-watcher-afk.exe') -or + ($_.Name -ieq 'aw-watcher-window.exe') + } | + ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + + Start-Sleep -Seconds 2 + + # Ensure tasks are enabled (some environments keep them disabled, causing "0s" in WebUI). + try { + Enable-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue | Out-Null + } catch {} + + $config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json + foreach ($taskDef in @($config.userTasks)) { + try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {} + } + + $recoveryTask = Get-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue + if ($recoveryTask -and $recoveryTask.State -notin @('Running', 'Queued')) { + Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" + } + foreach ($taskDef in @($config.userTasks)) { + $launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + if ($launchTask -and $launchTask.State -notin @('Running', 'Queued')) { + Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + } + } - name: Получить Windows hostname для AW smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_enabled | bool ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME" register: aw_windows_hostname_result changed_when: false - - name: Вычислить AW AFK smoke-check bucket + - name: Вычислить AW worktime smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_enabled | bool + - aw_windows_hostname_result.stdout is defined ansible.builtin.set_fact: aw_windows_api_smoke_check_bucket_effective: >- {{ aw_windows_api_smoke_check_bucket if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0 - else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim) + else 'aw-worktime-sessions_' ~ (aw_windows_hostname_result.stdout | trim) }} - - name: Дождаться свежих AFK событий на AW server + - name: Вычислить AW Window smoke-check bucket + when: + - aw_windows_api_smoke_check_enabled | bool + - aw_windows_api_smoke_check_window_enabled_effective | bool + - aw_windows_window_enabled_effective | bool + - aw_windows_hostname_result.stdout is defined + ansible.builtin.set_fact: + aw_windows_api_smoke_check_window_bucket_effective: >- + {{ + 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) + }} + + - name: Выполнить AW API smoke-check (worktime bucket должен получать события) when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_enabled | bool - delegate_to: localhost ansible.builtin.uri: url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET + status_code: 200 return_content: true - register: aw_windows_api_smoke - until: > - aw_windows_api_smoke.status == 200 and - (aw_windows_api_smoke.json | length) > 0 and - ( - aw_windows_api_smoke.json - | selectattr('data.status', 'equalto', 'not-afk') + register: aw_windows_api_smoke_result + failed_when: false + until: >- + (aw_windows_api_smoke_result.status | default(0)) == 200 + and ((aw_windows_api_smoke_result.json | default([])) | length) >= aw_windows_api_smoke_check_min_events + and ( + (aw_windows_api_smoke_result.json | default([])) + | selectattr('data.source', 'equalto', 'worktime-session-collector') | list | length ) > 0 - retries: 10 - delay: 6 + retries: 12 + delay: 5 + ignore_errors: "{{ (not aw_windows_fail_on_validation_error | bool) }}" + delegate_to: localhost + changed_when: false - - name: Выполнить валидацию и сохранить отчёт на целевом Windows host + - name: Выполнить AW API smoke-check (Window bucket должен получать события) + when: + - aw_windows_api_smoke_check_enabled | bool + - aw_windows_api_smoke_check_window_enabled_effective | bool + - aw_windows_window_enabled_effective | bool + ansible.builtin.uri: + url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_window_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" + method: GET + status_code: 200 + return_content: true + register: aw_windows_api_smoke_window_result + failed_when: false + until: >- + (aw_windows_api_smoke_window_result.status | default(0)) == 200 + and ((aw_windows_api_smoke_window_result.json | default([])) | length) >= aw_windows_api_smoke_check_min_events + retries: 12 + delay: 5 + ignore_errors: "{{ (not aw_windows_fail_on_validation_error | bool) }}" + delegate_to: localhost + changed_when: false + + - name: Валидировать развёртывание на эндпоинте ansible.windows.win_powershell: script: | $ErrorActionPreference = 'Stop' - $report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" ` + $result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" ` -ConfigPath "{{ aw_windows_state_root }}\deployment-config.json" - $report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8 - if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) { - throw "Проверка развёртывания ActivityWatch завершилась ошибкой. Отчёт: {{ aw_windows_validation_remote_path }}" - } + $result | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8 + return $result - - name: Создать локальный каталог для validation reports + - name: Создать локальную директорию для отчётов валидации ansible.builtin.file: path: "{{ aw_windows_validation_local_dir }}" state: directory mode: "0755" delegate_to: localhost - - name: Забрать validation report + - name: Стянуть отчёт валидации с эндпоинта ansible.builtin.fetch: src: "{{ aw_windows_validation_remote_path }}" dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" flat: true - - name: Показать путь к отчёту - ansible.builtin.debug: - msg: - - "Windows/RDP развёртывание завершено на {{ inventory_hostname }}." - - "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" + - name: Проверить статус валидации + ansible.builtin.shell: | + python3 - <<'PY' + import json, sys + with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r', encoding='utf-8-sig') as f: + data = json.load(f) + if not data.get('overallOk', False): + failed = ", ".join(data.get("summary", {}).get("failedSections", [])) or "unknown" + print(f"Validation failed for {{ inventory_hostname }}: {failed}") + sys.exit(1) + PY + delegate_to: localhost + when: aw_windows_fail_on_validation_error | bool diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml index be28ad3..fada2ca 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml @@ -4,23 +4,69 @@ aw_server_bind_host: "0.0.0.0" aw_server_port: 5600 aw_server_webui_dir: "/opt/activitywatch/webui-ru" aw_server_data_dir: "/var/lib/activitywatch" +aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db" aw_server_log_dir: "/var/log/activitywatch" aw_server_user: "activitywatch" aw_server_group: "activitywatch" aw_worktime_report_base: "http://10.10.10.13:5610" aw_worktime_timezone: "Europe/Moscow" +aw_worktime_influx_enabled: false +aw_worktime_influx_url: "http://10.10.10.10:8086" +aw_worktime_influx_org: "proxmox" +aw_worktime_influx_bucket: "aw_metrics" +aw_worktime_influx_hosts: "SHARKON2025" +aw_worktime_influx_days: "today,yesterday" +aw_worktime_influx_token: "" +aw_dlp_influx_enabled: false +aw_dlp_influx_url: "http://10.10.10.10:8086" +aw_dlp_influx_org: "proxmox" +aw_dlp_influx_bucket: "aw_metrics" +aw_dlp_influx_hosts: "SHARKON2025" +aw_dlp_influx_lookback_days: 30 +aw_dlp_influx_event_limit: 2000 +aw_dlp_influx_token: "" +aw_monitored_windows_host: "192.168.100.18" +aw_monitored_windows_hostname: "SHARKON2025" +aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" +aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" aw_repo_root: "{{ playbook_dir | dirname }}" -# Опционально: применить базовые категории и views для рабочего времени через AW settings API. -# Внимание: это перезаписывает существующие server-side settings/classes/views. -aw_apply_worktime_settings: false +# Применить базовые категории и views для рабочего времени через AW settings API. +# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes. +aw_apply_worktime_settings: true + +# Дополнительные origin для aw-server-rust CORS. +# Обязательно включите тот origin, с которого реально открывается Web UI. +aw_server_cors_origins: + - "http://127.0.0.1:5600" + - "http://localhost:5600" + - "http://10.10.10.13:5600" + - "http://aw-server:5600" # Опциональные значения периода рабочего времени в Web UI. # startOfDay задаёт границу дня и стартовое время окна отчёта. # durationDefault задаёт диапазон по умолчанию в секундах. # # Рекомендуется явно задать рабочий интервал и дать playbook вычислить duration. -aw_worktime_from: "08:00" +aw_worktime_from: "00:00" aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" +aw_server_always_active_pattern: "aw-watcher-window" +aw_server_landingpage: "/activity/SHARKON2025/view/" +aw_health_strict_fileops: 0 + +aw_dlp_policy_engine_enabled: true +aw_dlp_policy_engine_bind_host: "0.0.0.0" +aw_dlp_policy_engine_port: 5601 +aw_dlp_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite" +aw_dlp_content_analysis_enabled: true +aw_dlp_integrations_enabled: true +aw_dlp_case_management_enabled: true +aw_dlp_case_bind_host: "0.0.0.0" +aw_dlp_case_port: 5602 +aw_dlp_case_db_path: "/opt/activitywatch/dlp-case-management/cases.db" +aw_dlp_compliance_enabled: true +aw_dlp_compliance_report_dir: "/opt/activitywatch/dlp-compliance/reports" +aw_dlp_compliance_template_path: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html" +aw_server_post_deploy_health_check_enabled: true diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml index 30c20c3..4dfe821 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml @@ -8,6 +8,7 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases aw_windows_package_zip_path: "" aw_windows_domain: "SHARKON2025" aw_windows_users: + - Администратор - user1 - user2 - user3 @@ -21,12 +22,23 @@ 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: "" # Например: SHARKON2025 aw_windows_afk_enabled: true aw_windows_window_enabled: true +aw_windows_file_ops_enabled: true aw_windows_local_agent_logs_enabled: false aw_windows_incident_capture_enabled: true aw_windows_incident_screenshot_enabled: true aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" +aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" +aw_windows_evtx_retention_days: 14 +aw_windows_evtx_channels: + - Security + - System + - Application + - Microsoft-Windows-PowerShell/Operational + - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational + - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true aw_windows_skip_hardening: false @@ -34,7 +46,7 @@ aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rule aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json" -aw_windows_validation_local_dir: "/tmp/aw-rus-validation" +aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_fail_on_validation_error: true # Безопасная миграция текущего прода со старых путей в единый профиль AWatch-rus. diff --git a/install-kit-awindows-20260427-211240/ansible/install_full_stack.yml b/install-kit-awindows-20260427-211240/ansible/install_full_stack.yml index cd3a7f6..f2694c9 100644 --- a/install-kit-awindows-20260427-211240/ansible/install_full_stack.yml +++ b/install-kit-awindows-20260427-211240/ansible/install_full_stack.yml @@ -5,6 +5,7 @@ # 2) развёртывание AW server на хостах [aw_server] # 3) развёртывание Windows/RDP collector'ов на [aw_windows] # 4) развёртывание pfSense poller'а на [aw_pfsense_pollers] +# 5) импорт Grafana dashboard'ов на [grafana] # # Примечания: # - Заполняйте только нужные группы inventory для своего окружения. @@ -14,3 +15,4 @@ - import_playbook: deploy_aw_server.yml - import_playbook: deploy_aw_windows.yml - import_playbook: deploy_aw_pfsense_poller.yml +- import_playbook: deploy_grafana_dashboards.yml diff --git a/install-kit-awindows-20260427-211240/ansible/inventory.example.ini b/install-kit-awindows-20260427-211240/ansible/inventory.example.ini index e1c77ff..8c8b1b0 100644 --- a/install-kit-awindows-20260427-211240/ansible/inventory.example.ini +++ b/install-kit-awindows-20260427-211240/ansible/inventory.example.ini @@ -6,7 +6,12 @@ aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22 [aw_windows] # Примечание: в русифицированных Windows часто нужен "Администратор", а не "Administrator". -win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore +win-node1 ansible_host=192.168.100.18 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore [aw_pfsense_pollers] # pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22 + +[grafana] +# Для API-import playbook достаточно указать grafana_url. +# SSH-подключение не требуется: playbook работает через HTTP API с control host. +grafana-main grafana_url=http://10.20.30.11:3000 diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml index 40c6382..1ac786e 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -11,6 +11,9 @@ - activitywatch-server.service - aw-worktime-api.py - aw-worktime-api.service + - aw-worktime-ui-bridge.py + - aw-worktime-ui-bridge.service + - aw-worktime-ui-bridge.timer - aw-worktime-panel.js - aw-server.env.example - aw-ru-patch.js diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index 5452935..e0ff9bc 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -11,6 +11,9 @@ - activitywatch-server.service - aw-worktime-api.py - aw-worktime-api.service + - aw-worktime-ui-bridge.py + - aw-worktime-ui-bridge.service + - aw-worktime-ui-bridge.timer - aw-worktime-panel.js - aw-server.env.example - aw-ru-patch.js diff --git a/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml index 0551e2f..a2610fd 100644 --- a/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml @@ -159,6 +159,7 @@ AW_SERVER_LOG_DIR={{ aw_server_log_dir }} AW_SERVER_USER={{ aw_server_user }} AW_SERVER_GROUP={{ aw_server_group }} + AW_SERVER_URL=http://127.0.0.1:{{ aw_server_port }} AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} AW_WORKTIME_TZ={{ aw_worktime_timezone }} no_log: true diff --git a/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service b/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service index e8f26e3..89d31e3 100755 --- a/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service +++ b/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service @@ -9,7 +9,7 @@ EnvironmentFile=/etc/activitywatch/aw-server.env User=__AW_SERVER_USER__ Group=__AW_SERVER_GROUP__ WorkingDirectory=__AW_SERVER_DATA_DIR__ -ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"' +ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT" --dbpath "$AW_SERVER_DB_PATH" --webpath "$AW_SERVER_WEBUI_DIR"' Restart=on-failure RestartSec=5s StateDirectory=activitywatch @@ -17,7 +17,7 @@ LogsDirectory=activitywatch NoNewPrivileges=true PrivateTmp=true ProtectSystem=full -ProtectHome=true +ProtectHome=read-only LimitNOFILE=65535 [Install] diff --git a/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh b/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh index 32e53fa..343ee06 100755 --- a/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh +++ b/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh @@ -11,6 +11,8 @@ source "$ENV_FILE" WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}" REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}" +CASE_PORT="${AW_DLP_CASE_PORT:-5602}" +CASE_BASE="${AW_DLP_CASE_PUBLIC_BASE:-}" PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js" SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js" WORKTIME_PANEL_SRC="/root/bootstrap/aw-worktime-panel.js" @@ -35,6 +37,17 @@ CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){retu [[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; } [[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; } +if [[ ! -s "$INDEX_HTML" ]]; then + latest_nonempty_backup="$(find "$WEBUI_DIR" -maxdepth 1 -type f -name 'index.html.bak.*' -size +0c | sort | tail -n 1 || true)" + if [[ -n "$latest_nonempty_backup" ]]; then + cp "$latest_nonempty_backup" "$INDEX_HTML" + echo "restored empty index.html from backup: $latest_nonempty_backup" + else + echo "index.html is empty and no non-empty backup exists: $INDEX_HTML" >&2 + exit 1 + fi +fi + install -d "$WEBUI_DIR/js" install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET" install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET" @@ -46,6 +59,21 @@ patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')" sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')" worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1,1,12)}')" +if [[ -z "$CASE_BASE" ]]; then + CASE_BASE="$(python3 - "$REPORT_BASE" "$CASE_PORT" <<'PY' +from urllib.parse import urlsplit, urlunsplit +import sys + +report_base = sys.argv[1] +case_port = sys.argv[2] +parts = urlsplit(report_base) +hostname = parts.hostname or "10.10.10.13" +scheme = parts.scheme or "http" +print(urlunsplit((scheme, f"{hostname}:{case_port}", "", "", ""))) +PY +)" +fi + python3 - "$WORKTIME_PANEL_TARGET" "$REPORT_BASE" <<'PY' from pathlib import Path import sys @@ -57,16 +85,18 @@ text = text.replace("__AW_WORKTIME_REPORT_BASE__", report_base) path.write_text(text) PY -python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" <<'PY' +python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" "$CASE_BASE" <<'PY' from pathlib import Path import re import sys +from urllib.parse import urlsplit path = Path(sys.argv[1]) sw_hash = sys.argv[2] patch_hash = sys.argv[3] panel_hash = sys.argv[4] report_base = sys.argv[5] +case_base = sys.argv[6] content = path.read_text() content = re.sub( @@ -74,23 +104,33 @@ content = re.sub( '', content, ) -content = re.sub(r"; frame-src 'self' [^\";>]*", "", content) -content = content.replace( - "script-src 'self' 'unsafe-eval'", - f"script-src 'self' 'unsafe-eval'; frame-src 'self' {report_base}", - 1, +content = re.sub(r";\s*frame-src 'self' [^\";>]*", "", content) +content = re.sub(r";\s*connect-src 'self' [^\";>]*", "", content) +report_origin = urlsplit(report_base) +case_origin = urlsplit(case_base) +connect_targets = " ".join( + [ + f"{report_origin.scheme}://{report_origin.netloc}", + f"{case_origin.scheme}://{case_origin.netloc}", + ] +) +content = re.sub( + r"script-src 'self' 'unsafe-eval'(?:;\s*connect-src 'self' [^\";>]*)?(?:;\s*frame-src 'self' [^\";>]*)?", + f"script-src 'self' 'unsafe-eval'; connect-src 'self' {connect_targets}; frame-src 'self' {report_base}", + content, + count=1, ) content = content.replace( "", - f'', + ( + f'' + f'' + ), 1, ) content = content.replace( "", - ( - f'' - f'' - ), + f'', 1, ) if 'id="aw-report-links"' not in content: @@ -103,6 +143,11 @@ path.write_text(content) PY cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER" +if [[ ! -s "$INDEX_HTML" ]]; then + echo "index.html became empty after RU patch: $INDEX_HTML" >&2 + exit 1 +fi + trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)" if [[ -n "$trends_chunk" ]]; then cp "$trends_chunk" "$trends_chunk.bak.$TS" diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json b/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json index f3d0118..7432f60 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json +++ b/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json @@ -29,6 +29,21 @@ { "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" } ] }, + { + "id": "linux-remote", + "name": "Linux remote workers", + "description": "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.", + "patterns": [ + "^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)" + ], + "links": [ + { "label": "Активность", "type": "activity" }, + { "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" }, + { "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" }, + { "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" }, + { "label": "Все бакеты", "type": "buckets" } + ] + }, { "id": "virtual-infra", "name": "Virtual servers + Proxmox", diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js b/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js index 91cfa39..0e7b585 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js +++ b/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js @@ -1,6 +1,10 @@ (function () { - window.__awRuPatchVersion = "template-v12-activity-heading-ru"; - document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru"); + if (window.__awRuPatchBootstrapped) { + return; + } + window.__awRuPatchBootstrapped = true; + window.__awRuPatchVersion = "template-v13-category-builder-early-fix"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-category-builder-early-fix"); const exact = new Map([ ["ActivityWatch", "АктивВотч"], @@ -370,25 +374,43 @@ return /^pve[-_]/i.test(String(host || "")); } + function isLikelyClientHost(host) { + const value = String(host || "").trim(); + if (!value) return false; + if (/^(?:unknown|undefined|null)$/i.test(value)) return false; + if (/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)$/i.test(value)) return false; + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return false; + if (value.indexOf(":") !== -1 && /^[0-9a-f:\[\]]+$/i.test(value)) return false; + return true; + } + function enforceSafeActivityViewForPveHost() { const hash = window.location.hash || ""; - const match = hash.match(/^#\/activity\/([^/]+)\/day\/([^/]+)\/view\/([^/?#]+)/i); + const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i); if (!match) return; const host = decodeURIComponent(match[1] || ""); - const day = decodeURIComponent(match[2] || ""); + const day = match[2] ? decodeURIComponent(match[2]) : ""; const viewId = decodeURIComponent(match[3] || ""); - if (!isPveLikeHost(host)) return; - const safeHash = "#/activity/" + encodeURIComponent(host) + "/day/" + encodeURIComponent(day) + "/view/" + encodeURIComponent("pve_audit"); - if (safeHash !== hash && !/^pve_audit$/i.test(viewId)) { - window.location.replace(safeHash); + const prefix = day + ? "#/activity/" + encodeURIComponent(host) + "/day/" + encodeURIComponent(day) + "/view/" + : "#/activity/" + encodeURIComponent(host) + "/view/"; + if (isPveLikeHost(host)) { + const safeHash = prefix + encodeURIComponent("pve_audit"); + if (safeHash !== hash && !/^pve_audit$/i.test(viewId)) { + window.location.replace(safeHash); + } + return; + } + if (/^pve_audit$/i.test(viewId)) { + window.location.replace(prefix + encodeURIComponent("summary")); } } function getDlpHostFromSettings(settings) { const routeHost = getCurrentHostFromHash(); - if (routeHost) return routeHost; + if (isLikelyClientHost(routeHost)) return routeHost; const bucketHost = getDlpHostFromBucketId(getDlpBucketIdFromHash()); - if (bucketHost) return bucketHost; + if (isLikelyClientHost(bucketHost)) return bucketHost; return getTrendsHostFromSettings(settings); } @@ -680,6 +702,19 @@ { label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" } ] }, + { + id: "linux-remote", + name: "Linux remote workers", + description: "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.", + patterns: ["^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"], + links: [ + { label: "Активность", type: "activity" }, + { label: "SSH сессии", type: "bucket", bucket_prefix: "aw-ssh-sessions_" }, + { label: "Команды shell", type: "bucket", bucket_prefix: "aw-console-commands_" }, + { label: "Web категории", type: "bucket", bucket_prefix: "aw-detmir-web-category_" }, + { label: "Все бакеты", type: "buckets" } + ] + }, { id: "virtual-infra", name: "Virtual servers + Proxmox", @@ -740,7 +775,15 @@ const prefixes = [ "aw-watcher-window_", "aw-watcher-afk_", + "aw-console-commands_", + "aw-ssh-sessions_", + "aw-linux-web-context_", + "aw-detmir-web-category_", "aw-dlp-endpoint-signals_", + "aw-session-events_", + "aw-worktime-sessions_", + "aw-pve-webadmin-events_", + "aw-pve-task-events_", "aw-dlp-incidents_", "aw-pfsense-health_", "aw-pfsense-gateways_", @@ -770,7 +813,27 @@ return result; } - function matchHostGroup(host, groups) { + function hostHasBucketPrefix(hostBuckets, prefix) { + return (hostBuckets || []).some(function (bucketId) { + return String(bucketId || "").indexOf(prefix) === 0; + }); + } + + function matchHostGroup(host, groups, hostBuckets) { + const bucketList = hostBuckets || []; + if (hostHasBucketPrefix(bucketList, "aw-dlp-endpoint-signals_") || hostHasBucketPrefix(bucketList, "aw-session-events_")) { + return "windows-rdp"; + } + if ( + hostHasBucketPrefix(bucketList, "aw-console-commands_") || + hostHasBucketPrefix(bucketList, "aw-ssh-sessions_") || + hostHasBucketPrefix(bucketList, "aw-linux-web-context_") || + hostHasBucketPrefix(bucketList, "aw-detmir-web-category_") + ) { + if (!hostHasBucketPrefix(bucketList, "aw-pve-webadmin-events_") && !hostHasBucketPrefix(bucketList, "aw-pve-task-events_")) { + return "linux-remote"; + } + } for (const group of groups) { const patterns = Array.isArray(group.patterns) ? group.patterns : []; for (const pattern of patterns) { @@ -813,7 +876,7 @@ grouped.set("__ungrouped__", []); Array.from(hostBuckets.keys()).sort().forEach(function (host) { - const groupId = matchHostGroup(host, groups) || "__ungrouped__"; + const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__"; grouped.get(groupId).push(host); }); @@ -869,7 +932,7 @@ center.setAttribute("data-aw-ru-host-groups", "1"); center.innerHTML = '

Разделы хостов

' + - '

Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.

' + + '

Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.

' + '

Загрузка...

'; heading.parentElement.insertBefore(center, heading.nextSibling); } @@ -885,9 +948,10 @@ const hideSuppressed = center.querySelector("[data-aw-ru-hide-suppressed]") && center.querySelector("[data-aw-ru-hide-suppressed]").checked; const rows = []; for (const event of state.events) { + const data = event.data || {}; + if (String(data.signalType || "").toLowerCase() === "self_test") continue; const matchedRule = state.activeRules.find(function (rule) { return ruleMatchesEvent(rule, event); }) || null; if (hideSuppressed && matchedRule) continue; - const data = event.data || {}; const eventKey = buildDlpKey(event); rows.push( '' + @@ -907,6 +971,7 @@ '' + '' + '' + + '' + "" + "" ); @@ -977,6 +1042,58 @@ }, 1); } + function getCaseApiBase() { + if (window.__awCaseApiBase && typeof window.__awCaseApiBase === "string") { + return window.__awCaseApiBase.replace(/\/+$/, ""); + } + try { + const origin = window.location.origin || ""; + if (/:\d+$/.test(origin)) return origin.replace(/:\d+$/, ":5602"); + return origin + ":5602"; + } catch (error) { + return "http://127.0.0.1:5602"; + } + } + + async function caseApi(path, init) { + const response = await fetch(getCaseApiBase() + path, Object.assign({ credentials: "omit" }, init || {})); + if (!response.ok) throw new Error("Case API HTTP " + response.status); + if (response.status === 204) return null; + return response.json(); + } + + async function createCaseFromEvent(host, event, row) { + const data = event.data || {}; + if (String(data.signalType || "").toLowerCase() === "self_test") { + throw new Error("self_test не должен превращаться в кейс"); + } + const verdict = row.querySelector("[data-aw-ru-dlp-verdict]").value; + const category = row.querySelector("[data-aw-ru-dlp-category]").value.trim(); + const comment = row.querySelector("[data-aw-ru-dlp-comment]").value.trim(); + const incidentId = buildDlpKey(event); + const title = "DLP " + (data.signalType || "incident") + " · " + (data.username || data.owner || host || "unknown"); + return caseApi("/api/0/dlp/cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + incident_id: incidentId, + host: host, + title: title, + severity: verdict === "incident" ? "high" : "medium", + source_bucket: getDlpBucketIdFromHash(), + source_event_ts: event.timestamp, + evidence: { + signalType: data.signalType || "", + username: data.username || data.owner || "", + documentName: data.documentName || "", + printerName: data.printerName || "", + category: category, + comment: comment + } + }) + }); + } + async function saveDlpRule(host, event, row) { const bucketId = "aw-dlp-rules_" + host; await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host); @@ -1030,9 +1147,57 @@ message.textContent = "Ошибка сохранения правила: " + error.message; } }); + row.querySelector("[data-aw-ru-create-case]").addEventListener("click", async function () { + const message = center.querySelector("[data-aw-ru-dlp-message]"); + try { + const created = await createCaseFromEvent(host, event, row); + await renderCaseManager(center, host); + message.textContent = "Кейс создан: #" + (created && created.id ? created.id : "?"); + } catch (error) { + message.textContent = "Ошибка создания кейса: " + error.message; + } + }); }); } + async function renderCaseManager(center, host) { + const tbody = center.querySelector("[data-aw-ru-dlp-cases]"); + if (!tbody) return; + try { + const cases = await caseApi("/api/0/dlp/cases?host=" + encodeURIComponent(host) + "&limit=100", { method: "GET" }); + function renderCaseDfir(c) { + const hayabusa = c && c.forensics && c.forensics.hayabusa; + if (!hayabusa) return ""; + const status = String(hayabusa.status || ""); + const mode = String(hayabusa.mode || ""); + const reportDir = String(hayabusa.report_dir || ""); + const title = reportDir ? ' title="' + escapeHtml(reportDir) + '"' : ""; + return 'Hayabusa ' + escapeHtml(status) + (mode ? " · " + escapeHtml(mode) : "") + ''; + } + const rows = (cases || []).map(function (c) { + return ( + "" + + "" + escapeHtml(String(c.id || "")) + "" + + "" + escapeHtml(String(c.status || "")) + "" + + "" + escapeHtml(String(c.severity || "")) + "" + + "" + escapeHtml(String(c.title || "")) + "" + + "" + escapeHtml(String(c.assignee || "")) + "" + + "" + escapeHtml(String(c.incident_id || "")) + "" + + "" + renderCaseDfir(c) + "" + + "" + escapeHtml(String(c.updated_at || c.created_at || "")) + "" + + "" + ); + }); + tbody.innerHTML = rows.length ? rows.join("") : 'Кейсов нет.'; + const status = center.querySelector("[data-aw-ru-dlp-cases-status]"); + if (status) status.textContent = "Кейсов: " + (cases || []).length; + } catch (error) { + tbody.innerHTML = 'Ошибка загрузки кейсов: ' + escapeHtml(error.message) + ''; + const status = center.querySelector("[data-aw-ru-dlp-cases-status]"); + if (status) status.textContent = "Кейсы недоступны"; + } + } + async function setDlpRuleEnabled(host, ruleEvent, enabled) { const bucketId = "aw-dlp-rules_" + host; await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host); @@ -1187,6 +1352,7 @@ state.reviews = []; } renderDlpTableRows(center, host); + await renderCaseManager(center, host); center.querySelector("[data-aw-ru-dlp-message]").textContent = "DLP review центр обновлен."; } catch (error) { center.querySelector("[data-aw-ru-dlp-message]").textContent = "Ошибка загрузки DLP-событий: " + error.message; @@ -1242,6 +1408,16 @@ 'Загрузка...' + '' + '' + + '
' + + '
' + + '
Case Management
' + + '
Кейсов: 0
' + + '
' + + '' + + '' + + '' + + '
IDСтатусSeverityЗаголовокИсполнительIncident IDDFIRОбновлено
Загрузка...
' + + '
' + '
'; heading.parentElement.insertBefore(center, heading.nextSibling); center.querySelector("[data-aw-ru-refresh-dlp]").addEventListener("click", function () { @@ -1378,6 +1554,16 @@ } } + function hidePveAuditTabForRegularHost(root) { + const hash = window.location.hash || ""; + const match = hash.match(/^#\/activity\/([^/]+)/i); + const host = match && match[1] ? decodeURIComponent(match[1]) : ""; + if (!host || isPveLikeHost(host)) return; + Array.from(root.querySelectorAll('a[href*="/view/pve_audit"]')).forEach(function (link) { + link.style.display = "none"; + }); + } + function injectDlpAlertsCenter(root) { if (!isAlertsRoute()) return; const host = window.__awRuPatchSettingsHost || getCurrentHostFromHash(); @@ -1414,18 +1600,27 @@ center.setAttribute("data-aw-ru-loaded", "1"); refreshDlpAlertsCenter(center, host); } + Array.from(heading.parentElement.children).forEach(function (child) { + if (child === heading || child === center) return; + child.style.display = "none"; + }); } let trendsRedirectInFlight = false; let settingsHostFetchInFlight = false; let applyPatchScheduled = false; let networkPatchesInstalled = false; + let dlpOverlayFailureCount = 0; + let applyPatchInFlight = false; + let observerAttached = false; + let staticPatchRouteKey = ""; function getTrendsHostFromSettings(settings) { if (!settings || typeof settings !== "object") return ""; const landingpage = typeof settings.landingpage === "string" ? settings.landingpage : ""; const match = landingpage.match(/\/activity\/([^/]+)/); - return match && match[1] ? match[1] : ""; + const host = match && match[1] ? decodeURIComponent(match[1]) : ""; + return isLikelyClientHost(host) ? host : ""; } function getTrendsPath(hash) { @@ -1477,6 +1672,9 @@ .finally(function () { settingsHostFetchInFlight = false; injectDlpNavigation(document.body); + if (isAlertsRoute()) { + scheduleApplyPatch(); + } }); } @@ -1492,14 +1690,25 @@ .map(function (bucketId) { return bucketId.replace(/^aw-watcher-window_/i, ""); }) .filter(Boolean) .filter(function (host) { return !/^unknown$/i.test(host); }); - if (settingsHost && hosts.indexOf(settingsHost) >= 0) return settingsHost; - if (settingsHost) return settingsHost; + if (isLikelyClientHost(settingsHost) && hosts.indexOf(settingsHost) >= 0) return settingsHost; + if (isLikelyClientHost(settingsHost) && !hosts.length) return settingsHost; hosts.sort(); return hosts[0] || ""; } function rewriteUnknownCategoryBuilderQueryBody(body) { if (typeof body !== "string") return body; + function stripUnknownBucketTokens(raw) { + return raw + .replace(/aw-watcher-window_unknown/gi, "__AW_RU_UNKNOWN_WINDOW__") + .replace(/aw-watcher-afk_unknown/gi, "__AW_RU_UNKNOWN_AFK__") + .replace(/find_bucket\((\\?["'])__AW_RU_UNKNOWN_WINDOW__(\\?["'])\)/gi, "[]") + .replace(/find_bucket\((\\?["'])__AW_RU_UNKNOWN_AFK__(\\?["'])\)/gi, "[]") + .replace(/query_bucket\((\\?["'])__AW_RU_UNKNOWN_WINDOW__(\\?["'])\)/gi, "[]") + .replace(/query_bucket\((\\?["'])__AW_RU_UNKNOWN_AFK__(\\?["'])\)/gi, "[]") + .replace(/__AW_RU_UNKNOWN_WINDOW__/g, "") + .replace(/__AW_RU_UNKNOWN_AFK__/g, ""); + } function stripUnknownBucketQueries(raw) { return raw .replace(/flood\(query_bucket\(find_bucket\(\\"aw-watcher-window_unknown\\"\)\)\)/g, '[]') @@ -1534,6 +1743,10 @@ body = stripUnknownBucketQueries(body); } } + if (body.indexOf("aw-watcher-window_unknown") !== -1 || body.indexOf("aw-watcher-afk_unknown") !== -1) { + body = stripUnknownBucketQueries(body); + body = stripUnknownBucketTokens(body); + } return body; } @@ -1541,20 +1754,25 @@ if (networkPatchesInstalled) return; networkPatchesInstalled = true; - const originalFetch = window.fetch ? window.fetch.bind(window) : null; - if (originalFetch) { - window.fetch = function (input, init) { + const originalFetch = window.fetch; + if (typeof originalFetch === "function" && !originalFetch.__awRuCategoryBuilderPatched) { + const patchedFetch = function (input, init) { + let nextInput = input; + let nextInit = init; try { - const url = typeof input === "string" ? input : String(input && input.url || ""); - if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") { - init = Object.assign({}, init, { - body: rewriteUnknownCategoryBuilderQueryBody(init.body) + const url = typeof nextInput === "string" ? nextInput : String(nextInput && nextInput.url || ""); + if (/\/api\/0\/query\/?$/i.test(url) && nextInit && typeof nextInit.body === "string") { + nextInit = Object.assign({}, nextInit, { + body: rewriteUnknownCategoryBuilderQueryBody(nextInit.body) }); } } catch (error) { } - return originalFetch(input, init); + return originalFetch.call(this, nextInput, nextInit); }; + patchedFetch.__awRuCategoryBuilderPatched = true; + patchedFetch.__awRuOriginalFetch = originalFetch; + window.fetch = patchedFetch; } if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) { @@ -1599,6 +1817,45 @@ }); } + function normalizeCategoryBuilderUnknownHostRefs() { + const hash = window.location.hash || ""; + if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(hash)) return; + const preferredHost = getPreferredWindowHostFromBuckets(); + if (!preferredHost) return; + + const nextHash = hash + .replace(/aw-watcher-window_unknown/gi, "aw-watcher-window_" + preferredHost) + .replace(/aw-watcher-afk_unknown/gi, "aw-watcher-afk_" + preferredHost); + if (nextHash !== hash) { + window.location.replace(nextHash); + return; + } + + try { + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if (!key) continue; + const value = window.localStorage.getItem(key); + if (!value || (value.indexOf("aw-watcher-window_unknown") === -1 && value.indexOf("aw-watcher-afk_unknown") === -1)) continue; + window.localStorage.setItem( + key, + value + .replace(/aw-watcher-window_unknown/gi, "aw-watcher-window_" + preferredHost) + .replace(/aw-watcher-afk_unknown/gi, "aw-watcher-afk_" + preferredHost) + ); + } + } catch (error) { + } + } + + function primeCategoryBuilderEarlyFix() { + const hash = window.location.hash || ""; + if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(hash)) return; + ensureSettingsHost(); + ensureHostGroupsData().catch(function () {}); + normalizeCategoryBuilderUnknownHostRefs(); + } + function patchActivityHeading(root) { const heading = root.querySelector("h3"); if (!heading) return; @@ -1611,27 +1868,64 @@ }); } + function detachObserver() { + if (!observerAttached) return; + observer.disconnect(); + observerAttached = false; + } + + function attachObserver() { + if (observerAttached || !document.body) return; + observer.observe(document.body, { childList: true, subtree: true }); + observerAttached = true; + } + function applyPatch() { - enforceSafeActivityViewForPveHost(); - ensureSettingsHost(); - ensureHostGroupsData().catch(function () {}); - installCategoryBuilderNetworkPatch(); - injectStyles(); - walk(document.body); - translateAttributes(document.body); - hideNoiseNavigation(document.body); - patchActivityHeading(document.body); - patchCategoryBuilderHostLabel(document.body); - injectPveAuditCenter(document.body); - injectDlpNavigation(document.body); - injectDlpReviewCenter(document.body); - injectDlpAlertsCenter(document.body); - injectHostGroupsCenter(document.body).catch(function () {}); - redirectBareTrendsRoute(); + if (applyPatchInFlight || !document.body) return; + applyPatchInFlight = true; + detachObserver(); + try { + const routeKey = window.location.hash || "#"; + const routeChanged = routeKey !== staticPatchRouteKey; + enforceSafeActivityViewForPveHost(); + ensureSettingsHost(); + ensureHostGroupsData().catch(function () {}); + normalizeCategoryBuilderUnknownHostRefs(); + installCategoryBuilderNetworkPatch(); + injectStyles(); + if (routeChanged) { + walk(document.body); + translateAttributes(document.body); + hideNoiseNavigation(document.body); + hidePveAuditTabForRegularHost(document.body); + patchActivityHeading(document.body); + patchCategoryBuilderHostLabel(document.body); + staticPatchRouteKey = routeKey; + } + injectPveAuditCenter(document.body); + injectDlpNavigation(document.body); + if (isDlpSignalBucketRoute() && dlpOverlayFailureCount === 0) { + try { + injectDlpReviewCenter(document.body); + } catch (error) { + dlpOverlayFailureCount += 1; + const existing = document.body.querySelector("[data-aw-ru-dlp-center='1']"); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + } + } else if (!isDlpSignalBucketRoute()) { + injectDlpReviewCenter(document.body); + } + injectDlpAlertsCenter(document.body); + injectHostGroupsCenter(document.body).catch(function () {}); + redirectBareTrendsRoute(); + } finally { + applyPatchInFlight = false; + attachObserver(); + } } function scheduleApplyPatch() { - if (applyPatchScheduled) return; + if (applyPatchScheduled || applyPatchInFlight) return; applyPatchScheduled = true; window.setTimeout(function () { applyPatchScheduled = false; @@ -1640,15 +1934,20 @@ } const observer = new MutationObserver(function () { + if (applyPatchInFlight) return; scheduleApplyPatch(); }); + installCategoryBuilderNetworkPatch(); + primeCategoryBuilderEarlyFix(); + window.addEventListener("load", function () { applyPatch(); - observer.observe(document.body, { childList: true, subtree: true }); + attachObserver(); }); window.addEventListener("hashchange", function () { redirectBareTrendsRoute(); + staticPatchRouteKey = ""; scheduleApplyPatch(); }); })(); diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py new file mode 100644 index 0000000..0423403 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib import request + + +ENV_FILE = Path("/etc/activitywatch/aw-server.env") + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'").strip('"') + os.environ.setdefault(key, value) + + +def env(name: str, default: str) -> str: + value = os.environ.get(name) + return value if value not in (None, "") else default + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +def parse_ts(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + except ValueError: + return None + + +def age_seconds(ts: datetime | None, now: datetime) -> int | None: + if ts is None: + return None + return max(0, int((now - ts).total_seconds())) + + +def http_json(url: str, timeout: int = 10) -> Any: + with request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def run_command(cmd: list[str]) -> tuple[int, str]: + proc = subprocess.run( + cmd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return proc.returncode, proc.stdout.strip() + + +def tcp_connect(host: str, port: int, timeout: float) -> tuple[bool, str]: + try: + with socket.create_connection((host, port), timeout=timeout): + return True, "connected" + except OSError as exc: + return False, str(exc) + + +@dataclass +class CheckResult: + name: str + status: str + summary: str + details: dict[str, Any] + + +class Report: + def __init__(self) -> None: + self.results: list[CheckResult] = [] + + def add(self, name: str, status: str, summary: str, **details: Any) -> None: + self.results.append(CheckResult(name=name, status=status, summary=summary, details=details)) + + @property + def ok(self) -> bool: + return not any(item.status == "fail" for item in self.results) + + def as_dict(self) -> dict[str, Any]: + counts = {"ok": 0, "warn": 0, "fail": 0} + for item in self.results: + counts[item.status] = counts.get(item.status, 0) + 1 + return { + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + "ok": self.ok, + "counts": counts, + "results": [ + { + "name": item.name, + "status": item.status, + "summary": item.summary, + "details": item.details, + } + for item in self.results + ], + } + + def render_text(self) -> str: + icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"} + lines = ["=== AW-RUS Health ===", f"Timestamp: {now_utc().isoformat().replace('+00:00', 'Z')}", ""] + for item in self.results: + lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}") + lines.append("") + payload = self.as_dict() + lines.append( + "Counts: ok={ok} warn={warn} fail={fail}".format( + ok=payload["counts"]["ok"], + warn=payload["counts"]["warn"], + fail=payload["counts"]["fail"], + ) + ) + lines.append(f"Overall: {'OK' if payload['ok'] else 'FAIL'}") + return "\n".join(lines) + + +def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: + events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20") + if isinstance(events, list) and events: + events = [item for item in events if isinstance(item, dict)] + if not events: + return None + events.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return events[0] + return None + + +def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]: + now = now_utc() + if not event: + return {"fresh": False, "active": False, "age_seconds": None, "timestamp": None} + ts = parse_ts(event.get("timestamp")) + age = age_seconds(ts, now) + data = event.get("data") or {} + is_fresh = age is not None and age <= max_age_seconds + is_active = bool(is_fresh and data.get("active")) + return { + "fresh": bool(is_fresh), + "active": bool(is_active), + "age_seconds": age, + "timestamp": event.get("timestamp"), + "data": data, + } + + +def bucket_health( + api_base: str, + bucket_id: str, + max_age_seconds: int, + missing_status: str, + stale_status: str, +) -> tuple[str, str, dict[str, Any]]: + try: + event = latest_bucket_event(api_base, bucket_id) + except Exception as exc: + return "fail", f"bucket query failed: {exc}", {"bucket": bucket_id} + + if not event: + return missing_status, "no events", {"bucket": bucket_id} + + ts = parse_ts(event.get("timestamp")) + age = age_seconds(ts, now_utc()) + details = {"bucket": bucket_id, "timestamp": event.get("timestamp"), "age_seconds": age} + if age is None: + return "warn", "timestamp parse failed", details + if age > max_age_seconds: + return stale_status, f"stale ({age}s)", details + return "ok", f"fresh ({age}s)", details + + +def latest_validation_report(validation_dir: Path) -> Path | None: + candidates = sorted( + (path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + return candidates[0] if candidates else None + + +def write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: + handle.write(content) + tmp_name = handle.name + os.replace(tmp_name, path) + + +def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = False) -> None: + if not Path(cmd[0]).exists(): + report.add(name, "warn", "binary missing", command=cmd) + return + rc, output = run_command(cmd) + details: dict[str, Any] = {"command": cmd, "returncode": rc} + if json_mode: + try: + details["payload"] = json.loads(output) if output else {} + except json.JSONDecodeError: + details["raw_output"] = output + report.add(name, "fail", "invalid JSON output", **details) + return + else: + details["output"] = output + report.add(name, "ok" if rc == 0 else "fail", "passed" if rc == 0 else "failed", **details) + + +def main() -> int: + load_env_file(ENV_FILE) + + parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator") + parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600")) + parser.add_argument("--worktime-api", default=env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")) + parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18")) + parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025")) + parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) + parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation")) + parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) + parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) + parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "604800"))) + parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) + parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + report = Report() + aw_api_base = args.aw_server.rstrip("/") + if not aw_api_base.endswith("/api/0"): + aw_api_base = aw_api_base.rstrip("/") + "/api/0" + + check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"]) + check_wrapper(report, "wrapper:dlp-health-check", ["/usr/local/bin/dlp-health-check", "--json"], json_mode=True) + + try: + info = http_json(f"{aw_api_base}/info") + report.add("http:aw-server", "ok", "activitywatch API responded", version=info.get("version")) + except Exception as exc: + report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info") + + try: + payload = http_json(args.worktime_api.rstrip("/") + "/reports/worktime/today") + rows = len(payload) if isinstance(payload, list) else None + report.add("http:worktime-api", "ok", "worktime API responded", rows=rows) + except Exception as exc: + report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api) + + for port, label in ((5985, "winrm"), (3389, "rdp")): + ok, message = tcp_connect(args.rdp_host, port, args.tcp_timeout_seconds) + report.add(f"tcp:{label}", "ok" if ok else "fail", message if ok else f"unreachable: {message}", host=args.rdp_host, port=port) + + try: + buckets = http_json(f"{aw_api_base}/buckets") + if not isinstance(buckets, dict): + raise RuntimeError("bucket index is not a dict") + report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) + except Exception as exc: + report.add("aw:buckets-index", "fail", f"failed to load bucket index: {exc}") + buckets = {} + + host = args.rdp_hostname + worktime_bucket = f"aw-worktime-sessions_{host}" + worktime_event = None + if buckets: + try: + worktime_event = latest_bucket_event(aw_api_base, worktime_bucket) + except Exception: + worktime_event = None + activity = host_activity_from_worktime(worktime_event, args.session_max_age_seconds) + if worktime_event: + status, summary, details = bucket_health( + aw_api_base, + worktime_bucket, + args.session_max_age_seconds, + missing_status="fail", + stale_status="fail", + ) + details["host_activity"] = activity + report.add("bucket:worktime-sessions", status, summary, **details) + else: + report.add("bucket:worktime-sessions", "fail", "no events", bucket=worktime_bucket, host_activity=activity) + + interactive_required = bool(activity["active"]) + for bucket_name, label in ( + ("aw-watcher-afk", "bucket:afk"), + ("aw-watcher-window", "bucket:window"), + ("aw-dlp-endpoint-signals", "bucket:endpoint-signals"), + ): + status, summary, details = bucket_health( + aw_api_base, + f"{bucket_name}_{host}", + args.interactive_max_age_seconds, + missing_status="fail" if interactive_required else "warn", + stale_status="fail" if interactive_required else "warn", + ) + details["interactive_required"] = interactive_required + details["host_activity"] = activity + report.add(label, status, summary, **details) + + session_status, session_summary, session_details = bucket_health( + aw_api_base, + f"aw-session-events_{host}", + args.session_events_max_age_seconds, + missing_status="fail", + stale_status="warn", + ) + report.add("bucket:session-events", session_status, session_summary, **session_details) + + validation_dir = Path(args.validation_dir) + validation_report = latest_validation_report(validation_dir) + if validation_report is None: + report.add("validation:windows", "warn", "no validation report snapshot", directory=str(validation_dir)) + else: + try: + payload = json.loads(validation_report.read_text(encoding="utf-8-sig")) + age = age_seconds(datetime.fromtimestamp(validation_report.stat().st_mtime, tz=UTC), now_utc()) + if age is not None and age > args.validation_max_age_seconds: + report.add( + "validation:windows", + "warn", + f"validation snapshot is stale ({age}s)", + path=str(validation_report), + overall_ok=payload.get("overallOk"), + failed_sections=payload.get("summary", {}).get("failedSections", []), + ) + elif payload.get("overallOk") is True: + report.add("validation:windows", "ok", "validation snapshot OK", path=str(validation_report), age_seconds=age) + else: + report.add( + "validation:windows", + "fail", + "validation snapshot reports failure", + path=str(validation_report), + age_seconds=age, + failed_sections=payload.get("summary", {}).get("failedSections", []), + ) + except Exception as exc: + report.add("validation:windows", "fail", f"invalid validation snapshot: {exc}", path=str(validation_report)) + + payload = report.as_dict() + state_dir = Path(args.state_dir) + write_atomic(state_dir / "aw-rus-health.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + write_atomic(state_dir / "aw-rus-health.txt", report.render_text() + "\n") + + if args.json: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(report.render_text()) + return 0 if payload["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service new file mode 100644 index 0000000..015a906 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service @@ -0,0 +1,14 @@ +[Unit] +Description=AW-RUS unified health orchestrator +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-rus-healthd.py +User=root +Group=root +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-rus-healthd diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer new file mode 100644 index 0000000..bc6f25b --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AW-RUS unified health orchestrator every 2 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=2min +Unit=aw-rus-healthd.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example index 03693fb..1d43206 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +++ b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example @@ -1,13 +1,46 @@ # Copy to /etc/activitywatch/aw-server.env and fill with real values. +# Core AW Server Configuration AW_SERVER_VERSION=0.13.2 AW_SERVER_DOWNLOAD_URL=https://github.com/ActivityWatch/aw-server-rust/releases/download/v0.13.2/aw-server-rust-linux-x86_64.zip AW_SERVER_BIND_HOST=0.0.0.0 AW_SERVER_PORT=5600 AW_SERVER_WEBUI_DIR=/opt/activitywatch/webui-ru AW_SERVER_DATA_DIR=/var/lib/activitywatch +AW_SERVER_DB_PATH=/var/lib/activitywatch/pebble.db AW_SERVER_LOG_DIR=/var/log/activitywatch AW_SERVER_USER=activitywatch AW_SERVER_GROUP=activitywatch + +# Worktime API Configuration AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610 AW_WORKTIME_TZ=Europe/Moscow +AW_SERVER_URL=http://127.0.0.1:5600 + +# DLP IOC Configuration +AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output + +# DLP Policy Engine Configuration +AW_DLP_POLICY_ENGINE_BIND_HOST=0.0.0.0 +AW_DLP_POLICY_ENGINE_PORT=5601 +AW_DLP_POLICY_ENGINE_DB_PATH=/var/lib/activitywatch/dlp-policy-engine.sqlite + +# Logging Configuration +AW_LOG_LEVEL=info +AW_LOG_TO_JOURNAL=true +AW_LOG_TO_FILE=true + +# Health Check Configuration +AW_HEALTH_CHECK_ENABLED=true +AW_HEALTH_CHECK_INTERVAL=60 +AW_EXPECT_START_OF_DAY=00:00 +AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window +AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ +AW_HEALTH_STRICT_FILEOPS=0 +AW_MONITORED_WINDOWS_HOST=192.168.100.18 +AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 +AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health +AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation + +# Integration Test Configuration +AW_INTEGRATION_TEST_ENABLED=false diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py index 6f6611d..a1ad20b 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py @@ -1,15 +1,36 @@ #!/usr/bin/env python3 -from http.server import BaseHTTPRequestHandler, HTTPServer import csv +import html import io +import importlib.util import json import os +import sys import urllib.request from datetime import datetime, timezone, timedelta +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlencode, urlparse from zoneinfo import ZoneInfo -AW = "http://127.0.0.1:5600/api/0" + +def build_aw_api_base(raw_url): + url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/") + if url.endswith("/api/0"): + return url + return url + "/api/0" + + +AW_SERVER_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600") +AW = build_aw_api_base(AW_SERVER_URL) REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow")) +IOC_DIR = os.environ.get("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output") +DEFAULT_HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025").strip() or "SHARKON2025" +DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", "30"))) +MAX_SAMPLE_SECONDS = max(DEFAULT_SAMPLE_SECONDS, float(os.environ.get("AW_WORKTIME_MAX_SAMPLE_SECONDS", "300"))) +LISTEN_HOST = os.environ.get("AW_WORKTIME_LISTEN_HOST", "0.0.0.0") +LISTEN_PORT = int(os.environ.get("AW_WORKTIME_PORT", "5610")) +MODULE_PATH = Path(__file__).resolve() def get(u): @@ -17,80 +38,404 @@ def get(u): return json.loads(r.read().decode()) +def log_warning(message): + print(f"[aw-worktime-api] {message}", file=sys.stderr, flush=True) + + def pts(s): return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc) -def report_today(): +def to_iso_utc(dt): + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def hhmm(total_seconds): + total_seconds = max(0, int(total_seconds)) + return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) + + +def safe_slug(value): + text = str(value or "").strip().lower() + slug = [] + for char in text: + if char.isalnum(): + slug.append(char) + else: + slug.append("-") + normalized = "".join(slug).strip("-") + while "--" in normalized: + normalized = normalized.replace("--", "-") + return normalized or "user" + + +def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): + try: + seconds = float(value) + except Exception: + seconds = float(fallback) + if seconds <= 0: + seconds = float(fallback) + return min(seconds, MAX_SAMPLE_SECONDS) + + +def resolve_host(request_host=None): + host = (request_host or DEFAULT_HOST).strip() + if not host: + host = DEFAULT_HOST + return host + + +def get_sessions_bucket_id(host): + return f"aw-worktime-sessions_{resolve_host(host)}" + + +def resolve_report_date(day=None, date_text=None): now_local = datetime.now(REPORT_TZ) - start_local = datetime(now_local.year, now_local.month, now_local.day, tzinfo=REPORT_TZ) + if date_text: + return datetime.strptime(date_text, "%Y-%m-%d").date() + if day == "yesterday": + return (now_local - timedelta(days=1)).date() + return now_local.date() + + +def get_report_bounds(report_date): + start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ) end_local = start_local + timedelta(days=1) - timedelta(seconds=1) start = start_local.astimezone(timezone.utc) end = end_local.astimezone(timezone.utc) - b = get(AW + "/buckets") - sb = next((k for k in b if k.startswith("aw-worktime-sessions_")), None) - if not sb: + end_exclusive = end + timedelta(seconds=1) + return { + "start_local": start_local, + "end_local": end_local, + "start": start, + "end": end, + "end_exclusive": end_exclusive, + } + + +def _is_machine_user(user: str): + u = (user or "").strip().lower() + return u.endswith("$") or u in {"system", "localservice", "networkservice"} + + +def _is_active_sample(data: dict): + state = str(data.get("state") or "").strip().lower() + if isinstance(data.get("active"), bool) and data.get("active"): + return True + if ("актив" in state) or (state == "active"): + return True + if state == "unknown": + try: + sid = int(data.get("sessionId")) + except Exception: + sid = -1 + user = str(data.get("username") or "").strip() + session_name = str(data.get("sessionName") or "").strip().lower() + if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"): + return True + return False + + +def _normalize_user_id(data, host, username): + user_id = str(data.get("userId") or "").strip() + if user_id: + left, sep, right = user_id.partition("\\") + if sep and right: + return f"{resolve_host(host)}\\{right}" + return user_id + return f"{resolve_host(host)}\\{username}" + + +def _event_sample_seconds(event, next_same_session_ts=None): + data = event.get("data") or {} + for key in ("sampleSeconds", "pollSeconds"): + value = data.get(key) + try: + if float(value) > 0: + return clamp_seconds(value) + except Exception: + pass + try: + duration = float(event.get("duration") or 0.0) + except Exception: + duration = 0.0 + if duration > 0: + return clamp_seconds(duration) + if next_same_session_ts is not None: + delta = (next_same_session_ts - event["_ts"]).total_seconds() + if delta > 0: + return clamp_seconds(delta) + return clamp_seconds(DEFAULT_SAMPLE_SECONDS) + + +def _merge_intervals(intervals): + if not intervals: return [] - ev = get(f"{AW}/buckets/{sb}/events?limit=50000") - by = {} - for e in ev: - ts = pts(e.get("timestamp")) + ordered = sorted(intervals, key=lambda item: item[0]) + merged = [ordered[0]] + for start, end in ordered[1:]: + last_start, last_end = merged[-1] + if start <= last_end: + if end > last_end: + merged[-1] = (last_start, end) + continue + merged.append((start, end)) + return merged + + +def _collect_user_rows(events, start, end, host): + end_exclusive = end + timedelta(seconds=1) + by_user = {} + by_identity = {} + + for event in events: + ts = pts(event.get("timestamp")) if ts < start or ts > end: continue - d = e.get("data") or {} - user = (d.get("username") or "").strip() - if not user: + data = event.get("data") or {} + username = str(data.get("username") or "").strip() + if not username: continue - state = (d.get("state") or "").lower() - active = ("актив" in state) or (state == "active") - row = by.setdefault(user, {"active": set(), "first": None, "last": None, "rows": 0}) - row["rows"] += 1 - if active: - second = ts.replace(microsecond=0) - row["active"].add(second) - row["first"] = second if row["first"] is None or second < row["first"] else row["first"] - row["last"] = second if row["last"] is None or second > row["last"] else row["last"] + session_id = str(data.get("sessionId") or "").strip() or "unknown" + event_copy = { + "_ts": ts, + "data": data, + "duration": event.get("duration"), + } + by_identity.setdefault((username, session_id), []).append(event_copy) + + for (username, session_id), samples in by_identity.items(): + ordered = sorted(samples, key=lambda item: item["_ts"]) + for idx, sample in enumerate(ordered): + data = sample["data"] + active = _is_active_sample(data) + next_ts = ordered[idx + 1]["_ts"] if idx + 1 < len(ordered) else None + sample_seconds = _event_sample_seconds(sample, next_ts) + row = by_user.setdefault( + username, + { + "user": username, + "user_id": _normalize_user_id(data, host, username), + "samples_count": 0, + "active_samples": 0, + "session_ids": set(), + "intervals": [], + }, + ) + row["samples_count"] += 1 + row["session_ids"].add(session_id) + if active: + row["active_samples"] += 1 + interval_start = sample["_ts"] + interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end_exclusive) + if interval_end > interval_start: + row["intervals"].append((interval_start, interval_end)) + return by_user + + +def aggregate_rows(events, start, end, host): + by_user = _collect_user_rows(events, start, end, host) rows = [] - full = int((end_local - start_local).total_seconds()) - for user in sorted(by): - row = by[user] - active_seconds = len(row["active"]) - rows.append({ - "user": user, - "active_seconds": active_seconds, - "active_hhmm": "%02d:%02d" % (active_seconds // 3600, (active_seconds % 3600) // 60), - "first_activity": row["first"].isoformat().replace("+00:00", "Z") if row["first"] else "", - "last_activity": row["last"].isoformat().replace("+00:00", "Z") if row["last"] else "", - "idle_seconds": max(0, full - active_seconds), - "sessions_count": row["rows"], - }) + full_range = int((end - start).total_seconds()) + 1 + for username in sorted(by_user): + row = by_user[username] + merged = _merge_intervals(row["intervals"]) + active_seconds = int(sum((end_dt - start_dt).total_seconds() for start_dt, end_dt in merged)) + active_seconds = min(active_seconds, full_range) + first_activity = to_iso_utc(merged[0][0]) if merged else "" + last_activity = to_iso_utc(merged[-1][1]) if merged else "" + rows.append( + { + "user": row["user"], + "user_id": row["user_id"], + "active_seconds": active_seconds, + "active_hhmm": hhmm(active_seconds), + "first_activity": first_activity, + "last_activity": last_activity, + "idle_seconds": max(0, full_range - active_seconds), + "sessions_count": len(row["session_ids"]), + "samples_count": row["samples_count"], + "active_samples": row["active_samples"], + } + ) return rows -def render_html(rows): +def aggregate_hourly_rows(events, start, end, host): + by_user = _collect_user_rows(events, start, end, host) + rows = [] + for username in sorted(by_user): + row = by_user[username] + merged = _merge_intervals(row["intervals"]) + per_bucket = {} + for interval_start, interval_end in merged: + cursor = interval_start + while cursor < interval_end: + bucket_local = cursor.astimezone(REPORT_TZ).replace(minute=0, second=0, microsecond=0) + bucket_start = bucket_local.astimezone(timezone.utc) + bucket_end = (bucket_local + timedelta(hours=1)).astimezone(timezone.utc) + overlap_start = max(interval_start, bucket_start) + overlap_end = min(interval_end, bucket_end) + if overlap_end > overlap_start: + key = bucket_start + per_bucket[key] = per_bucket.get(key, 0) + int((overlap_end - overlap_start).total_seconds()) + cursor = bucket_end + + for bucket_start in sorted(per_bucket): + active_seconds = per_bucket[bucket_start] + if active_seconds <= 0: + continue + bucket_local = bucket_start.astimezone(REPORT_TZ) + rows.append( + { + "user": row["user"], + "user_id": row["user_id"], + "bucket_start_utc": to_iso_utc(bucket_start), + "bucket_start_local": bucket_local.isoformat(), + "report_date": bucket_local.date().isoformat(), + "hour_local": bucket_local.strftime("%H:00"), + "active_seconds": active_seconds, + "active_hhmm": hhmm(active_seconds), + } + ) + return rows + + +def fetch_events_for_date(host, report_date): + bounds = get_report_bounds(report_date) + bucket_id = get_sessions_bucket_id(host) + try: + get(f"{AW}/buckets/{bucket_id}") + except Exception: + log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") + return bounds, [] + try: + events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000") + except Exception: + log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") + return bounds, [] + return bounds, events + + +def build_report_summary(rows): + if not rows: + return { + "users_count": 0, + "total_active_seconds": 0, + "total_active_hhmm": "00:00", + "first_activity": "", + "last_activity": "", + "top_user": "", + "top_user_active_hhmm": "00:00", + } + + total_active_seconds = sum(int(row.get("active_seconds", 0) or 0) for row in rows) + first_values = [row.get("first_activity") for row in rows if row.get("first_activity")] + last_values = [row.get("last_activity") for row in rows if row.get("last_activity")] + top_row = max(rows, key=lambda row: int(row.get("active_seconds", 0) or 0)) + return { + "users_count": len(rows), + "total_active_seconds": total_active_seconds, + "total_active_hhmm": hhmm(total_active_seconds), + "first_activity": min(first_values) if first_values else "", + "last_activity": max(last_values) if last_values else "", + "top_user": top_row.get("user", ""), + "top_user_active_hhmm": top_row.get("active_hhmm", "00:00"), + } + + +def report_for_date(host, report_date): + bounds, events = fetch_events_for_date(host, report_date) + return aggregate_rows(events, bounds["start"], bounds["end"], host) + + +def report_today(host): + return report_for_date(host, resolve_report_date()) + + +def report_for_date_fresh(host, report_date): + spec = importlib.util.spec_from_file_location("aw_worktime_runtime", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.report_for_date(host, report_date) + + +def render_html(rows, host, report_date, selected_day=None): generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - date_local = datetime.now(REPORT_TZ).strftime("%Y-%m-%d") + date_local = report_date.strftime("%Y-%m-%d") + day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" + date_query = f"&date={date_local}" if not day_query else "" + summary = build_report_summary(rows) + today_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "today"}) + yesterday_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "yesterday"}) + csv_url = "/reports/worktime/today?" + urlencode({"format": "csv", "host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) + json_url = "/reports/worktime/today?" + urlencode({"host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) + form_action = "/reports/worktime/today" + cards = [ + ("Пользователи", str(summary["users_count"])), + ("Активное время", summary["total_active_hhmm"]), + ("Лидер дня", f"{summary['top_user']} · {summary['top_user_active_hhmm']}" if summary["top_user"] else "н/д"), + ("Диапазон", f"{summary['first_activity']} -> {summary['last_activity']}" if summary["first_activity"] else "нет активности"), + ] trs = [] + detail_cards = [] for row in rows: + user_slug = safe_slug(row["user"]) + active_seconds = int(row.get("active_seconds", 0) or 0) + utilization = 0.0 + day_total = 24 * 3600 + if day_total > 0: + utilization = round((active_seconds / day_total) * 100.0, 2) trs.append( "" - f"{row['user']}" - f"{row['active_hhmm']}" + f"{html.escape(row['user'])}" + f"{html.escape(row['user_id'])}" + f"{row['active_hhmm']}" f"{row['active_seconds']}" - f"{row['first_activity']}" - f"{row['last_activity']}" + f"{html.escape(row['first_activity'])}" + f"{html.escape(row['last_activity'])}" f"{row['idle_seconds']}" f"{row['sessions_count']}" + f"{row['samples_count']}" "" ) + detail_cards.append( + "
" + "
" + "

{user}

" + "{active}" + "
" + "
" + "
Пользователь{user_id}
" + "
Загрузка{utilization}%
" + "
Начало активности{first_activity}
" + "
Конец активности{last_activity}
" + "
Сессии{sessions}
" + "
Активные сэмплы{active_samples} / {samples}
" + "
" + "
" + .format( + slug=user_slug, + user=html.escape(row["user"]), + active=html.escape(row["active_hhmm"]), + user_id=html.escape(row["user_id"]), + utilization=utilization, + first_activity=html.escape(row["first_activity"] or "н/д"), + last_activity=html.escape(row["last_activity"] or "н/д"), + sessions=row["sessions_count"], + active_samples=row["active_samples"], + samples=row["samples_count"], + )) if not trs: - trs.append('No data for today yet.') + trs.append('За выбранную дату данных пока нет.') + detail_cards.append("

За выбранную дату нет активности пользователей.

") return f""" - + - AW-rus Worktime + AW-rus Отчёт по работе в RDP
-

RDP Worktime Report

-
Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}
+

Отчёт по работе в RDP

+
Хост: {resolve_host(host)} · Дата: {date_local} · Часовой пояс: {REPORT_TZ} · Сформировано UTC: {generated}
+
+
+ + + + +
+
+
+ {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)}
+

Таблица по пользователям

- - - - - - - + + + + + + + + + @@ -182,6 +656,12 @@ def render_html(rows):
UserActiveActive secFirst activityLast activityIdle secSamplesПользовательУчётная записьАктивноАктивно, секНачало активностиКонец активностиПростой, секСессииСэмплы
+
+

Детали по пользователям

+
+ {''.join(detail_cards)} +
+
""" @@ -189,28 +669,65 @@ def render_html(rows): class H(BaseHTTPRequestHandler): def do_GET(self): - if not self.path.startswith("/reports/worktime/today"): + parsed = urlparse(self.path) + if parsed.path.startswith("/dlp-ioc/"): + name = parsed.path.rsplit("/", 1)[-1] + if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}: + self.send_response(404) + self.end_headers() + return + path = os.path.join(IOC_DIR, name) + if not os.path.isfile(path): + self.send_response(404) + self.end_headers() + return + with open(path, "rb") as f: + data = f.read() + if name.endswith(".json"): + ctype = "application/json; charset=utf-8" + elif name.endswith(".csv"): + ctype = "text/csv; charset=utf-8" + else: + ctype = "text/plain; charset=utf-8" + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + + if parsed.path != "/reports/worktime/today": self.send_response(404) self.end_headers() return + + params = parse_qs(parsed.query, keep_blank_values=False) fmt = "json" - if "format=csv" in self.path: + if params.get("format", ["json"])[0] == "csv": fmt = "csv" - elif "format=html" in self.path: + elif params.get("format", ["json"])[0] == "html": fmt = "html" - rows = report_today() + host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) + day = params.get("day", ["today"])[0] + date_text = params.get("date", [None])[0] + report_date = resolve_report_date(day=day, date_text=date_text) + rows = report_for_date_fresh(host, report_date) + if fmt == "csv": out = io.StringIO() writer = csv.DictWriter( out, fieldnames=[ "user", + "user_id", "active_seconds", "active_hhmm", "first_activity", "last_activity", "idle_seconds", "sessions_count", + "samples_count", + "active_samples", ], ) writer.writeheader() @@ -222,17 +739,22 @@ class H(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) return + if fmt == "html": - data = render_html(rows).encode("utf-8") + data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return + obj = { "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "report_timezone": str(REPORT_TZ), + "host": host, + "report_date": report_date.isoformat(), + "bucket_id": get_sessions_bucket_id(host), "rows": rows, } data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") @@ -243,4 +765,9 @@ class H(BaseHTTPRequestHandler): self.wfile.write(data) -HTTPServer(("0.0.0.0", 5610), H).serve_forever() +def main(): + HTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service index 4225fb5..e85d3bf 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service @@ -2,15 +2,20 @@ Description=AW Worktime Report API After=network.target activitywatch-server.service Wants=activitywatch-server.service +StartLimitBurst=3 +StartLimitIntervalSec=60 [Service] Type=simple EnvironmentFile=/etc/activitywatch/aw-server.env ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py -Restart=always -RestartSec=2 +Restart=on-failure +RestartSec=5 User=activitywatch Group=activitywatch +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-worktime-api [Install] WantedBy=multi-user.target diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js index c38b29e..a91741d 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js @@ -1,15 +1,23 @@ (function () { var reportBase = "__AW_WORKTIME_REPORT_BASE__"; - var reportUrl = reportBase + "/reports/worktime/today?format=html"; + function defaultDayQuery() { + var now = new Date(); + return now.getHours() < 6 ? "day=yesterday" : "day=today"; + } + + var dayQuery = defaultDayQuery(); + var htmlUrl = reportBase + "/reports/worktime/today?format=html&" + dayQuery; + var csvUrl = reportBase + "/reports/worktime/today?format=csv&" + dayQuery; + var jsonUrl = reportBase + "/reports/worktime/today?" + dayQuery; var existing = document.getElementById("aw-report-links"); if (!existing) return; existing.innerHTML = - 'RDP report: ' + - 'HTML | ' + - 'CSV | ' + - 'JSON | ' + - 'Panel'; + 'RDP отчёт: ' + + 'HTML | ' + + 'CSV | ' + + 'JSON | ' + + 'Панель'; var panel = document.createElement("div"); panel.id = "aw-report-panel"; @@ -30,12 +38,12 @@ panel.innerHTML = '
' + - '
RDP Worktime Report
' + + '
Отчёт по работе в RDP
' + '
' + - 'Open' + - 'Close' + + 'Открыть' + + 'Закрыть' + "
" + - ''; + ''; document.body.appendChild(panel); diff --git a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh index 50ba3e7..6a28f35 100755 --- a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +++ b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh @@ -26,6 +26,12 @@ VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json" CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json" WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py" WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service" +WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" +WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service" +WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer" +HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py" +HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service" +HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer" for var_name in "${required_vars[@]}"; do if [[ -z "${!var_name:-}" ]]; then @@ -51,6 +57,7 @@ install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/release install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR" install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR" install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR" +install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR/health/windows-validation" tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT @@ -103,6 +110,41 @@ if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then systemctl --no-pager --full status aw-worktime-api.service || true fi +if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then + install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py +fi + +if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then + install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service +fi + +if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then + install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer + systemctl daemon-reload + systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true + systemctl enable aw-worktime-ui-bridge.timer + systemctl restart aw-worktime-ui-bridge.timer + systemctl start aw-worktime-ui-bridge.service || true + systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true +fi + +if [[ -f "$HEALTHD_SRC" ]]; then + install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py +fi + +if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then + install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service +fi + +if [[ -f "$HEALTHD_TIMER_SRC" ]]; then + install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer + systemctl daemon-reload + systemctl enable aw-rus-healthd.timer + systemctl restart aw-rus-healthd.timer + systemctl start aw-rus-healthd.service || true + systemctl --no-pager --full status aw-rus-healthd.timer || true +fi + for _ in $(seq 1 20); do if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then break diff --git a/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json b/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json index b6361f4..11ee810 100644 --- a/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json +++ b/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json @@ -20,7 +20,7 @@ "name": ["Работа", "Документы"], "rule": { "type": "regex", - "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat", + "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat", "ignore_case": true }, "data": { "color": "#2E7D32" } @@ -40,7 +40,7 @@ "name": ["Работа", "Администрирование"], "rule": { "type": "regex", - "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b", + "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd|gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\.exe\\b|\\b(gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\b|Proxmox Virtual Environment|\\bpfSense\\b|\\bGrafana\\b|\\bKibana\\b|\\bPortainer\\b", "ignore_case": true }, "data": { "color": "#6D4C41" } @@ -56,7 +56,7 @@ "name": ["Интернет", "Браузер"], "rule": { "type": "regex", - "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b", + "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b", "ignore_case": true }, "data": { "color": "#00897B" } @@ -82,7 +82,7 @@ "name": ["ActivityWatch"], "rule": { "type": "regex", - "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b", + "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b", "ignore_case": true }, "data": {} diff --git a/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json b/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json index 439fce6..5421e98 100644 --- a/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json +++ b/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json @@ -30,10 +30,5 @@ { "type": "category_tree", "size": 3, "props": {} }, { "type": "top_apps", "size": 3, "props": {} } ] - }, - { - "id": "pve_audit", - "name": "PVE Audit", - "elements": [] } ] diff --git a/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-admin.deployment-config.json b/install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-admin.deployment-config.json similarity index 100% rename from install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-admin.deployment-config.json rename to install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-admin.deployment-config.json diff --git a/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-u2u5.deployment-config.json b/install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-u2u5.deployment-config.json similarity index 100% rename from install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-u2u5.deployment-config.json rename to install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-u2u5.deployment-config.json diff --git a/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-user1.deployment-config.json b/install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-user1.deployment-config.json similarity index 100% rename from install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-user1.deployment-config.json rename to install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-user1.deployment-config.json diff --git a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 index e3a30b5..2f7f461 100755 --- a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +++ b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 @@ -1,4 +1,4 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Assert-Administrator { @@ -83,6 +83,41 @@ function Get-ActivityWatchPackageRoot { return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) } +function Expand-ActivityWatchArchiveSafe { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath, + [int]$Attempts = 3 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Recurse -Force -ErrorAction SilentlyContinue + } + New-ActivityWatchDirectory -Path $DestinationPath + Expand-Archive -Path $ArchivePath -DestinationPath $DestinationPath -Force -ErrorAction Stop + return + } + catch { + if ($attempt -lt $Attempts) { + Start-Sleep -Milliseconds (500 * $attempt) + continue + } + } + } + + # Fallback for intermittent Expand-Archive issues in Windows PowerShell. + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Recurse -Force -ErrorAction SilentlyContinue + } + New-ActivityWatchDirectory -Path $DestinationPath + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $DestinationPath) +} + function Install-ActivityWatchPackage { param( [Parameter(Mandatory = $true)] @@ -98,6 +133,13 @@ function Install-ActivityWatchPackage { New-ActivityWatchDirectory -Path $WorkingRoot New-ActivityWatchDirectory -Path $BackupRoot + # Cleanup stale extraction directories from previous failed deployments. + Get-ChildItem -LiteralPath $WorkingRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'extract-*' } | + ForEach-Object { + try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } catch {} + } + # Ensure nothing is holding locks inside InstallRoot during upgrade. foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { try { @@ -114,7 +156,17 @@ function Install-ActivityWatchPackage { } New-ActivityWatchDirectory -Path $extractRoot - Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $archiveSize = (Get-Item -LiteralPath $ArchivePath -ErrorAction Stop).Length + $workDrive = (Get-PSDrive -Name ([System.IO.Path]::GetPathRoot($WorkingRoot).TrimEnd('\').TrimEnd(':')) -ErrorAction SilentlyContinue) + if ($workDrive) { + # Require at least ~2.5x archive size to handle extraction + copy safely. + $required = [int64]([Math]::Ceiling($archiveSize * 2.5)) + if ([int64]$workDrive.Free -lt $required) { + throw ("Недостаточно свободного места на {0}: free={1} bytes, required>={2} bytes" -f $workDrive.Name, $workDrive.Free, $required) + } + } + + Expand-ActivityWatchArchiveSafe -ArchivePath $ArchivePath -DestinationPath $extractRoot $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot if (Test-Path -LiteralPath $InstallRoot) { @@ -181,7 +233,7 @@ function Normalize-ActivityWatchUsers { $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop $extension = [IO.Path]::GetExtension($resolved.Path) if ($extension -ieq '.csv') { - $rows = Import-Csv -LiteralPath $resolved.Path + $rows = Import-Csv -LiteralPath $resolved.Path -Encoding UTF8 foreach ($row in $rows) { foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { if ($row.PSObject.Properties.Name -contains $column) { @@ -195,7 +247,7 @@ function Normalize-ActivityWatchUsers { } } else { - Get-Content -LiteralPath $resolved.Path | ForEach-Object { + Get-Content -LiteralPath $resolved.Path -Encoding UTF8 | ForEach-Object { $line = $_.Trim() if ($line -and -not $line.StartsWith('#')) { $collected.Add($line) @@ -259,16 +311,95 @@ 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)] [string]$CollectorScriptSource, [Parameter(Mandatory = $true)] [string]$EndpointCollectorScriptSource, + [string]$PolicyClientScriptSource, [Parameter(Mandatory = $true)] [string]$FileCollectorScriptSource, [Parameter(Mandatory = $true)] [string]$SessionCollectorScriptSource, + [string]$EvtxExportScriptSource, [string]$EmailCollectorScriptSource, [Parameter(Mandatory = $true)] [string]$ExampleRulesSource, @@ -284,8 +415,10 @@ function Copy-ActivityWatchCollectorAssets { $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $policyClientTarget = Join-Path $StateRoot 'dlp-policy-client.ps1' $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $evtxExportTarget = Join-Path $StateRoot 'export-evtx-for-hayabusa.ps1' $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' @@ -294,8 +427,14 @@ function Copy-ActivityWatchCollectorAssets { Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + if ($PolicyClientScriptSource -and (Test-Path -LiteralPath $PolicyClientScriptSource)) { + Copy-Item -LiteralPath $PolicyClientScriptSource -Destination $policyClientTarget -Force + } Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EvtxExportScriptSource -and (Test-Path -LiteralPath $EvtxExportScriptSource)) { + Copy-Item -LiteralPath $EvtxExportScriptSource -Destination $evtxExportTarget -Force + } if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force } @@ -306,6 +445,9 @@ function Copy-ActivityWatchCollectorAssets { $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } if ($CustomPolicySource) { $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop @@ -318,8 +460,10 @@ function Copy-ActivityWatchCollectorAssets { return [pscustomobject]@{ CollectorScript = $collectorTarget EndpointCollectorScript = $endpointCollectorTarget + PolicyClientScript = $policyClientTarget FileCollectorScript = $fileCollectorTarget SessionCollectorScript = $sessionCollectorTarget + EvtxExportScript = $evtxExportTarget EmailCollectorScript = $emailCollectorTarget ExampleRules = $exampleRulesTarget ActiveRules = $rulesTarget @@ -346,10 +490,12 @@ function New-ActivityWatchDeploymentConfig { [string]$CollectorScript, [Parameter(Mandatory = $true)] [string]$EndpointCollectorScript, + [string]$PolicyClientScript, [Parameter(Mandatory = $true)] [string]$FileCollectorScript, [Parameter(Mandatory = $true)] [string]$SessionCollectorScript, + [string]$EvtxExportScript, [string]$EmailCollectorScript, [Parameter(Mandatory = $true)] [string]$RulesPath, @@ -368,21 +514,51 @@ function New-ActivityWatchDeploymentConfig { [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, [Parameter(Mandatory = $true)] [string]$LaunchScriptPath, [Parameter(Mandatory = $true)] [string]$RecoveryScriptPath, + [string]$AwHostname, + [ValidateSet('local', 'server')] + [string]$PolicyMode = 'local', + [bool]$PolicyEngineEnabled = $false, + [string]$PolicyEngineHost, + [int]$PolicyEnginePort = 5601, + [ValidateSet('http', 'https')] + [string]$PolicyEngineScheme = 'http', + [int]$PolicyRefreshSeconds = 300, + [string]$PolicyCachePath, [Parameter(Mandatory = $true)] [pscustomobject[]]$UserTasks, - [string]$PackageVersion = 'v0.13.2' + [string]$PackageVersion = 'v0.13.2', + [switch]$IntegrationTestEnabled ) $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + $effectiveEvtxExportRoot = if ($EvtxExportRoot) { $EvtxExportRoot } else { Join-Path $StateRoot 'forensics\evtx-exports' } + $effectiveEvtxChannels = if ($EvtxChannels -and $EvtxChannels.Count -gt 0) { + @($EvtxChannels) + } else { + @( + 'Security', + 'System', + 'Application', + 'Microsoft-Windows-PowerShell/Operational', + 'Microsoft-Windows-TerminalServices-LocalSessionManager/Operational', + 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational' + ) + } + $effectivePolicyEngineHost = if ([string]::IsNullOrWhiteSpace($PolicyEngineHost)) { $ServerHost } else { $PolicyEngineHost } + $effectivePolicyCachePath = if ([string]::IsNullOrWhiteSpace($PolicyCachePath)) { Join-Path $StateRoot 'dlp-policy-cache.json' } else { $PolicyCachePath } return [pscustomobject]@{ version = 1 generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + awHostname = if ([string]::IsNullOrWhiteSpace($AwHostname)) { [string]$env:COMPUTERNAME } else { [string]$AwHostname } server = [pscustomobject]@{ host = $ServerHost port = $ServerPort @@ -394,9 +570,11 @@ function New-ActivityWatchDeploymentConfig { logsRoot = $LogsRoot collectorScript = $CollectorScript endpointCollectorScript = $EndpointCollectorScript + policyClientScript = $PolicyClientScript emailCollectorScript = $EmailCollectorScript fileCollectorScript = $FileCollectorScript sessionCollectorScript = $SessionCollectorScript + evtxExportScript = $EvtxExportScript rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -410,7 +588,7 @@ function New-ActivityWatchDeploymentConfig { afkEnabled = $AfkEnabled windowEnabled = $WindowEnabled fileOpsEnabled = $FileOpsEnabled - emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + emailEnabled = $false } logging = [pscustomobject]@{ localAgentLogsEnabled = $LocalAgentLogsEnabled @@ -420,6 +598,11 @@ function New-ActivityWatchDeploymentConfig { screenshotEnabled = $IncidentScreenshotEnabled artifactsRoot = $effectiveIncidentArtifactsRoot } + forensics = [pscustomobject]@{ + evtxExportRoot = $effectiveEvtxExportRoot + retentionDays = $EvtxRetentionDays + evtxChannels = @($effectiveEvtxChannels) + } sessionEvents = [pscustomobject]@{ logonEnabled = $LogonMarkerEnabled bucketPrefix = 'aw-session-events' @@ -432,10 +615,20 @@ function New-ActivityWatchDeploymentConfig { incidentBucketPrefix = 'aw-dlp-incidents' enabled = $true } + policyEngine = [pscustomobject]@{ + enabled = $PolicyEngineEnabled + mode = $PolicyMode + host = $effectivePolicyEngineHost + port = $PolicyEnginePort + scheme = $PolicyEngineScheme + refreshSeconds = $PolicyRefreshSeconds + cachePath = $effectivePolicyCachePath + } package = [pscustomobject]@{ version = $PackageVersion } userTasks = @($UserTasks) + integrationTestEnabled = [bool]$IntegrationTestEnabled } } @@ -534,11 +727,7 @@ function Get-CollectorPowerShellProcessCount { function New-LaunchLock { param([string]`$StateRoot, [int]`$SessionId) - if (-not (Test-Path -LiteralPath `$StateRoot)) { - New-Item -Path `$StateRoot -ItemType Directory -Force | Out-Null - } - - `$lockPath = Join-Path `$StateRoot ("launch-watchers-session-{0}.lock" -f `$SessionId) + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) if (Test-Path -LiteralPath `$lockPath) { try { `$lockData = Get-Content -LiteralPath `$lockPath -Raw | ConvertFrom-Json @@ -560,6 +749,33 @@ function New-LaunchLock { return `$lockPath } +function Get-SessionMarkerToken { + param([int]`$SessionId) + + try { + `$explorer = Get-Process -Name 'explorer' -ErrorAction SilentlyContinue | + Where-Object { `$_.SessionId -eq `$SessionId } | + Sort-Object StartTime | + Select-Object -First 1 + if (`$explorer -and `$explorer.StartTime) { + return `$explorer.StartTime.ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + } + } + catch { + } + + try { + `$currentProcess = Get-Process -Id `$PID -ErrorAction Stop + if (`$currentProcess.StartTime) { + return `$currentProcess.StartTime.ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + } + } + catch { + } + + return [string]`$SessionId +} + function Invoke-AwJsonPost { param( [Parameter(Mandatory = `$true)][string]`$Uri, @@ -675,7 +891,8 @@ function Send-LogonMarkerIfNeeded { return } - `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + `$sessionMarkerToken = Get-SessionMarkerToken -SessionId `$SessionId + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}-{2}.marker" -f `$env:USERNAME, `$SessionId, `$sessionMarkerToken) if (Test-Path -LiteralPath `$markerFile) { return } @@ -731,13 +948,11 @@ function Start-CollectorScriptIfNeeded { return } - Start-Process -FilePath `$PowerShellExe -ArgumentList @( - '-NoProfile', - '-WindowStyle', 'Hidden', - '-ExecutionPolicy', 'Bypass', - '-File', `$ScriptPath, - '-ConfigPath', `$ConfigPath - ) -WindowStyle Hidden + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden } `$config = Get-DeploymentConfig -Path `$ConfigPath @@ -745,7 +960,7 @@ function Start-CollectorScriptIfNeeded { `$installRoot = [string]`$config.paths.installRoot `$stateRoot = [string]`$config.paths.stateRoot `$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port -`$script:Hostname = `$env:COMPUTERNAME +`$script:Hostname = if (`$config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]`$config.awHostname)) { [string]`$config.awHostname } else { `$env:COMPUTERNAME } `$script:KnownBuckets = @{} `$collectorScript = [string]`$config.paths.collectorScript `$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } @@ -792,7 +1007,6 @@ try { if (`$fileOpsEnabled) { Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } - Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } @@ -859,17 +1073,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 + } } } } @@ -877,7 +1095,7 @@ function Get-RecoveryTaskNames { } } - return @(`$taskNames) + return @(`$taskMap.Values) } function New-RecoveryLock { @@ -910,11 +1128,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) { @@ -929,6 +1159,120 @@ 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)) { + return `$false + } + + return [bool]@( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match [Regex]::Escape(`$ScriptPath) + } + ).Count +} + +function Start-CollectorScriptGlobalIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunningGlobal -ScriptPath `$ScriptPath) { + return + } + + `$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$powershellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + `$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath if (-not `$recoveryLockPath) { return @@ -939,11 +1283,15 @@ try { `$sleepSeconds = 180 try { `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath - foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { - Start-TaskIfNotRunning -TaskName `$taskName + `$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 (`$taskDef in Get-RecoveryTaskDefinitions -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskDef.taskName -UserId `$taskDef.userId -LoggedOnUsers `$loggedOnUsers } - `$config = Get-DeploymentConfig -Path `$ConfigPath if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) } @@ -1049,10 +1397,24 @@ function Set-ActivityWatchScheduledTaskAction { [string]$Arguments ) - $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) - & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return $false + } + + $newAction = New-ScheduledTaskAction -Execute $Execute -Argument $Arguments + try { + # Non-interactive update path. Avoids schtasks.exe /Change password prompt for user-bound tasks. + Set-ScheduledTask -TaskName $TaskName -Action $newAction -ErrorAction Stop | Out-Null + return $true + } + catch { + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Не удалось обновить action задачи ${TaskName}: $($_.Exception.Message)" + } + return $true } } @@ -1137,8 +1499,10 @@ function Register-ActivityWatchUserTasks { $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath if ($existingTask) { - Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments - continue + $updated = Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + if ($updated) { + continue + } } Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName @@ -1206,8 +1570,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/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 index d0c809f..b40b48c 100755 --- a/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 @@ -62,13 +62,14 @@ $resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Joi $resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } $resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } $resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } +$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME } if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null } $script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort -$script:Hostname = $env:COMPUTERNAME +$script:Hostname = $resolvedHostname $script:SessionId = (Get-Process -Id $PID).SessionId $script:KnownBuckets = @{} $script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled @@ -541,7 +542,7 @@ function Send-DlpIncidentHeartbeat { } + $captureData } | ConvertTo-Json -Depth 5 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null } function Get-FileSha256Hex { @@ -701,13 +702,26 @@ function Ensure-Bucket { return } + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + $body = @{ client = $ClientName type = $BucketType hostname = $script:Hostname } | ConvertTo-Json -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } $script:KnownBuckets[$BucketId] = $true } @@ -733,7 +747,7 @@ function Send-Heartbeat { } } | ConvertTo-Json -Depth 4 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null } function Send-CategoryHeartbeat { @@ -770,7 +784,7 @@ function Send-CategoryHeartbeat { } } | ConvertTo-Json -Depth 4 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null } Load-CustomCategoryRules -Path $resolvedRulesPath diff --git a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 index 92e5494..0f9cb1f 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 @@ -23,9 +23,23 @@ param( [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [string]$AwHostname, [string]$CustomRulesPath, - [string]$CustomPolicyPath + [string]$CustomPolicyPath, + [ValidateSet('local', 'server')] + [string]$PolicyMode = 'local', + [bool]$PolicyEngineEnabled = $false, + [string]$PolicyEngineHost, + [int]$PolicyEnginePort = 5601, + [ValidateSet('http', 'https')] + [string]$PolicyEngineScheme = 'http', + [int]$PolicyRefreshSeconds = 300, + [string]$PolicyCachePath, + [switch]$IntegrationTestEnabled ) Set-StrictMode -Version Latest @@ -45,9 +59,11 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1' $recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1' $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1' $endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' +$policyClientSource = Join-Path $PSScriptRoot 'dlp-policy-client.ps1' $emailCollectorSource = Join-Path $PSScriptRoot 'email-outbound-collector.ps1' $fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1' $sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' +$evtxExportScriptSource = Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -62,9 +78,11 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource $collectorSource ` -EndpointCollectorScriptSource $endpointCollectorSource ` + -PolicyClientScriptSource $policyClientSource ` -EmailCollectorScriptSource $emailCollectorSource ` -FileCollectorScriptSource $fileCollectorSource ` -SessionCollectorScriptSource $sessionCollectorSource ` + -EvtxExportScriptSource $evtxExportScriptSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -84,9 +102,11 @@ $config = New-ActivityWatchDeploymentConfig ` -LogsRoot $logsRoot ` -CollectorScript $assetResult.CollectorScript ` -EndpointCollectorScript $assetResult.EndpointCollectorScript ` + -PolicyClientScript $assetResult.PolicyClientScript ` -EmailCollectorScript $assetResult.EmailCollectorScript ` -FileCollectorScript $assetResult.FileCollectorScript ` -SessionCollectorScript $assetResult.SessionCollectorScript ` + -EvtxExportScript $assetResult.EvtxExportScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` @@ -99,11 +119,23 @@ $config = New-ActivityWatchDeploymentConfig ` -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -AwHostname $AwHostname ` + -PolicyMode $PolicyMode ` + -PolicyEngineEnabled $PolicyEngineEnabled ` + -PolicyEngineHost $PolicyEngineHost ` + -PolicyEnginePort $PolicyEnginePort ` + -PolicyEngineScheme $PolicyEngineScheme ` + -PolicyRefreshSeconds $PolicyRefreshSeconds ` + -PolicyCachePath $PolicyCachePath ` -LaunchScriptPath $launchScriptPath ` -RecoveryScriptPath $recoveryScriptPath ` -UserTasks $taskDefinitions ` - -PackageVersion $Version + -PackageVersion $Version ` + -IntegrationTestEnabled:$IntegrationTestEnabled Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath Remove-LegacyActivityWatchEntries diff --git a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 index fa6fd75..11d4366 100644 --- a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 @@ -23,12 +23,26 @@ param( [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, + [ValidateSet('local', 'server')] + [string]$PolicyMode = 'local', + [bool]$PolicyEngineEnabled = $false, + [string]$PolicyEngineHost, + [int]$PolicyEnginePort = 5601, + [ValidateSet('http', 'https')] + [string]$PolicyEngineScheme = 'http', + [int]$PolicyRefreshSeconds = 300, + [string]$PolicyCachePath, [string]$ReportPath, [switch]$SkipHardening, - [switch]$ValidateAfterDeploy + [switch]$ValidateAfterDeploy, + [switch]$IntegrationTestEnabled ) Set-StrictMode -Version Latest @@ -70,9 +84,21 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` - -CustomPolicyPath $CustomPolicyPath + -CustomPolicyPath $CustomPolicyPath ` + -PolicyMode $PolicyMode ` + -PolicyEngineEnabled $PolicyEngineEnabled ` + -PolicyEngineHost $PolicyEngineHost ` + -PolicyEnginePort $PolicyEnginePort ` + -PolicyEngineScheme $PolicyEngineScheme ` + -PolicyRefreshSeconds $PolicyRefreshSeconds ` + -PolicyCachePath $PolicyCachePath ` + -IntegrationTestEnabled:$IntegrationTestEnabled if (-not $SkipHardening) { & $hardeningScript ` @@ -93,9 +119,20 @@ if (-not $SkipHardening) { -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` - -CustomPolicyPath $CustomPolicyPath + -CustomPolicyPath $CustomPolicyPath ` + -PolicyMode $PolicyMode ` + -PolicyEngineEnabled $PolicyEngineEnabled ` + -PolicyEngineHost $PolicyEngineHost ` + -PolicyEnginePort $PolicyEnginePort ` + -PolicyEngineScheme $PolicyEngineScheme ` + -PolicyRefreshSeconds $PolicyRefreshSeconds ` + -PolicyCachePath $PolicyCachePath } $report = [ordered]@{ diff --git a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 index 160265d..c970c13 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 @@ -21,7 +21,11 @@ param( [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath ) @@ -44,6 +48,7 @@ $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1 $endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' $emailCollectorSource = Join-Path $PSScriptRoot 'email-outbound-collector.ps1' $sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' +$evtxExportScriptSource = Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -59,6 +64,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -EndpointCollectorScriptSource $endpointCollectorSource ` -EmailCollectorScriptSource $emailCollectorSource ` -SessionCollectorScriptSource $sessionCollectorSource ` + -EvtxExportScriptSource $evtxExportScriptSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -80,6 +86,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EndpointCollectorScript $assetResult.EndpointCollectorScript ` -EmailCollectorScript $assetResult.EmailCollectorScript ` -SessionCollectorScript $assetResult.SessionCollectorScript ` + -EvtxExportScript $assetResult.EvtxExportScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` @@ -91,7 +98,11 @@ $config = New-ActivityWatchDeploymentConfig ` -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -AwHostname $AwHostname ` -LaunchScriptPath $launchScriptPath ` -RecoveryScriptPath $recoveryScriptPath ` -UserTasks $taskDefinitions ` diff --git a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 index b286e48..9a754f0 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 @@ -1,11 +1,19 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, [int]$ServerPort, [ValidateSet('http', 'https')] [string]$ServerScheme, + [string]$PolicyEngineHost, + [int]$PolicyEnginePort, + [ValidateSet('http', 'https')] + [string]$PolicyEngineScheme, [string]$PolicyPath, + [ValidateSet('local', 'server')] + [string]$PolicyMode, + [int]$PolicyRefreshSeconds, + [string]$PolicyCachePath, [string]$LogPath, [int]$PollSeconds ) @@ -13,6 +21,36 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Ensure HttpClient is available (Windows PowerShell 5 may not auto-load it) +try { + Add-Type -AssemblyName System.Net.Http +} +catch { +} + +$script:TransportQueuePath = $null +$script:TransportQueueLockPath = $null +$script:TransportMetrics = @{ + eventsEnqueued = 0 + eventsFlushed = 0 + sendFailures = 0 + queueDepth = 0 +} + +$policyClientModulePath = Join-Path $PSScriptRoot 'dlp-policy-client.ps1' +if (Test-Path -LiteralPath $policyClientModulePath) { + try { + Import-Module $policyClientModulePath -Force -DisableNameChecking + $script:PolicyClientAvailable = $true + } + catch { + $script:PolicyClientAvailable = $false + } +} +else { + $script:PolicyClientAvailable = $false +} + function Get-DeploymentConfig { param([string]$Path) if ($Path -and (Test-Path -LiteralPath $Path)) { @@ -39,8 +77,146 @@ function Invoke-AwJsonPost { [Parameter(Mandatory = $true)][string]$Json ) - $bytes = [Text.Encoding]::UTF8.GetBytes($Json) - Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) + $req = [System.Net.HttpWebRequest]::Create($Uri) + $req.Method = 'POST' + $req.ContentType = 'application/json' + $req.Accept = 'application/json' + $req.KeepAlive = $false + $req.Timeout = 15000 + $req.ReadWriteTimeout = 15000 + $req.ContentLength = $bytes.Length + + $stream = $req.GetRequestStream() + try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() } + + $resp = $req.GetResponse() + try { + # read body for debugging, but discard on success + $rs = $resp.GetResponseStream() + if ($rs) { $sr = New-Object System.IO.StreamReader($rs); $null = $sr.ReadToEnd(); $sr.Close() } + } finally { + $resp.Close() + } + return + } + catch [System.Net.WebException] { + $status = $null + $body = '' + try { + if ($_.Exception.Response) { + try { $status = [int]$_.Exception.Response.StatusCode } catch {} + $rs = $_.Exception.Response.GetResponseStream() + if ($rs) { $sr = New-Object System.IO.StreamReader($rs); $body = $sr.ReadToEnd(); $sr.Close() } + } + } catch {} + + # aw-server-rust may return 304 for idempotent bucket create. Treat it as OK. + if ($status -eq 304) { + Write-EndpointLog ("POST bucket exists (304): uri={0}" -f $Uri) + return + } + + Write-EndpointLog ("POST failed: uri={0} status={1} err={2} body={3}" -f $Uri, $status, $_.Exception.Message, $body) + throw + } + catch { + Write-EndpointLog ("POST error: uri={0} err={1}" -f $Uri, $_.Exception.Message) + throw + } +} + +function Initialize-TransportQueue { + param([Parameter(Mandatory = $true)][string]$StateRoot) + $script:TransportQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl' + $script:TransportQueueLockPath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.lock' + if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { + New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null + } +} + +function Get-TransportQueueLock { + $tries = 0 + while ($tries -lt 50) { + try { + return [System.IO.File]::Open($script:TransportQueueLockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + } + catch { + Start-Sleep -Milliseconds 50 + $tries++ + } + } + throw "Failed to acquire transport queue lock: $script:TransportQueueLockPath" +} + +function Add-TransportQueueItem { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Payload, + [string]$Kind = 'endpoint' + ) + $lock = Get-TransportQueueLock + try { + $line = @{ + ts = (Get-Date).ToUniversalTime().ToString('o') + uri = $Uri + payload = $Payload + kind = $Kind + } | ConvertTo-Json -Compress + Add-Content -LiteralPath $script:TransportQueuePath -Value $line -Encoding UTF8 + $script:TransportMetrics.eventsEnqueued++ + } + finally { + $lock.Dispose() + } +} + +function Read-TransportQueueItems { + if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return @() } + $items = @() + foreach ($line in @(Get-Content -LiteralPath $script:TransportQueuePath -ErrorAction SilentlyContinue)) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + try { $items += ($line | ConvertFrom-Json) } catch {} + } + return $items +} + +function Flush-TransportQueue { + param([int]$MaxItems = 200) + if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return } + $lock = Get-TransportQueueLock + try { + $items = Read-TransportQueueItems + $script:TransportMetrics.queueDepth = $items.Count + if ($items.Count -eq 0) { return } + $left = New-Object System.Collections.Generic.List[object] + $sent = 0 + foreach ($item in $items) { + if ($sent -ge $MaxItems) { + $left.Add($item) + continue + } + try { + Invoke-AwJsonPost -Uri ([string]$item.uri) -Json ([string]$item.payload) + $sent++ + $script:TransportMetrics.eventsFlushed++ + } + catch { + $script:TransportMetrics.sendFailures++ + $left.Add($item) + } + } + foreach ($item in $items | Select-Object -Skip ($sent + $left.Count)) { + $left.Add($item) + } + $lines = @($left | ForEach-Object { $_ | ConvertTo-Json -Compress }) + Set-Content -LiteralPath $script:TransportQueuePath -Value $lines -Encoding UTF8 + $script:TransportMetrics.queueDepth = $left.Count + } + finally { + $lock.Dispose() + } } function Ensure-Bucket { @@ -54,13 +230,39 @@ function Ensure-Bucket { return } + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + # Fast-path: if bucket already exists, don't POST. + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" -TimeoutSec 10 -DisableKeepAlive -ErrorAction Stop | Out-Null + Write-EndpointLog ("bucket ok (GET): {0}" -f $BucketId) + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + Write-EndpointLog ("bucket GET failed: {0} err={1}" -f $BucketId, $_.Exception.Message) + } + $body = @{ client = $ClientName type = $BucketType hostname = $script:Hostname } | ConvertTo-Json -Compress - Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + try { + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + } + catch { + # If create failed (race), verify it exists now. + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" -TimeoutSec 10 -DisableKeepAlive | Out-Null + } + catch { + throw + } + } $script:KnownBuckets[$BucketId] = $true } @@ -85,7 +287,8 @@ function Send-EndpointSignalHeartbeat { } + $Data } | ConvertTo-Json -Depth 6 -Compress - Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload + Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Payload $payload -Kind 'endpoint_signal' + Flush-TransportQueue -MaxItems 50 } function Send-DlpIncidentHeartbeat { @@ -126,7 +329,8 @@ function Send-DlpIncidentHeartbeat { } + $Data + $captureData } | ConvertTo-Json -Depth 7 -Compress - Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload + Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Payload $payload -Kind 'dlp_incident' + Flush-TransportQueue -MaxItems 100 } function Get-FileSha256Hex { @@ -321,6 +525,53 @@ function Get-StringHash { } } +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Clipboard is not reliably accessible from Session 0 (SYSTEM). Avoid noisy thread hacks there. + if ($script:SessionId -eq 0) { + return $null + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $script:__aw_clip = $null + $threadStart = [System.Threading.ThreadStart]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread = New-Object System.Threading.Thread($threadStart) + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { + try { $thread.Abort() } catch {} + } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + function Load-DlpPolicy { param([string]$Path) @@ -336,8 +587,17 @@ function Load-DlpPolicy { usb = @() print = @() } + contentAnalysis = [ordered]@{ + dictionaryPack = $null + regexPack = $null + ocrEnabled = $false + } } + $script:PolicySource = 'defaults' + $script:PolicyVersion = $null + $script:PolicyChecksum = $null + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) return @@ -353,16 +613,240 @@ function Load-DlpPolicy { } if ($raw.endpoint) { - if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } - if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } - if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + $props = @() + try { $props = @($raw.endpoint.PSObject.Properties.Name) } catch { $props = @() } + if ($props -contains 'clipboard' -and $raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($props -contains 'usb' -and $raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($props -contains 'print' -and $raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } } + + if ($raw.contentAnalysis) { + if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'dictionaryPack' -and $raw.contentAnalysis.dictionaryPack) { + $script:Policy.contentAnalysis.dictionaryPack = [string]$raw.contentAnalysis.dictionaryPack + } + if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'regexPack' -and $raw.contentAnalysis.regexPack) { + $script:Policy.contentAnalysis.regexPack = [string]$raw.contentAnalysis.regexPack + } + if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'ocrEnabled') { + $script:Policy.contentAnalysis.ocrEnabled = [bool]$raw.contentAnalysis.ocrEnabled + } + } + $script:PolicySource = 'local' } catch { Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) } } +function Test-ValidInn { + param([string]$Value) + $digits = ($Value -replace '\D', '') + if ($digits.Length -eq 10) { + $coef = @(2, 4, 10, 3, 5, 9, 4, 6, 8) + $sum = 0 + for ($i = 0; $i -lt 9; $i++) { $sum += ([int][string]$digits[$i]) * $coef[$i] } + $chk = ($sum % 11) % 10 + return $chk -eq ([int][string]$digits[9]) + } + if ($digits.Length -eq 12) { + $c11 = @(7, 2, 4, 10, 3, 5, 9, 4, 6, 8) + $c12 = @(3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) + $sum11 = 0 + for ($i = 0; $i -lt 10; $i++) { $sum11 += ([int][string]$digits[$i]) * $c11[$i] } + $sum12 = 0 + for ($i = 0; $i -lt 11; $i++) { $sum12 += ([int][string]$digits[$i]) * $c12[$i] } + return ((($sum11 % 11) % 10) -eq ([int][string]$digits[10])) -and ((($sum12 % 11) % 10) -eq ([int][string]$digits[11])) + } + return $false +} + +function Test-ValidSnils { + param([string]$Value) + $digits = ($Value -replace '\D', '') + if ($digits.Length -ne 11) { return $false } + $num = $digits.Substring(0, 9) + $checksum = [int]$digits.Substring(9, 2) + $sum = 0 + for ($i = 0; $i -lt 9; $i++) { $sum += ([int][string]$num[$i]) * (9 - $i) } + if ($sum -lt 100) { $expected = $sum } + elseif ($sum -eq 100 -or $sum -eq 101) { $expected = 0 } + else { + $expected = $sum % 101 + if ($expected -eq 100) { $expected = 0 } + } + return $checksum -eq $expected +} + +function Test-ValidPassport { + param([string]$Value) + $digits = ($Value -replace '\D', '') + if ($digits.Length -ne 10) { return $false } + if ($digits -eq '0000000000') { return $false } + return ($digits.ToCharArray() | Select-Object -Unique).Count -gt 1 +} + +function Get-AdvancedContentMatches { + param( + [string]$Text, + [string]$DictionaryPack, + [string]$RegexPack + ) + + $result = @{ + dictionaryMatches = @() + regexMatches = @() + } + if ([string]::IsNullOrWhiteSpace($Text)) { return $result } + + if ($DictionaryPack -eq '152-fz-pdn') { + $m = [regex]::Matches($Text, '\b\d{10}\b|\b\d{12}\b') + foreach ($item in $m) { + if (Test-ValidInn -Value $item.Value) { + $result.dictionaryMatches += @{ name = 'inn'; value = $item.Value; severity = 'high' } + } + } + $m = [regex]::Matches($Text, '\b\d{3}-\d{3}-\d{3}\s?\d{2}\b') + foreach ($item in $m) { + if (Test-ValidSnils -Value $item.Value) { + $result.dictionaryMatches += @{ name = 'snils'; value = $item.Value; severity = 'high' } + } + } + $m = [regex]::Matches($Text, '\b\d{4}\s?\d{6}\b') + foreach ($item in $m) { + if (Test-ValidPassport -Value $item.Value) { + $result.dictionaryMatches += @{ name = 'passport'; value = $item.Value; severity = 'high' } + } + } + } + + $regexRules = @() + switch ($RegexPack) { + 'financial' { + $regexRules = @( + @{ id = 'card-pan'; regex = '\b(?:\d[ -]*?){13,19}\b'; severity = 'high' }, + @{ id = 'iban'; regex = '\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b'; severity = 'medium' } + ) + } + 'contacts' { + $regexRules = @( + @{ id = 'email'; regex = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'; severity = 'low' }, + @{ id = 'phone-ru'; regex = '(?:\+7|8)\s*\(?\d{3}\)?\s*\d{3}[- ]?\d{2}[- ]?\d{2}'; severity = 'low' } + ) + } + 'secrets' { + $regexRules = @( + @{ id = 'aws-access-key'; regex = 'AKIA[0-9A-Z]{16}'; severity = 'high' }, + @{ id = 'generic-password'; regex = '(?i)(password|пароль)\s*[:=]\s*\S{6,}'; severity = 'medium' } + ) + } + } + foreach ($rule in $regexRules) { + $m = [regex]::Matches($Text, [string]$rule.regex) + foreach ($item in $m) { + $result.regexMatches += @{ name = [string]$rule.id; value = $item.Value; severity = [string]$rule.severity } + } + } + + return $result +} + +function Apply-PolicyFromBundle { + param( + [Parameter(Mandatory = $true)]$Bundle, + [Parameter(Mandatory = $true)][string]$Source + ) + + if (-not $Bundle.policy) { + throw 'Policy bundle has no policy payload.' + } + + $tempPath = [System.IO.Path]::GetTempFileName() + try { + $Bundle.policy | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tempPath -Encoding UTF8 + Load-DlpPolicy -Path $tempPath + $script:PolicySource = $Source + $script:PolicyVersion = if ($Bundle.PSObject.Properties.Name -contains 'version') { [string]$Bundle.version } else { $null } + $script:PolicyChecksum = if ($Bundle.PSObject.Properties.Name -contains 'checksum') { [string]$Bundle.checksum } else { $null } + } + finally { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + } +} + +function Refresh-DlpPolicyFromServer { + if (-not $script:PolicyEngineEnabled) { + return $false + } + if (-not $script:PolicyClientAvailable) { + Write-EndpointLog 'policy client module unavailable, cannot use server mode' + return $false + } + + try { + $bundle = Get-RemoteDlpPolicyBundle -ApiBase $script:PolicyApiBase -TimeoutSec 10 + Save-CachedDlpPolicyBundle -Bundle $bundle -CachePath $script:PolicyCachePath + Apply-PolicyFromBundle -Bundle $bundle -Source 'server' + $script:LastPolicyRefreshAt = (Get-Date).ToUniversalTime() + Write-EndpointLog ("policy refreshed from server version={0} checksum={1}" -f $script:PolicyVersion, $script:PolicyChecksum) + return $true + } + catch { + Write-EndpointLog ("policy refresh failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Sync-DlpPolicyDesiredState { + if (-not $script:PolicyEngineEnabled -or -not $script:PolicyClientAvailable) { + return $false + } + if (-not $script:PolicyAgentId) { + return $false + } + + try { + [void](Send-DlpPolicyAgentHeartbeat -ApiBase $script:PolicyApiBase -AgentId $script:PolicyAgentId -Hostname $script:Hostname -Version $script:PolicyVersion -Checksum $script:PolicyChecksum -TimeoutSec 10) + $desired = Get-RemoteDlpPolicyDesired -ApiBase $script:PolicyApiBase -AgentId $script:PolicyAgentId -TimeoutSec 10 + if ($desired -and $desired.refreshNow -eq $true) { + Write-EndpointLog ("policy desired refresh requested: reason={0}" -f $desired.reason) + return (Refresh-DlpPolicyFromServer) + } + return $true + } + catch { + Write-EndpointLog ("policy desired sync failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Initialize-DlpPolicy { + if ($script:PolicyMode -eq 'server') { + if (Refresh-DlpPolicyFromServer) { + return + } + + if ($script:PolicyClientAvailable) { + $cached = Read-CachedDlpPolicyBundle -CachePath $script:PolicyCachePath + if ($cached) { + try { + Apply-PolicyFromBundle -Bundle $cached -Source 'cache' + Write-EndpointLog ("policy loaded from cache version={0} checksum={1}" -f $script:PolicyVersion, $script:PolicyChecksum) + return + } + catch { + Write-EndpointLog ("cached policy load failed: {0}" -f $_.Exception.Message) + } + } + } + + Load-DlpPolicy -Path $script:LocalPolicyPath + $script:PolicySource = 'local-fallback' + return + } + + Load-DlpPolicy -Path $script:LocalPolicyPath +} + function Should-EmitByCooldown { param( [string]$Fingerprint, @@ -394,6 +878,9 @@ function Evaluate-ClipboardRules { if (-not $ruleId) { continue } $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + $dictionaryPack = if ($rule.dictionaryPack) { [string]$rule.dictionaryPack } elseif ($script:Policy.contentAnalysis.dictionaryPack) { [string]$script:Policy.contentAnalysis.dictionaryPack } else { $null } + $regexPack = if ($rule.regexPack) { [string]$rule.regexPack } elseif ($script:Policy.contentAnalysis.regexPack) { [string]$script:Policy.contentAnalysis.regexPack } else { $null } + $ocrEnabled = if ($rule.PSObject.Properties.Name -contains 'ocrEnabled') { [bool]$rule.ocrEnabled } else { [bool]$script:Policy.contentAnalysis.ocrEnabled } if ($ClipboardText.Length -lt $minLength) { continue } $matched = $false @@ -403,6 +890,9 @@ function Evaluate-ClipboardRules { break } } + $advanced = Get-AdvancedContentMatches -Text $ClipboardText -DictionaryPack $dictionaryPack -RegexPack $regexPack + $advancedMatched = (@($advanced.dictionaryMatches).Count -gt 0) -or (@($advanced.regexMatches).Count -gt 0) + if ($advancedMatched) { $matched = $true } if (-not $matched) { continue } @@ -424,6 +914,11 @@ function Evaluate-ClipboardRules { clipboardHash = $ClipboardHash clipboardLength = $ClipboardText.Length enforced = $enforced + dictionaryPack = $dictionaryPack + regexPack = $regexPack + dictionaryMatches = @($advanced.dictionaryMatches) + regexMatches = @($advanced.regexMatches) + ocrRequested = $ocrEnabled } Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) } @@ -484,6 +979,12 @@ function Evaluate-PrintRules { if ($rule.documentRegex) { $match = $match -and ($DocumentName -match [string]$rule.documentRegex) } + $dictionaryPack = if ($rule.dictionaryPack) { [string]$rule.dictionaryPack } elseif ($script:Policy.contentAnalysis.dictionaryPack) { [string]$script:Policy.contentAnalysis.dictionaryPack } else { $null } + $regexPack = if ($rule.regexPack) { [string]$rule.regexPack } elseif ($script:Policy.contentAnalysis.regexPack) { [string]$script:Policy.contentAnalysis.regexPack } else { $null } + $ocrEnabled = if ($rule.PSObject.Properties.Name -contains 'ocrEnabled') { [bool]$rule.ocrEnabled } else { [bool]$script:Policy.contentAnalysis.ocrEnabled } + $advanced = Get-AdvancedContentMatches -Text $DocumentName -DictionaryPack $dictionaryPack -RegexPack $regexPack + $advancedMatched = (@($advanced.dictionaryMatches).Count -gt 0) -or (@($advanced.regexMatches).Count -gt 0) + if ($advancedMatched) { $match = $true } if (-not $match) { continue } $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } @@ -505,6 +1006,11 @@ function Evaluate-PrintRules { documentName = $DocumentName owner = $Owner enforced = $enforced + dictionaryPack = $dictionaryPack + regexPack = $regexPack + dictionaryMatches = @($advanced.dictionaryMatches) + regexMatches = @($advanced.regexMatches) + ocrRequested = $ocrEnabled } Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) } @@ -734,19 +1240,29 @@ $resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig $resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } $resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } $resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedStateRoot = if ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$deploymentConfig.paths.stateRoot } else { Split-Path -Path $resolvedPolicyPath -Parent } $resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } $resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } $resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } $resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } $resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } $resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } +$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME } +$resolvedPolicyMode = if ($PolicyMode) { [string]$PolicyMode } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$deploymentConfig.policyEngine.mode } else { 'local' } +$resolvedPolicyEngineEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'enabled') { [bool]$deploymentConfig.policyEngine.enabled } else { $false } +$resolvedPolicyEngineHost = if ($PolicyEngineHost) { [string]$PolicyEngineHost } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'host') { [string]$deploymentConfig.policyEngine.host } else { $resolvedServerHost } +$resolvedPolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePort')) { $PolicyEnginePort } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'port') { [int]$deploymentConfig.policyEngine.port } else { $resolvedServerPort } +$resolvedPolicyEngineScheme = if ($PolicyEngineScheme) { [string]$PolicyEngineScheme } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'scheme') { [string]$deploymentConfig.policyEngine.scheme } else { $resolvedServerScheme } +$resolvedPolicyRefreshSeconds = if ($PSBoundParameters.ContainsKey('PolicyRefreshSeconds')) { $PolicyRefreshSeconds } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'refreshSeconds') { [int]$deploymentConfig.policyEngine.refreshSeconds } else { 300 } +$resolvedPolicyCachePath = if ($PolicyCachePath) { [string]$PolicyCachePath } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'cachePath') { [string]$deploymentConfig.policyEngine.cachePath } else { Join-Path $resolvedStateRoot 'dlp-policy-cache.json' } if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null } $script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort -$script:Hostname = $env:COMPUTERNAME +$script:PolicyApiBase = '{0}://{1}:{2}/api/0' -f $resolvedPolicyEngineScheme, $resolvedPolicyEngineHost, $resolvedPolicyEnginePort +$script:Hostname = $resolvedHostname $script:SessionId = (Get-Process -Id $PID).SessionId $script:KnownBuckets = @{} $script:Cooldown = @{} @@ -762,17 +1278,59 @@ $script:LogPath = $resolvedLogPath $script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled $script:ScreenshotTypesLoaded = $false +$script:PolicyMode = $resolvedPolicyMode +$script:PolicyEngineEnabled = $resolvedPolicyEngineEnabled +$script:PolicyRefreshSeconds = [Math]::Max($resolvedPolicyRefreshSeconds, 60) +$script:PolicyCachePath = $resolvedPolicyCachePath +$script:LocalPolicyPath = $resolvedPolicyPath +$script:LastPolicyRefreshAt = [datetime]::MinValue +$script:PolicyAgentId = $resolvedHostname +$script:TransportBackoffSeconds = 1 +# Integration test flag (backward compatible - defaults to false) +$script:IntegrationTestEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'integrationTestEnabled') { [bool]$deploymentConfig.integrationTestEnabled } else { $false } -Load-DlpPolicy -Path $resolvedPolicyPath +# Integration metadata tracking (backward compatible) +$script:TotalEventsProcessed = 0 +$script:LastEventTime = $null + +Initialize-TransportQueue -StateRoot $resolvedStateRoot +Initialize-DlpPolicy Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) while ($true) { try { + try { + Flush-TransportQueue -MaxItems 200 + $script:TransportBackoffSeconds = 1 + } + catch { + $script:TransportBackoffSeconds = [Math]::Min($script:TransportBackoffSeconds * 2, 60) + Write-EndpointLog ("transport flush failed, backoff={0}s err={1}" -f $script:TransportBackoffSeconds, $_.Exception.Message) + } + + if ($script:PolicyMode -eq 'server') { + $policyAge = ((Get-Date).ToUniversalTime() - $script:LastPolicyRefreshAt).TotalSeconds + if ($policyAge -ge $script:PolicyRefreshSeconds) { + [void](Refresh-DlpPolicyFromServer) + } + else { + [void](Sync-DlpPolicyDesiredState) + } + } + $nowUtc = (Get-Date).ToUniversalTime() if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ collector = 'dlp-endpoint-signals' policyEnabled = [bool]$script:Policy.defaults.enabled + policyMode = $script:PolicyMode + policySource = $script:PolicySource + policyVersion = $script:PolicyVersion + policyChecksum = $script:PolicyChecksum + queueDepth = [int]$script:TransportMetrics.queueDepth + eventsEnqueued = [int]$script:TransportMetrics.eventsEnqueued + eventsFlushed = [int]$script:TransportMetrics.eventsFlushed + sendFailures = [int]$script:TransportMetrics.sendFailures } $script:LastSelfTestAt = $nowUtc } @@ -783,7 +1341,7 @@ while ($true) { } try { - $clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue + $clipboardText = Get-ClipboardTextSafe if ($clipboardText) { $clipboardHash = Get-StringHash -Value $clipboardText if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { @@ -792,6 +1350,8 @@ while ($true) { clipboardHash = $clipboardHash clipboardLength = $clipboardText.Length } + $script:TotalEventsProcessed++ + $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash } } @@ -813,6 +1373,8 @@ while ($true) { driveLetter = $deviceId volumeName = $volumeName } + $script:TotalEventsProcessed++ + $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName } } @@ -852,6 +1414,8 @@ while ($true) { documentNameOriginal = $documentNameOriginal owner = $owner } + $script:TotalEventsProcessed++ + $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner } @@ -899,6 +1463,8 @@ while ($true) { eventRecordId = $recordId eventSource = 'printservice-307' } + $script:TotalEventsProcessed++ + $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner } @@ -917,5 +1483,35 @@ while ($true) { Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) } - Start-Sleep -Seconds $resolvedPollSeconds + # Integration metadata self-test (backward compatible) + if ($script:IntegrationTestEnabled -and (Get-Date).Minute -eq 0) { + try { + $testMetadata = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + collector = 'dlp-endpoint-signals' + version = '1.0.0' + hostname = $env:COMPUTERNAME + username = $env:USERNAME + status = 'healthy' + checks = @{ + eventsProcessed = $script:TotalEventsProcessed + lastEventTime = $script:LastEventTime + iocRulesLoaded = if ($script:IocRules) { @($script:IocRules).Count } else { 0 } + policyRulesLoaded = if ($script:Policy -and $script:Policy.endpoint) { (@($script:Policy.endpoint.clipboard).Count + @($script:Policy.endpoint.usb).Count + @($script:Policy.endpoint.print).Count) } else { 0 } + } + } + Send-EndpointSignalHeartbeat -SignalType 'integration_test' -Data $testMetadata + Write-EndpointLog "Integration metadata test sent" + } + catch { + Write-EndpointLog "Integration test failed: $($_.Exception.Message)" + } + } + + if ($script:TransportBackoffSeconds -gt $resolvedPollSeconds) { + Start-Sleep -Seconds $script:TransportBackoffSeconds + } + else { + Start-Sleep -Seconds $resolvedPollSeconds + } } diff --git a/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json b/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json index 5ed8ef6..7c8c40f 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json +++ b/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json @@ -95,5 +95,16 @@ "documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)" } ] + }, + "contentAnalysis": { + "dictionaryPack": "152-fz-pdn", + "regexPack": "secrets", + "ocrEnabled": true + }, + "ioc": { + "enabled": true, + "source": "http://10.10.10.13:5610/dlp-ioc/ioc_blacklist.json", + "format": "hayabusa_sigma_v1", + "refreshMinutes": 360 } } diff --git a/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 b/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 index 7a1600f..96c6461 100644 --- a/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 @@ -26,7 +26,7 @@ param( [string]$LogPath, [int]$PollSeconds, [ValidateSet('outlook', 'smtp', 'both')] - [string]$Mode = 'both' + [string]$Mode = 'smtp' ) Set-StrictMode -Version Latest @@ -322,8 +322,23 @@ function Invoke-EmailEnforcement { # --------------------------------------------------------------------------- function Initialize-OutlookCom { + if ($script:OutlookDisabled) { + return $false + } + + if (-not (Test-OutlookProfileConfigured)) { + Write-CollectorLog "Outlook profile not configured for current user, Outlook mode disabled" + $script:OutlookDisabled = $true + return $false + } + + if (-not (Get-Process -Name OUTLOOK -ErrorAction SilentlyContinue | Select-Object -First 1)) { + Write-CollectorLog "Outlook process not running, skipping COM initialization" + return $false + } + try { - $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookApp = [Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application') $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" @@ -335,6 +350,32 @@ function Initialize-OutlookCom { } } +function Test-OutlookProfileConfigured { + [OutputType([bool])] + $officeRoots = @( + 'HKCU:\Software\Microsoft\Office', + 'HKCU:\Software\WOW6432Node\Microsoft\Office' + ) + + foreach ($root in $officeRoots) { + if (-not (Test-Path -LiteralPath $root)) { continue } + $versions = Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -match '^\d+\.\d+$' } | + Sort-Object { [version]$_.PSChildName } -Descending + foreach ($ver in $versions) { + $profilesPath = Join-Path $ver.PSPath 'Outlook\Profiles' + if (Test-Path -LiteralPath $profilesPath) { + $profiles = Get-ChildItem -LiteralPath $profilesPath -ErrorAction SilentlyContinue + if ($profiles -and $profiles.Count -gt 0) { + return $true + } + } + } + } + + return $false +} + function Get-OutlookSentItems { param([datetime]$Since) @@ -521,6 +562,7 @@ $script:OutlookApp = $null $script:OutlookNamespace = $null $script:SentFolder = $null $script:OutlookLastPoll = (Get-Date).AddMinutes(-5) +$script:OutlookDisabled = $false Load-EmailPolicy -Path $resolvedPolicyPath Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) diff --git a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 index d382f41..dd9852e 100755 --- a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +++ b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 @@ -20,9 +20,22 @@ param( [bool]$IncidentCaptureEnabled, [bool]$IncidentScreenshotEnabled, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays, + [string[]]$EvtxChannels, [bool]$LogonMarkerEnabled, + [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, + [ValidateSet('local', 'server')] + [string]$PolicyMode, + [bool]$PolicyEngineEnabled, + [string]$PolicyEngineHost, + [int]$PolicyEnginePort, + [ValidateSet('http', 'https')] + [string]$PolicyEngineScheme, + [int]$PolicyRefreshSeconds, + [string]$PolicyCachePath, [switch]$RepairPackage, [string]$Version, [string]$PackageUrl, @@ -56,8 +69,10 @@ $effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-coll $effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' } $effectiveFileCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$existingConfig.paths.fileCollectorScript } else { Join-Path $effectiveStateRoot 'file-operations-collector.ps1' } $effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' } +$effectiveEvtxExportScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$existingConfig.paths.evtxExportScript } else { Join-Path $effectiveStateRoot 'export-evtx-for-hayabusa.ps1' } $effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json' $effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' } +$effectivePolicyClientScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$existingConfig.paths.policyClientScript } else { Join-Path $effectiveStateRoot 'dlp-policy-client.ps1' } $effectiveServerHost = if ($ServerHost) { $ServerHost } elseif ($existingConfig) { [string]$existingConfig.server.host } else { $null } $effectiveServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($existingConfig) { [int]$existingConfig.server.port } else { 5600 } @@ -72,8 +87,19 @@ $effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgent $effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentCaptureEnabled')) { [bool]$IncidentCaptureEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.incidentCapture.enabled } else { $true } $effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true } $effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' } +$effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot') -and $EvtxExportRoot) { $EvtxExportRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$existingConfig.forensics.evtxExportRoot } else { Join-Path $effectiveStateRoot 'forensics\evtx-exports' } +$effectiveEvtxRetentionDays = if ($PSBoundParameters.ContainsKey('EvtxRetentionDays')) { [int]$EvtxRetentionDays } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 } +$effectiveEvtxChannels = if ($PSBoundParameters.ContainsKey('EvtxChannels')) { @($EvtxChannels) } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } +$effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } +$effectivePolicyMode = if ($PSBoundParameters.ContainsKey('PolicyMode') -and $PolicyMode) { [string]$PolicyMode } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.policyEngine.mode } else { 'local' } +$effectivePolicyEngineEnabled = if ($PSBoundParameters.ContainsKey('PolicyEngineEnabled')) { [bool]$PolicyEngineEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.policyEngine.enabled } else { $false } +$effectivePolicyEngineHost = if ($PSBoundParameters.ContainsKey('PolicyEngineHost') -and -not [string]::IsNullOrWhiteSpace($PolicyEngineHost)) { [string]$PolicyEngineHost } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'host') { [string]$existingConfig.policyEngine.host } else { [string]$effectiveServerHost } +$effectivePolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePort')) { [int]$PolicyEnginePort } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'port') { [int]$existingConfig.policyEngine.port } else { 5601 } +$effectivePolicyEngineScheme = if ($PSBoundParameters.ContainsKey('PolicyEngineScheme') -and $PolicyEngineScheme) { [string]$PolicyEngineScheme } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'scheme') { [string]$existingConfig.policyEngine.scheme } else { 'http' } +$effectivePolicyRefreshSeconds = if ($PSBoundParameters.ContainsKey('PolicyRefreshSeconds')) { [int]$PolicyRefreshSeconds } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'refreshSeconds') { [int]$existingConfig.policyEngine.refreshSeconds } else { 300 } +$effectivePolicyCachePath = if ($PSBoundParameters.ContainsKey('PolicyCachePath') -and $PolicyCachePath) { [string]$PolicyCachePath } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'cachePath') { [string]$existingConfig.policyEngine.cachePath } else { Join-Path $effectiveStateRoot 'dlp-policy-cache.json' } $effectiveUsers = if ($Users -or $UserListPath) { Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain @@ -104,6 +130,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -EmailCollectorScriptSource (Join-Path $PSScriptRoot 'email-outbound-collector.ps1') ` -FileCollectorScriptSource (Join-Path $PSScriptRoot 'file-operations-collector.ps1') ` -SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') ` + -EvtxExportScriptSource (Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1') ` -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` -ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') ` -StateRoot $effectiveStateRoot ` @@ -123,9 +150,11 @@ $config = New-ActivityWatchDeploymentConfig ` -LogsRoot $effectiveLogsRoot ` -CollectorScript $effectiveCollector ` -EndpointCollectorScript $effectiveEndpointCollector ` + -PolicyClientScript $effectivePolicyClientScript ` -EmailCollectorScript $assetResult.EmailCollectorScript ` -FileCollectorScript $effectiveFileCollector ` -SessionCollectorScript $effectiveSessionCollector ` + -EvtxExportScript $effectiveEvtxExportScript ` -RulesPath $effectiveRules ` -PolicyPath $effectivePolicy ` -PollSeconds $effectivePollSeconds ` @@ -138,7 +167,18 @@ $config = New-ActivityWatchDeploymentConfig ` -IncidentCaptureEnabled $effectiveIncidentCaptureEnabled ` -IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled ` -IncidentArtifactsRoot $effectiveIncidentArtifactsRoot ` + -EvtxExportRoot $effectiveEvtxExportRoot ` + -EvtxRetentionDays $effectiveEvtxRetentionDays ` + -EvtxChannels $effectiveEvtxChannels ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled ` + -AwHostname $effectiveAwHostname ` + -PolicyMode $effectivePolicyMode ` + -PolicyEngineEnabled $effectivePolicyEngineEnabled ` + -PolicyEngineHost $effectivePolicyEngineHost ` + -PolicyEnginePort $effectivePolicyEnginePort ` + -PolicyEngineScheme $effectivePolicyEngineScheme ` + -PolicyRefreshSeconds $effectivePolicyRefreshSeconds ` + -PolicyCachePath $effectivePolicyCachePath ` -LaunchScriptPath $effectiveLaunchScript ` -RecoveryScriptPath $effectiveRecoveryScript ` -UserTasks $taskDefinitions ` diff --git a/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 b/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 index eb7cbe4..c588050 100644 --- a/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 +++ b/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 @@ -154,7 +154,36 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W @{ Source = $NewStateRoot; Name = 'new-state' } )) { if (Test-Path -LiteralPath $item.Source) { - Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force + $backupDest = Join-Path $backupRoot $item.Name + New-ActivityWatchDirectory -Path $backupDest + + $excludeDirs = @() + if ($item.Source -eq $NewStateRoot) { + # Avoid infinite recursion: backupRoot is inside NewStateRoot by default. + $excludeDirs += $backupRoot + } + + $robocopyArgs = @( + $item.Source, + $backupDest, + '/E', + '/R:1', + '/W:1', + '/NFL', + '/NDL', + '/NJH', + '/NJS', + '/NP' + ) + if ($excludeDirs.Count -gt 0) { + $robocopyArgs += '/XD' + $robocopyArgs += $excludeDirs + } + + & robocopy @robocopyArgs | Out-Null + if ($LASTEXITCODE -ge 8) { + throw "Backup robocopy failed (exit=$LASTEXITCODE) for source '$($item.Source)' to '$backupDest'" + } } } diff --git a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 index 52b35d2..1273a86 100644 --- a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +++ b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 @@ -14,24 +14,327 @@ $installRoot = [string]$config.paths.installRoot $stateRoot = [string]$config.paths.stateRoot $collectorScript = [string]$config.paths.collectorScript $endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' } +$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' } $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } +$evtxExportScript = if ($config.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$config.paths.evtxExportScript } else { Join-Path $stateRoot 'export-evtx-for-hayabusa.ps1' } $rulesPath = [string]$config.paths.rulesPath $policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' } +$policyClientScript = if ($config.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$config.paths.policyClientScript } else { Join-Path $stateRoot 'dlp-policy-client.ps1' } $launchScript = [string]$config.paths.launchScript $recoveryScript = [string]$config.paths.recoveryScript +$awHostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME } +$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port +$apiBase = "$serverUrl/api/0" +$pollSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $config.collector.PSObject.Properties.Name -contains 'pollSeconds') { [int]$config.collector.pollSeconds } else { 5 } +$pulseSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $config.collector.PSObject.Properties.Name -contains 'pulseSeconds') { [int]$config.collector.pulseSeconds } else { [Math]::Max($pollSeconds * 3, 30) } +$freshnessSeconds = [Math]::Max($pollSeconds * 3, 30) +$sessionFreshnessSeconds = [Math]::Max($pollSeconds * 4, 45) +$transportStaleSeconds = [Math]::Max($pollSeconds * 12, 180) +$queueMaxDepth = 1000 $afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } +$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } + +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 Get-CollectorProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + return @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -and + $_.CommandLine -match [Regex]::Escape($ScriptPath) + } | + Select-Object @{ Name = 'Name'; Expression = { $_.Name } }, @{ Name = 'Id'; Expression = { [int]$_.ProcessId } }, @{ Name = 'SessionId'; Expression = { [int]$_.SessionId } }, @{ Name = 'CommandLine'; Expression = { [string]$_.CommandLine } } + ) +} + +function Get-DuplicateProcessGroups { + param( + [object[]]$Processes, + [bool]$PerSession = $true + ) + + if (-not $Processes -or @($Processes).Count -eq 0) { return @() } + $groups = if ($PerSession) { + $Processes | Group-Object -Property Name, SessionId + } + else { + $Processes | Group-Object -Property Name + } + + return @( + $groups | + Where-Object { $_.Count -gt 1 } | + ForEach-Object { + [pscustomobject]@{ + name = [string]$_.Name + count = [int]$_.Count + members = @($_.Group | Select-Object Name, Id, SessionId, CommandLine) + } + } + ) +} + +function Convert-ToUtcDate { + param($Value) + + if ($null -eq $Value) { return $null } + try { + return ([DateTimeOffset]::Parse([string]$Value)).UtcDateTime + } + catch { + return $null + } +} + +function Get-BucketHealth { + param( + [Parameter(Mandatory = $true)] + [string]$BucketId, + [Parameter(Mandatory = $true)] + [int]$MaxAgeSeconds, + [bool]$Required = $true, + [bool]$RequireFreshEvent = $true + ) + + $events = @() + $queryOk = $false + $errorMessage = $null + try { + $response = Invoke-RestMethod -Method Get -Uri "$apiBase/buckets/$BucketId/events?limit=25" -TimeoutSec 15 -DisableKeepAlive -ErrorAction Stop + $events = @($response) + $queryOk = $true + } + catch { + $errorMessage = $_.Exception.Message + } + + $latestTimestampUtc = $null + $ageSeconds = $null + if ($events.Count -gt 0) { + $latestTimestampUtc = @( + $events | + ForEach-Object { Convert-ToUtcDate $_.timestamp } | + Where-Object { $null -ne $_ } | + Sort-Object -Descending + ) | Select-Object -First 1 + if ($null -ne $latestTimestampUtc) { + $ageSeconds = [int][Math]::Floor(((Get-Date).ToUniversalTime() - $latestTimestampUtc).TotalSeconds) + } + } + + $hasFreshEvent = ($null -ne $ageSeconds -and $ageSeconds -le $MaxAgeSeconds) + $hasAnyEvent = ($events.Count -gt 0) + $ok = if (-not $Required) { $true } elseif ($RequireFreshEvent) { $queryOk -and $hasFreshEvent } else { $queryOk -and $hasAnyEvent } + + return [pscustomobject]@{ + bucketId = $BucketId + required = [bool]$Required + requireFreshEvent = [bool]$RequireFreshEvent + maxAgeSeconds = [int]$MaxAgeSeconds + queryOk = [bool]$queryOk + latestTimestampUtc = if ($null -ne $latestTimestampUtc) { $latestTimestampUtc.ToString('o') } else { $null } + ageSeconds = if ($null -ne $ageSeconds) { [int]$ageSeconds } else { $null } + count = [int]$events.Count + ok = [bool]$ok + error = $errorMessage + } +} + +function Get-TransportQueueHealth { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$QueuePath, + [Parameter(Mandatory = $true)] + [string]$LockPath, + [Parameter(Mandatory = $true)] + [int]$StaleAfterSeconds, + [Parameter(Mandatory = $true)] + [int]$MaxDepth, + [int]$ActiveProcessCount = 0, + [bool]$Required = $true + ) + + $queueExists = Test-Path -LiteralPath $QueuePath + $depth = 0 + $sizeBytes = 0 + $ageSeconds = $null + $lastWriteUtc = $null + if ($queueExists) { + $item = Get-Item -LiteralPath $QueuePath -ErrorAction SilentlyContinue + if ($item) { + $sizeBytes = [int64]$item.Length + $lastWriteUtc = $item.LastWriteTimeUtc + $ageSeconds = [int][Math]::Floor(((Get-Date).ToUniversalTime() - $lastWriteUtc).TotalSeconds) + } + try { + $depth = [int]((Get-Content -LiteralPath $QueuePath -ErrorAction SilentlyContinue | Measure-Object).Count) + } + catch { + $depth = 0 + } + } + + $lockExists = Test-Path -LiteralPath $LockPath + $lockHeld = $false + if ($lockExists) { + try { + $lockHandle = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + $lockHandle.Dispose() + } + catch { + $lockHeld = $true + } + } + + $staleQueue = ($depth -gt 0 -and $null -ne $ageSeconds -and $ageSeconds -gt $StaleAfterSeconds -and -not $lockHeld) + $orphanedQueue = ($depth -gt 0 -and $ActiveProcessCount -le 0 -and $null -ne $ageSeconds -and $ageSeconds -gt $StaleAfterSeconds) + $oversizedQueue = ($depth -gt $MaxDepth) + $ok = if (-not $Required) { $true } else { -not ($staleQueue -or $orphanedQueue -or $oversizedQueue) } + + return [pscustomobject]@{ + name = $Name + required = [bool]$Required + queuePath = $QueuePath + queueExists = [bool]$queueExists + depth = [int]$depth + sizeBytes = [int64]$sizeBytes + lastWriteUtc = if ($null -ne $lastWriteUtc) { $lastWriteUtc.ToString('o') } else { $null } + ageSeconds = if ($null -ne $ageSeconds) { [int]$ageSeconds } else { $null } + lockPath = $LockPath + lockExists = [bool]$lockExists + lockHeld = [bool]$lockHeld + activeProcessCount = [int]$ActiveProcessCount + staleAfterSeconds = [int]$StaleAfterSeconds + maxDepth = [int]$MaxDepth + staleQueue = [bool]$staleQueue + orphanedQueue = [bool]$orphanedQueue + oversizedQueue = [bool]$oversizedQueue + ok = [bool]$ok + } +} + +function Get-TaskSnapshot { + param( + [Parameter(Mandatory = $true)] + [string[]]$TaskNames + ) + + return @( + foreach ($taskName in @($TaskNames | Sort-Object -Unique)) { + $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 + if ($null -eq $task) { + [pscustomobject]@{ + taskName = $taskName + present = $false + enabled = $false + state = 'Отсутствует' + lastResult = $null + ok = $false + } + continue + } + + $taskInfo = $null + try { + $taskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop + } + catch { + } + + $enabled = $true + try { + if ($task.Settings.PSObject.Properties.Name -contains 'Enabled') { + $enabled = [bool]$task.Settings.Enabled + } + } + catch { + } + + [pscustomobject]@{ + taskName = [string]$task.TaskName + present = $true + enabled = [bool]$enabled + state = [string]$task.State + lastResult = if ($taskInfo) { [int64]$taskInfo.LastTaskResult } else { $null } + ok = [bool]($enabled) + } + } + ) +} + $requiredFiles = @( $collectorScript, $endpointCollectorScript, $sessionCollectorScript, + $evtxExportScript, $rulesPath, $policyPath, + $policyClientScript, $launchScript, $recoveryScript, $ConfigPath ) +if ($fileOpsExpected) { + $requiredFiles += $fileCollectorScript +} if ($afkExpected) { $requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe') } @@ -40,55 +343,123 @@ if ($windowExpected) { } $missingFiles = @( - $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) } + $requiredFiles | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | + Where-Object { -not (Test-Path -LiteralPath $_) } ) -$processNames = @() -if ($afkExpected) { $processNames += 'aw-watcher-afk' } -if ($windowExpected) { $processNames += 'aw-watcher-window' } -$runningProcesses = @() -if ($processNames.Count -gt 0) { - $runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId +$runningWatchers = @() +$expectedWatcherNames = @() +if ($afkExpected) { $expectedWatcherNames += 'aw-watcher-afk' } +if ($windowExpected) { $expectedWatcherNames += 'aw-watcher-window' } +if ($expectedWatcherNames.Count -gt 0) { + $runningWatchers = @(Get-Process -Name $expectedWatcherNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId) } -$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and - $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) - } | - Select-Object Name, ProcessId, SessionId, CommandLine + +$sessionCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $sessionCollectorScript) +$endpointCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $endpointCollectorScript) +$fileCollectorProcesses = if ($fileOpsExpected) { @(Get-CollectorProcesses -ScriptPath $fileCollectorScript) } else { @() } +$browserCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $collectorScript) + +$loggedOnUsers = Get-LoggedOnUsers +$sessionBoundUsers = @( + @($config.userTasks) | + Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } | + ForEach-Object { [string]$_.userId } +) +$sessionScopedExpectedCount = [int]$sessionBoundUsers.Count +$sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0) $taskNames = @() if ($config.userTasks) { $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) } $taskNames += [string]$config.recovery.taskName -$taskNames = $taskNames | Sort-Object -Unique +$tasks = @(Get-TaskSnapshot -TaskNames $taskNames) -$tasks = foreach ($taskName in $taskNames) { - $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 - if ($task) { - [pscustomobject]@{ - taskName = $task.TaskName - state = [string]$task.State - present = $true - } +$watcherDuplicates = @(Get-DuplicateProcessGroups -Processes $runningWatchers -PerSession $true) +$sessionCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $sessionCollectorProcesses -PerSession $false) +$endpointCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $endpointCollectorProcesses -PerSession $true) +$fileCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $fileCollectorProcesses -PerSession $true) +$browserCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $browserCollectorProcesses -PerSession $true) + +$watcherByName = @{} +foreach ($watcher in $runningWatchers) { + if (-not $watcherByName.ContainsKey([string]$watcher.Name)) { + $watcherByName[[string]$watcher.Name] = 0 } - else { - [pscustomobject]@{ - taskName = $taskName - state = 'Отсутствует' - present = $false - } + $watcherByName[[string]$watcher.Name]++ +} + +$bucketChecks = @( + Get-BucketHealth -BucketId ('aw-worktime-sessions_' + $awHostname) -MaxAgeSeconds $sessionFreshnessSeconds -Required $true -RequireFreshEvent $true +) +if ($sessionScopedCollectorsRequired -and $afkExpected) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-afk_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false +} +if ($sessionScopedCollectorsRequired -and $windowExpected) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false +} +if ($sessionScopedCollectorsRequired) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $true +} +if ($sessionScopedCollectorsRequired -and $fileOpsExpected) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true +} + +$queueChecks = @( + Get-TransportQueueHealth -Name 'endpoint' -QueuePath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.jsonl') -LockPath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $sessionScopedCollectorsRequired +) +if ($fileOpsExpected) { + $queueChecks += Get-TransportQueueHealth -Name 'fileops' -QueuePath (Join-Path $stateRoot 'file-operations-queue.jsonl') -LockPath (Join-Path $stateRoot 'file-operations-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $sessionScopedCollectorsRequired +} + +$printServiceOperationalEnabled = $false +try { + $printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop + $printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled +} +catch { +} + +$printJobTitlePolicyEnabled = $false +try { + $printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop + $printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1) +} +catch { +} + +$watcherCountsOk = $true +if ($sessionScopedCollectorsRequired) { + if ($afkExpected) { + $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-afk'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) + } + if ($windowExpected) { + $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-window'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) } } -$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port +$endpointProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($endpointCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$fileProcessOk = if (-not $fileOpsExpected -or -not $sessionScopedCollectorsRequired) { $true } else { (@($fileCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$browserProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($browserCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$sessionCollectorOk = (@($sessionCollectorProcesses).Count -eq 1) + $result = [ordered]@{ generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') configPath = $ConfigPath serverUrl = $serverUrl + apiBase = $apiBase + awHostname = $awHostname installRoot = $installRoot stateRoot = $stateRoot + timing = [ordered]@{ + pollSeconds = [int]$pollSeconds + pulseSeconds = [int]$pulseSeconds + freshnessSeconds = [int]$freshnessSeconds + sessionFreshnessSeconds = [int]$sessionFreshnessSeconds + transportStaleSeconds = [int]$transportStaleSeconds + } files = [ordered]@{ required = $requiredFiles missing = $missingFiles @@ -96,22 +467,76 @@ $result = [ordered]@{ } tasks = [ordered]@{ list = $tasks - ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present })) + ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled })) } processes = [ordered]@{ - expected = $processNames - list = @($runningProcesses) + sessionBoundUsers = $sessionBoundUsers + sessionScopedExpectedCount = [int]$sessionScopedExpectedCount + watchers = @($runningWatchers) + watcherDuplicates = @($watcherDuplicates) sessionCollectors = @($sessionCollectorProcesses) + sessionCollectorDuplicates = @($sessionCollectorDuplicates) + browserCollectors = @($browserCollectorProcesses) + browserCollectorDuplicates = @($browserCollectorDuplicates) + endpointCollectors = @($endpointCollectorProcesses) + endpointCollectorDuplicates = @($endpointCollectorDuplicates) + fileCollectors = @($fileCollectorProcesses) + fileCollectorDuplicates = @($fileCollectorDuplicates) ok = [bool]( - ( - ($processNames.Count -eq 0) -or - (($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count) - ) -and - ($sessionCollectorProcesses.Count -ge 1) + $watcherCountsOk -and + $sessionCollectorOk -and + $browserProcessOk -and + $endpointProcessOk -and + $fileProcessOk -and + ($watcherDuplicates.Count -eq 0) -and + ($sessionCollectorDuplicates.Count -eq 0) -and + ($browserCollectorDuplicates.Count -eq 0) -and + ($endpointCollectorDuplicates.Count -eq 0) -and + ($fileCollectorDuplicates.Count -eq 0) + ) + } + buckets = [ordered]@{ + list = @($bucketChecks) + ok = [bool](-not ($bucketChecks | Where-Object { -not $_.ok })) + } + queues = [ordered]@{ + list = @($queueChecks) + ok = [bool](-not ($queueChecks | Where-Object { -not $_.ok })) + } + printTelemetry = [ordered]@{ + operationalLogEnabled = $printServiceOperationalEnabled + jobTitlePolicyEnabled = $printJobTitlePolicyEnabled + ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled) + } + forensics = [ordered]@{ + evtxExportRoot = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$config.forensics.evtxExportRoot } else { $null } + retentionDays = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$config.forensics.retentionDays } else { $null } + evtxChannels = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($config.forensics.evtxChannels) } else { @() } + ok = [bool]( + ($config.PSObject.Properties.Name -contains 'forensics') -and + ($config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') -and + ($config.forensics.PSObject.Properties.Name -contains 'retentionDays') -and + ($config.forensics.PSObject.Properties.Name -contains 'evtxChannels') -and + (@($config.forensics.evtxChannels).Count -gt 0) ) } } -$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok) +$result.summary = [ordered]@{ + failedSections = @( + 'files', 'tasks', 'processes', 'buckets', 'queues', 'printTelemetry', 'forensics' | + Where-Object { -not [bool]$result.$_.ok } + ) +} + +$result.overallOk = [bool]( + $result.files.ok -and + $result.tasks.ok -and + $result.processes.ok -and + $result.buckets.ok -and + $result.queues.ok -and + $result.printTelemetry.ok -and + $result.forensics.ok +) $result diff --git a/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 b/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 index 226a500..84fbd04 100644 --- a/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 @@ -1,20 +1,77 @@ param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$Hostname, - [int]$PollSeconds = 30 + [int]$PollSeconds = 0 ) +# Force UTF-8 for console I/O +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +try { [Console]::InputEncoding = [System.Text.Encoding]::UTF8 } catch {} Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' +$ErrorActionPreference = 'Continue' + +function Decode-Bytes-Auto { + param([byte[]]$Bytes) + if (-not $Bytes) { return '' } + + $candidates = @() + + # Try strict UTF8 first (detect invalid sequences) + try { + $utf8Strict = New-Object System.Text.UTF8Encoding($false,$true) + $txt = $utf8Strict.GetString($Bytes) + $candidates += @{enc='utf8'; text=$txt} + } + catch { + # invalid UTF8 sequences; ignore + } + + # Try CP866 and CP1251 + try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {} + try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {} + + # If nothing decoded yet, fallback to UTF8 permissive + if ($candidates.Count -eq 0) { + try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' } + } + + # Score decodings by count of Cyrillic letters; prefer highest + $best = $null; $bestScore = -1 + foreach ($c in $candidates) { + $t = $c.text + if (-not $t) { continue } + $score = 0 + try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 } + if ($score -gt $bestScore) { $best = $c; $bestScore = $score } + } + + if ($best -ne $null) { return $best.text } + + # Final fallback: first candidate text + return $candidates[0].text +} function Get-Config { param([string]$Path) - if (-not (Test-Path -LiteralPath $Path)) { - throw "Конфигурация не найдена: $Path" + throw "Config not found: $Path" + } + try { + $bytes = [System.IO.File]::ReadAllBytes($Path) + # Config is JSON. Prefer deterministic BOM-based decoding over heuristics. + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { + $text = [System.Text.Encoding]::Unicode.GetString($bytes) + } else { + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + } + $text = $text -replace '^\uFEFF', '' + return $text | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Failed to read config: $Path - $($_.Exception.Message)" } - - Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } function Invoke-AwJsonPost { @@ -22,9 +79,15 @@ function Invoke-AwJsonPost { [Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Json ) - - $bytes = [Text.Encoding]::UTF8.GetBytes($Json) - Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null + return $true + } + catch { + Write-Verbose "POST error: $($_.Exception.Message)" + return $false + } } function Ensure-Bucket { @@ -33,127 +96,202 @@ function Ensure-Bucket { [Parameter(Mandatory = $true)][string]$BucketId, [Parameter(Mandatory = $true)][string]$HostnameValue ) + try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" } - try { - Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null - return + $body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress + $attempts = 0 + while ($attempts -lt 3) { + $attempts++ + $ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + if ($ok) { return } + Start-Sleep -Seconds (2 * $attempts) } - catch { - } - - $body = @{ - client = 'aw-worktime-session-collector' - type = 'aw.worktime.session' - hostname = $HostnameValue - } | ConvertTo-Json -Compress - - Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null } catch { Write-Verbose "Ensure-Bucket final check failed: $BucketId" } } -function Get-SessionRecords { +function Run-QueryUser { + $tries = @( + @{File='cmd.exe';Args='/c query user'}, + @{File='cmd.exe';Args='/c quser'}, + @{File='query.exe';Args='user'}, + @{File='quser.exe';Args=''} + ) + foreach ($t in $tries) { + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $t.File + if ($t.Args) { $psi.Arguments = $t.Args } + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $proc = [System.Diagnostics.Process]::Start($psi) + $stream = $proc.StandardOutput.BaseStream + $ms = New-Object System.IO.MemoryStream + $buffer = New-Object byte[] 4096 + while (($read = $stream.Read($buffer,0,$buffer.Length)) -gt 0) { $ms.Write($buffer,0,$read) } + if (-not $proc.WaitForExit(8000)) { + try { $proc.Kill() } catch {} + continue + } + $bytes = $ms.ToArray() + + $text = Decode-Bytes-Auto -Bytes $bytes + if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } } + } + catch { + # try next + } + } + return @() +} + +function Parse-SessionLines { + param([string[]]$Lines) $records = @() + if (-not $Lines) { return $records } - try { - $lines = quser 2>$null - if (-not $lines) { - return @() + $startIndex = 0 + # NOTE: Keep this script ASCII-only to stay compatible with Windows PowerShell 5 + # when the file is UTF-8 without BOM. Avoid Cyrillic literals in regex patterns. + if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|UserName|USER)\b') { $startIndex = 1 } + + for ($i = $startIndex; $i -lt $Lines.Count; $i++) { + $line = ($Lines[$i] -replace '^\s*>', '').Trim() + if (-not $line) { continue } + + $parts = $line -split '\s+' + if ($parts.Count -lt 3) { continue } + $user = $parts[0] + $sess = '' + $id = -1 + $state = '' + + if ($parts.Count -ge 4 -and $parts[1] -match '^\d+$') { + $sess = '' + $id = [int]$parts[1] + $state = [string]$parts[2] + } + elseif ($parts.Count -ge 4 -and $parts[2] -match '^\d+$') { + $sess = [string]$parts[1] + $id = [int]$parts[2] + $state = [string]$parts[3] + } + else { + continue } - foreach ($line in ($lines | Select-Object -Skip 1)) { - $clean = ($line -replace '^\s*>?', '').Trim() - if (-not $clean) { - continue - } + if ($id -lt 0) { continue } - $parts = $clean -split '\s+' - if ($parts.Count -lt 4) { - continue - } - - $sessionName = '' - $sessionIdIndex = 2 - if ($parts[1] -match '^\d+$') { - $sessionIdIndex = 1 - } - else { - $sessionName = $parts[1] - } - - $sessionId = 0 - if ($parts[$sessionIdIndex] -match '^\d+$') { - $sessionId = [int]$parts[$sessionIdIndex] - } - - $records += [pscustomobject]@{ - username = $parts[0] - sessionName = $sessionName - sessionId = $sessionId - state = $parts[$sessionIdIndex + 1] - } - } + $records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state } } - catch { - } - return $records } function Test-SessionIsActive { - param([AllowNull()][string]$State) - if ([string]::IsNullOrWhiteSpace($State)) { return $false } + param([string]$State) + if (-not $State) { return $false } $s = $State.Trim().ToLowerInvariant() - return ($s -eq 'active') -or ($s -like 'актив*') + # Match English "active" and Russian "актив*" without embedding Cyrillic. + # "актив" = \u0430\u043A\u0442\u0438\u0432 + return ($s -match 'active') -or ($s -match '\u0430\u043a\u0442\u0438\u0432') } +function Get-CanonicalUserId { + param( + [pscustomobject]$Config, + [string]$HostnameValue, + [string]$Username + ) + + $normalizedUser = [string]$Username + if ([string]::IsNullOrWhiteSpace($normalizedUser)) { + return '' + } + + if ($Config -and $Config.PSObject.Properties.Name -contains 'userTasks' -and $Config.userTasks) { + foreach ($task in @($Config.userTasks)) { + try { + $taskUserId = [string]$task.userId + if ([string]::IsNullOrWhiteSpace($taskUserId)) { + continue + } + $parts = $taskUserId -split '\\', 2 + if ($parts.Count -eq 2 -and $parts[1].Equals($normalizedUser, [System.StringComparison]::OrdinalIgnoreCase)) { + return $taskUserId + } + } + catch { + } + } + } + + return "$HostnameValue\$normalizedUser" +} + +# Main $cfg = Get-Config -Path $ConfigPath -$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } -$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$hostValue = if ($Hostname -and $Hostname.Trim()) { $Hostname.Trim() } elseif ($cfg -and $cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } elseif ($cfg -and $cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME } +try { $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port } catch { throw 'Invalid server configuration in config file.' } + $bucketId = 'aw-worktime-sessions_' + $hostValue -$pulse = 120 -$sleepSec = if ($PollSeconds -gt 0) { - $PollSeconds -} -elseif ($cfg.collector -and $cfg.collector.pollSeconds) { - [int]$cfg.collector.pollSeconds -} -else { - 30 -} +$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 } +$pulse = [Math]::Max($sleepSec * 3, 30) Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue while ($true) { $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') - $records = Get-SessionRecords + try { + $lines = Run-QueryUser + $records = Parse-SessionLines -Lines $lines + } + catch { + Write-Verbose "Session parse error: $($_.Exception.Message)" + $records = @() + } + if (-not $records -or $records.Count -eq 0) { - $records = @([pscustomobject]@{ - username = $env:USERNAME - sessionName = '' - sessionId = (Get-Process -Id $PID).SessionId - state = 'Unknown' - }) + # Fallback sample: keep bucket alive even when query user output is unavailable + # in non-interactive/session-0 contexts. + $records = @( + [pscustomobject]@{ + username = [string]$env:USERNAME + sessionName = '' + sessionId = [int](Get-Process -Id $PID).SessionId + state = 'Unknown' + } + ) } foreach ($rec in $records) { - $payload = @{ + $canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username) + $payloadObj = [PSCustomObject]@{ timestamp = $now - duration = 0 - data = @{ + duration = $sleepSec + data = [PSCustomObject]@{ username = [string]$rec.username - userId = "$($env:USERDOMAIN)\$($rec.username)" + userId = $canonicalUserId sessionId = [int]$rec.sessionId sessionName = [string]$rec.sessionName state = [string]$rec.state - active = (Test-SessionIsActive -State ([string]$rec.state)) + active = Test-SessionIsActive -State ([string]$rec.state) + sampleSeconds = $sleepSec + pollSeconds = $sleepSec hostname = $hostValue source = 'worktime-session-collector' } - } | ConvertTo-Json -Depth 6 -Compress + } + + $payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress try { - Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + $ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" } } catch { + Write-Verbose "Heartbeat error: $($_.Exception.Message)" } } diff --git a/scripts/check_install_kit_vs_repo.sh b/scripts/check_install_kit_vs_repo.sh index 97d165d..9e39a2b 100755 --- a/scripts/check_install_kit_vs_repo.sh +++ b/scripts/check_install_kit_vs_repo.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" KIT_DIR="install-kit-awindows-20260427-211240" -python - <<'PY' +python3 - <<'PY' from pathlib import Path import hashlib @@ -21,11 +21,16 @@ def sha(path: Path) -> str: all_compared=[] mismatches=[] missing_in_repo=[] +allowed_kit_only_prefixes=('server-configs-' ,) +allowed_kit_only_files={'README-INSTALL-KIT.txt'} for kp in sorted(p for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'): rel=kp.relative_to(kit) rp=root/rel if not rp.exists(): + rel_str=str(rel) + if rel_str in allowed_kit_only_files or rel_str.startswith(allowed_kit_only_prefixes): + continue missing_in_repo.append(str(rel)) continue all_compared.append(str(rel)) diff --git a/scripts/prod_rollout.sh b/scripts/prod_rollout.sh index 1f9ebfd..61583f7 100644 --- a/scripts/prod_rollout.sh +++ b/scripts/prod_rollout.sh @@ -52,7 +52,7 @@ fi if [[ -t 0 ]]; then prompt_secret AW_SSH_PASSWORD "Enter SSH password for aw_server (root@10.10.10.13)" - prompt_secret AW_WINRM_PASSWORD "Enter WinRM password for aw_windows (192.168.100.21)" + prompt_secret AW_WINRM_PASSWORD "Enter WinRM password for aw_windows (192.168.100.18)" fi if [[ -z "${AW_SSH_PASSWORD:-}" || -z "${AW_WINRM_PASSWORD:-}" ]]; then diff --git a/scripts/rebuild_install_kit.sh b/scripts/rebuild_install_kit.sh new file mode 100644 index 0000000..d89ffb2 --- /dev/null +++ b/scripts/rebuild_install_kit.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +KIT_DIR="install-kit-awindows-20260427-211240" +ZIP_ARCHIVE="${KIT_DIR}.zip" +TAR_ARCHIVE="${KIT_DIR}.tar.gz" +SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.18" +OLD_SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.21" +TMP_SERVER_CONFIG_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_SERVER_CONFIG_DIR"' EXIT + +copy_file() { + local src="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + cp "$src" "$dest" +} + +sync_tree() { + local base="$1" + shift + for rel in "$@"; do + copy_file "$rel" "${KIT_DIR}/${rel}" + done +} + +if [[ -d "$OLD_SERVER_CONFIG_DIR" ]]; then + cp "$OLD_SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ +fi + +rm -rf "${KIT_DIR}/ansible" "${KIT_DIR}/aw-server" "${KIT_DIR}/windows" "${KIT_DIR}/server-configs-"* + +ansible_files=( + ansible/README.md + ansible/deploy_aw_pfsense_poller.yml + ansible/deploy_aw_server.yml + ansible/deploy_aw_windows.yml + ansible/group_vars/all.example.yml + ansible/group_vars/pfsense-poller.example.yml + ansible/group_vars/proxmox-matrix.example.yml + ansible/group_vars/proxmox.example.yml + ansible/group_vars/windows.example.yml + ansible/install_full_stack.yml + ansible/inventory.example.ini + ansible/provision_proxmox_ct_and_deploy_aw.yml + ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml + ansible/tasks/provision_ct_and_deploy_aw.yml +) + +aw_server_files=( + aw-server/activitywatch-server.service + aw-server/apply_webui_ru_patch.sh + aw-server/aw-host-groups.json + aw-server/aw-ru-patch.js + aw-server/aw-rus-healthd.py + aw-server/aw-rus-healthd.service + aw-server/aw-rus-healthd.timer + aw-server/aw-server.env.example + aw-server/aw-sw-cleanup.js + aw-server/aw-worktime-api.py + aw-server/aw-worktime-api.service + aw-server/aw-worktime-panel.js + aw-server/install_aw_server.sh + aw-server/settings/classes-worktime.json + aw-server/settings/views-default.json +) + +windows_files=( + windows/ActivityWatch.Windows.Common.psd1 + windows/ActivityWatch.Windows.Common.psm1 + windows/browser-domains-native-collector.ps1 + windows/deploy-domain-users.ps1 + windows/deploy-ensemble.ps1 + windows/deploy-single-user.ps1 + windows/dlp-endpoint-signals-collector.ps1 + windows/dlp-policy.example.json + windows/email-outbound-collector.ps1 + windows/hardening-recovery.ps1 + windows/migrate-awatch-rus-paths.ps1 + windows/validate-deployment.ps1 + windows/web-category-rules.example.json + windows/worktime-session-collector.ps1 +) + +sync_tree ansible "${ansible_files[@]}" +sync_tree aw-server "${aw_server_files[@]}" +sync_tree windows "${windows_files[@]}" + +mkdir -p "$SERVER_CONFIG_DIR" +if compgen -G "$TMP_SERVER_CONFIG_DIR/*.deployment-config.json" >/dev/null; then + cp "$TMP_SERVER_CONFIG_DIR"/*.deployment-config.json "$SERVER_CONFIG_DIR"/ +fi + +cat > "${KIT_DIR}/README-INSTALL-KIT.txt" <<'EOF' +ActivityWatch DetMir Windows Install Kit + +Includes: +- windows/* (deploy scripts, collectors, common module, configs/examples) +- ansible/* (Windows and AW server playbooks, examples, inventory, tasks) +- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) +- server-configs-192.168.100.18/* (working Windows/RDP config snapshots) + +Source: +- Local project snapshot at build time. +EOF + +python3 - <<'PY' +from pathlib import Path +import hashlib + +root = Path('install-kit-awindows-20260427-211240') +manifest = root / 'MANIFEST.txt' +files = sorted(p for p in root.rglob('*') if p.is_file() and p.name != 'MANIFEST.txt') +with manifest.open('w', encoding='utf-8') as handle: + for path in files: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + handle.write(f"{digest} {path.as_posix()}\n") +PY + +rm -f "$ZIP_ARCHIVE" "$TAR_ARCHIVE" +zip -rq "$ZIP_ARCHIVE" "$KIT_DIR" +tar -czf "$TAR_ARCHIVE" "$KIT_DIR" diff --git a/scripts/validate_install_kit.sh b/scripts/validate_install_kit.sh index f0ddc43..0c0e037 100755 --- a/scripts/validate_install_kit.sh +++ b/scripts/validate_install_kit.sh @@ -14,7 +14,7 @@ required_files=( "$KIT_DIR/README-INSTALL-KIT.txt" "$KIT_DIR/windows/deploy-ensemble.ps1" "$KIT_DIR/windows/validate-deployment.ps1" - "$KIT_DIR/ansible/deploy_aw_windows_phase2.yml" + "$KIT_DIR/ansible/deploy_aw_windows.yml" "$KIT_DIR/aw-server/install_aw_server.sh" ) @@ -27,7 +27,7 @@ echo "[2/4] Manifest checksum verification" sha256sum -c "$MANIFEST" >/dev/null echo "[3/4] Manifest completeness" -python - <<'PY' +python3 - <<'PY' from pathlib import Path import sys kit=Path('install-kit-awindows-20260427-211240') @@ -54,7 +54,7 @@ print(f'MANIFEST complete: {len(actual_set)} files tracked') PY echo "[4/4] Archive composition check" -python - <<'PY' +python3 - <<'PY' from pathlib import Path import tarfile, zipfile, sys kit_prefix='install-kit-awindows-20260427-211240/' diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 0d69fed..2f7f461 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -749,6 +749,33 @@ function New-LaunchLock { return `$lockPath } +function Get-SessionMarkerToken { + param([int]`$SessionId) + + try { + `$explorer = Get-Process -Name 'explorer' -ErrorAction SilentlyContinue | + Where-Object { `$_.SessionId -eq `$SessionId } | + Sort-Object StartTime | + Select-Object -First 1 + if (`$explorer -and `$explorer.StartTime) { + return `$explorer.StartTime.ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + } + } + catch { + } + + try { + `$currentProcess = Get-Process -Id `$PID -ErrorAction Stop + if (`$currentProcess.StartTime) { + return `$currentProcess.StartTime.ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + } + } + catch { + } + + return [string]`$SessionId +} + function Invoke-AwJsonPost { param( [Parameter(Mandatory = `$true)][string]`$Uri, @@ -864,7 +891,8 @@ function Send-LogonMarkerIfNeeded { return } - `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + `$sessionMarkerToken = Get-SessionMarkerToken -SessionId `$SessionId + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}-{2}.marker" -f `$env:USERNAME, `$SessionId, `$sessionMarkerToken) if (Test-Path -LiteralPath `$markerFile) { return } diff --git a/windows/audit-cryptopro.ps1 b/windows/audit-cryptopro.ps1 new file mode 100644 index 0000000..7dab628 --- /dev/null +++ b/windows/audit-cryptopro.ps1 @@ -0,0 +1,338 @@ +[CmdletBinding()] +param( + [string[]]$ExpectedUsers = @(), + [switch]$IncludeUnexpectedProfiles +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-UninstallEntries { + param( + [Parameter(Mandatory = $true)] + [string]$NamePattern + ) + + $paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + + return @( + Get-ItemProperty $paths -ErrorAction SilentlyContinue | + Where-Object { + $_.PSObject.Properties.Name -contains 'DisplayName' -and + -not [string]::IsNullOrWhiteSpace([string]$_.DisplayName) -and + ([string]$_.DisplayName -match $NamePattern) + } | + Select-Object DisplayName, DisplayVersion, Publisher + ) +} + +function Get-CryptoProToolPath { + param( + [Parameter(Mandatory = $true)] + [string]$LeafName + ) + + $candidate = Join-Path 'C:\Program Files\Crypto Pro\CSP' $LeafName + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + return $null +} + +function Get-CryptoProStoreMapForCurrentUser { + param( + [string]$CertmgrPath + ) + + $map = @{} + if ([string]::IsNullOrWhiteSpace($CertmgrPath) -or -not (Test-Path -LiteralPath $CertmgrPath)) { + return $map + } + + $raw = & $CertmgrPath -list -store uMy 2>&1 + if ($LASTEXITCODE -ne 0) { + return $map + } + + $current = @{} + foreach ($line in @($raw | ForEach-Object { [string]$_ })) { + $trimmed = $line.Trim() + if ($trimmed -match '^SHA1 Thumbprint\s*:\s*(.+)$') { + $current.thumbprint = $Matches[1].Trim().ToUpperInvariant() + continue + } + if ($trimmed -match '^Embedded License\s*:\s*(.+)$') { + $current.embeddedLicense = $Matches[1].Trim() + continue + } + if ($trimmed -match '^Container\s*:\s*(.+)$') { + $current.container = $Matches[1].Trim() + continue + } + if ($trimmed -match '^\[ErrorCode:') { + if ($current.ContainsKey('thumbprint')) { + $map[$current.thumbprint] = [pscustomobject]@{ + embeddedLicense = if ($current.ContainsKey('embeddedLicense')) { [string]$current.embeddedLicense } else { $null } + container = if ($current.ContainsKey('container')) { [string]$current.container } else { $null } + } + } + $current = @{} + } + } + + return $map +} + +function Test-CertificateEmbeddedLicense { + param( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$CspTestPath + ) + + $result = [ordered]@{ + ok = $null + status = $null + error = $null + } + + if ([string]::IsNullOrWhiteSpace($CspTestPath) -or -not (Test-Path -LiteralPath $CspTestPath)) { + $result.error = 'csptest_missing' + return [pscustomobject]$result + } + + $tempPath = Join-Path $env:TEMP ("cryptopro-audit-{0}.cer" -f ([Guid]::NewGuid().ToString('N'))) + try { + Export-Certificate -Cert $Certificate -FilePath $tempPath -Force | Out-Null + $raw = & $CspTestPath -certlic -check -certfile $tempPath 2>&1 + if ($LASTEXITCODE -eq 0) { + $licenseLine = @($raw | Where-Object { [string]$_ -match '^License:\s*' } | Select-Object -First 1) + if ($licenseLine.Count -gt 0) { + $statusText = (($licenseLine[0] -replace '^License:\s*', '').Trim()) + $result.status = $statusText + $result.ok = ($statusText -match '^Good license\b') + } + else { + $result.ok = $false + $result.error = 'license_line_missing' + } + } + else { + $result.ok = $false + $result.error = ('csptest_exit_{0}' -f $LASTEXITCODE) + } + } + catch { + $result.ok = $false + $result.error = $_.Exception.Message + } + finally { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + } + + return [pscustomobject]$result +} + +function Get-ProfileMap { + $profileMap = @{} + foreach ($profile in @(Get-CimInstance Win32_UserProfile | Where-Object { -not $_.Special })) { + $leaf = Split-Path -Leaf ([string]$profile.LocalPath) + if ([string]::IsNullOrWhiteSpace($leaf)) { continue } + $profileMap[$leaf.ToUpperInvariant()] = [pscustomobject]@{ + user = $leaf + sid = [string]$profile.SID + localPath = [string]$profile.LocalPath + loaded = [bool]$profile.Loaded + } + } + return $profileMap +} + +function Get-UserAuditRows { + param( + [Parameter(Mandatory = $true)] + [string]$RequestedUser, + [pscustomobject]$Profile, + [string]$CspTestPath, + [hashtable]$CurrentUserStoreMap, + [bool]$ExpectedSigner + ) + + if ($null -eq $Profile) { + return @( + [pscustomobject]@{ + requestedUser = $RequestedUser + profileUser = $null + profilePath = $null + profileLoaded = $false + sid = $null + storeFilePath = $null + thumbprint = $null + subject = $null + issuer = $null + notAfterUtc = $null + hasPrivateKey = $false + embeddedLicenseOk = $null + embeddedLicenseStatus = $null + container = $null + expectedSigner = $ExpectedSigner + actionNeeded = 'profile_missing_or_no_login' + } + ) + } + + $certDir = Join-Path $Profile.localPath 'AppData\Roaming\Microsoft\SystemCertificates\My\Certificates' + $files = @() + if (Test-Path -LiteralPath $certDir) { + $files = @(Get-ChildItem -LiteralPath $certDir -File -ErrorAction SilentlyContinue) + } + if ($files.Count -eq 0) { + return @( + [pscustomobject]@{ + requestedUser = $RequestedUser + profileUser = $Profile.user + profilePath = $Profile.localPath + profileLoaded = [bool]$Profile.loaded + sid = $Profile.sid + storeFilePath = $certDir + thumbprint = $null + subject = $null + issuer = $null + notAfterUtc = $null + hasPrivateKey = $false + embeddedLicenseOk = $null + embeddedLicenseStatus = $null + container = $null + expectedSigner = $ExpectedSigner + actionNeeded = 'certificate_missing' + } + ) + } + + $rows = @() + foreach ($file in $files) { + try { + $cert = Get-PfxCertificate -FilePath $file.FullName + $thumbprint = [string]$cert.Thumbprint + $license = Test-CertificateEmbeddedLicense -Certificate $cert -CspTestPath $CspTestPath + $storeMeta = $null + if ($CurrentUserStoreMap.ContainsKey($thumbprint.ToUpperInvariant())) { + $storeMeta = $CurrentUserStoreMap[$thumbprint.ToUpperInvariant()] + } + + $actionNeeded = 'manual_review' + if ($cert.NotAfter -lt (Get-Date)) { + $actionNeeded = 'renew_certificate' + } + elseif (-not [bool]$cert.HasPrivateKey) { + $actionNeeded = 'attach_private_key_or_token' + } + elseif ($license.ok -eq $true) { + $actionNeeded = 'ready' + } + elseif ($license.ok -eq $false) { + $actionNeeded = 'embedded_license_missing_or_invalid' + } + + $rows += [pscustomobject]@{ + requestedUser = $RequestedUser + profileUser = $Profile.user + profilePath = $Profile.localPath + profileLoaded = [bool]$Profile.loaded + sid = $Profile.sid + storeFilePath = $file.FullName + thumbprint = $thumbprint + subject = [string]$cert.Subject + issuer = [string]$cert.Issuer + notAfterUtc = $cert.NotAfter.ToUniversalTime().ToString('o') + hasPrivateKey = [bool]$cert.HasPrivateKey + embeddedLicenseOk = $license.ok + embeddedLicenseStatus = $license.status + container = if ($null -ne $storeMeta) { [string]$storeMeta.container } else { $null } + expectedSigner = $ExpectedSigner + actionNeeded = $actionNeeded + } + } + catch { + $rows += [pscustomobject]@{ + requestedUser = $RequestedUser + profileUser = $Profile.user + profilePath = $Profile.localPath + profileLoaded = [bool]$Profile.loaded + sid = $Profile.sid + storeFilePath = $file.FullName + thumbprint = $null + subject = $null + issuer = $null + notAfterUtc = $null + hasPrivateKey = $false + embeddedLicenseOk = $null + embeddedLicenseStatus = $null + container = $null + expectedSigner = $ExpectedSigner + actionNeeded = ('certificate_parse_error: {0}' -f $_.Exception.Message) + } + } + } + + return $rows +} + +$cspEntries = @(Get-UninstallEntries -NamePattern 'CryptoPro|КриптоПро|Крипто') +$cspMain = @($cspEntries | Where-Object { $_.DisplayName -match 'CSP' } | Select-Object -First 1) +$browserPlugin = @($cspEntries | Where-Object { $_.DisplayName -match 'Browser' } | Select-Object -First 1) +$cspTestPath = Get-CryptoProToolPath -LeafName 'csptest.exe' +$certmgrPath = Get-CryptoProToolPath -LeafName 'certmgr.exe' +$currentUserStoreMap = Get-CryptoProStoreMapForCurrentUser -CertmgrPath $certmgrPath +$profileMap = Get-ProfileMap + +$targets = New-Object 'System.Collections.Generic.List[string]' +foreach ($expected in @($ExpectedUsers)) { + if (-not [string]::IsNullOrWhiteSpace([string]$expected)) { + $targets.Add([string]$expected) + } +} +if ($IncludeUnexpectedProfiles.IsPresent -or $targets.Count -eq 0) { + foreach ($profileKey in @($profileMap.Keys | Sort-Object)) { + $targets.Add([string]$profileMap[$profileKey].user) + } +} + +$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) +$rows = @() +foreach ($target in $targets) { + if (-not $seen.Add($target)) { continue } + $key = $target.ToUpperInvariant() + $profile = if ($profileMap.ContainsKey($key)) { $profileMap[$key] } else { $null } + $rows += Get-UserAuditRows -RequestedUser $target -Profile $profile -CspTestPath $cspTestPath -CurrentUserStoreMap $currentUserStoreMap -ExpectedSigner:([bool]($ExpectedUsers -contains $target)) +} + +$summary = [ordered]@{ + expectedSignerCount = [int](@($ExpectedUsers | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }).Count) + readyCount = [int](@($rows | Where-Object { $_.actionNeeded -eq 'ready' }).Count) + missingProfileCount = [int](@($rows | Where-Object { $_.actionNeeded -eq 'profile_missing_or_no_login' }).Count) + missingCertificateCount = [int](@($rows | Where-Object { $_.actionNeeded -eq 'certificate_missing' }).Count) + noPrivateKeyCount = [int](@($rows | Where-Object { $_.actionNeeded -eq 'attach_private_key_or_token' }).Count) + licenseProblemCount = [int](@($rows | Where-Object { $_.actionNeeded -eq 'embedded_license_missing_or_invalid' }).Count) + renewalNeededCount = [int](@($rows | Where-Object { $_.actionNeeded -eq 'renew_certificate' }).Count) +} + +[pscustomobject]@{ + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + computerName = [string]$env:COMPUTERNAME + currentUser = [string]$env:USERNAME + cryptopro = [ordered]@{ + cspInstalled = ($null -ne $cspMain) + cspVersion = if ($null -ne $cspMain) { [string]$cspMain.DisplayVersion } else { $null } + browserPluginInstalled = ($null -ne $browserPlugin) + browserPluginVersion = if ($null -ne $browserPlugin) { [string]$browserPlugin.DisplayVersion } else { $null } + cspTestPath = $cspTestPath + certmgrPath = $certmgrPath + } + expectedUsers = @($ExpectedUsers) + signers = @($rows) + summary = $summary +}