fix(aw): harden collectors and grafana exports

This commit is contained in:
igor04091968
2026-05-28 10:54:46 +03:00
parent 524ab33f63
commit cc9e4a0669
29 changed files with 1181 additions and 131 deletions
+109 -2
View File
@@ -94,6 +94,89 @@
- "{{ aw_rus_health_validation_dir }}" - "{{ aw_rus_health_validation_dir }}"
- "{{ aw_server_log_dir }}" - "{{ aw_server_log_dir }}"
- name: Установить prune script для локального state
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-prune-local-state.sh"
dest: /usr/local/bin/aw-prune-local-state.sh
owner: root
group: root
mode: "0755"
- name: Ограничить рост journald на aw-server
ansible.builtin.copy:
dest: /etc/systemd/journald.conf.d/aw-rus-retention.conf
owner: root
group: root
mode: "0644"
content: |
[Journal]
SystemMaxUse={{ aw_server_journal_system_max_use }}
RuntimeMaxUse={{ aw_server_journal_runtime_max_use }}
SystemKeepFree={{ aw_server_journal_system_keep_free }}
register: aw_journald_dropin
- name: Установить systemd service prune локального state
ansible.builtin.copy:
dest: /etc/systemd/system/aw-prune-local-state.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Prune ActivityWatch local backups and temp state
[Service]
Type=oneshot
Environment=AW_DATA_DIR={{ aw_server_data_dir }}
Environment=AW_BACKUP_RETENTION_DAYS={{ aw_server_backup_retention_days }}
Environment=AW_BACKUP_KEEP_LAST_DB={{ aw_server_backup_keep_last_db }}
Environment=AW_BACKUP_KEEP_LAST_JSON={{ aw_server_backup_keep_last_json }}
ExecStart=/usr/local/bin/aw-prune-local-state.sh
- name: Установить systemd timer prune локального state
ansible.builtin.copy:
dest: /etc/systemd/system/aw-prune-local-state.timer
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Daily prune of ActivityWatch local backups and temp state
[Timer]
OnCalendar=*-*-* 04:40:00
Persistent=true
[Install]
WantedBy=timers.target
- name: Перечитать systemd после retention unit/drop-in
ansible.builtin.systemd:
daemon_reload: true
- name: Включить и запустить timer prune локального state
ansible.builtin.systemd:
name: aw-prune-local-state.timer
enabled: true
state: started
- name: Применить journald retention без простоя
ansible.builtin.command:
argv:
- systemctl
- restart
- systemd-journald
when: aw_journald_dropin.changed
failed_when: false
- name: Сжать существующий journald до нового лимита
ansible.builtin.command:
argv:
- journalctl
- --vacuum-size={{ aw_server_journal_system_max_use }}
changed_when: true
failed_when: false
- name: (Check mode) Пропустить установку релиза ActivityWatch - name: (Check mode) Пропустить установку релиза ActivityWatch
ansible.builtin.debug: ansible.builtin.debug:
msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped." msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped."
@@ -115,6 +198,11 @@
remote_src: true remote_src: true
extra_opts: ["-o"] extra_opts: ["-o"]
- name: Удалить временный архив ActivityWatch после распаковки
ansible.builtin.file:
path: "{{ aw_archive_path }}"
state: absent
- name: Найти распакованный каталог ActivityWatch - name: Найти распакованный каталог ActivityWatch
ansible.builtin.find: ansible.builtin.find:
paths: "{{ aw_release_dir }}" paths: "{{ aw_release_dir }}"
@@ -288,6 +376,22 @@
dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh
mode: "0755" mode: "0755"
- name: Проверить Influx token для AW worktime exporter
ansible.builtin.assert:
that:
- aw_worktime_influx_token is defined
- aw_worktime_influx_token | length > 0
fail_msg: "aw_worktime_influx_enabled=true, но aw_worktime_influx_token пуст. Exporter будет падать и Grafana не получит worktime-ряды."
when: aw_worktime_influx_enabled | default(false) | bool
- name: Проверить Influx token для AW DLP exporter
ansible.builtin.assert:
that:
- aw_dlp_influx_token is defined
- aw_dlp_influx_token | length > 0
fail_msg: "aw_dlp_influx_enabled=true, но aw_dlp_influx_token пуст. Exporter будет падать и Grafana не получит DLP-ряды."
when: aw_dlp_influx_enabled | default(false) | bool
- name: Записать /etc/activitywatch/aw-server.env перед хотфиксами - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами
ansible.builtin.copy: ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env dest: /etc/activitywatch/aw-server.env
@@ -922,7 +1026,6 @@
ansible.builtin.systemd: ansible.builtin.systemd:
name: aw-worktime-influx-exporter.service name: aw-worktime-influx-exporter.service
state: started state: started
failed_when: false
when: aw_worktime_influx_enabled | default(false) | bool when: aw_worktime_influx_enabled | default(false) | bool
- name: Включить и перезапустить AW DLP Influx exporter timer - name: Включить и перезапустить AW DLP Influx exporter timer
@@ -936,7 +1039,6 @@
ansible.builtin.systemd: ansible.builtin.systemd:
name: aw-dlp-influx-exporter.service name: aw-dlp-influx-exporter.service
state: started state: started
failed_when: false
when: aw_dlp_influx_enabled | default(false) | bool when: aw_dlp_influx_enabled | default(false) | bool
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
@@ -1514,6 +1616,11 @@
remote_src: true remote_src: true
creates: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}" creates: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}"
- name: Удалить временный архив Hayabusa после распаковки
ansible.builtin.file:
path: "{{ aw_hayabusa_archive_path }}"
state: absent
- name: Нормализовать права release Hayabusa - name: Нормализовать права release Hayabusa
ansible.builtin.file: ansible.builtin.file:
path: "{{ aw_hayabusa_release_dir }}" path: "{{ aw_hayabusa_release_dir }}"
+6
View File
@@ -14,6 +14,7 @@
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip" aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
aw_windows_package_zip_path: "" aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025" aw_windows_domain: "SHARKON2025"
aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users: aw_windows_users:
- Администратор - Администратор
- user1 - user1
@@ -49,6 +50,7 @@
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports"
aw_windows_logon_marker_enabled: true aw_windows_logon_marker_enabled: true
aw_windows_process_events_enabled: true
aw_windows_skip_hardening: false aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" 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_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
@@ -137,6 +139,8 @@
- aw_windows_server_port is defined - aw_windows_server_port is defined
- aw_windows_server_scheme is defined - aw_windows_server_scheme is defined
- aw_windows_domain is defined - aw_windows_domain is defined
- aw_windows_builtin_administrator_name is defined
- aw_windows_builtin_administrator_name | length > 0
- aw_windows_users_effective | length > 0 - aw_windows_users_effective | length > 0
- aw_windows_install_root is defined - aw_windows_install_root is defined
- aw_windows_state_root is defined - aw_windows_state_root is defined
@@ -228,6 +232,7 @@
ansible.windows.win_powershell: ansible.windows.win_powershell:
script: | script: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME = "{{ aw_windows_builtin_administrator_name }}"
$params = @{ $params = @{
ServerScheme = "{{ aw_windows_server_scheme }}" ServerScheme = "{{ aw_windows_server_scheme }}"
ServerHost = "{{ aw_windows_server_host_effective }}" ServerHost = "{{ aw_windows_server_host_effective }}"
@@ -247,6 +252,7 @@
EvtxExportRoot = "{{ aw_windows_forensics_root }}" EvtxExportRoot = "{{ aw_windows_forensics_root }}"
EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }} EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }}
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }} LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
ProcessEventsEnabled = {{ '$true' if (aw_windows_process_events_enabled | bool) else '$false' }}
PolicyMode = "{{ aw_windows_policy_mode }}" PolicyMode = "{{ aw_windows_policy_mode }}"
PolicyEngineEnabled = {{ '$true' if (aw_windows_policy_engine_enabled | bool) else '$false' }} PolicyEngineEnabled = {{ '$true' if (aw_windows_policy_engine_enabled | bool) else '$false' }}
PolicyEngineHost = "{{ aw_windows_policy_engine_host_effective }}" PolicyEngineHost = "{{ aw_windows_policy_engine_host_effective }}"
+14 -12
View File
@@ -5,9 +5,10 @@
gather_facts: true gather_facts: true
vars: vars:
tsj_bot_user: "codex" tsj_bot_runtime_root: "/opt/infra-admin"
tsj_bot_user: "root"
tsj_bot_group: "admin" tsj_bot_group: "admin"
tsj_bot_root: "/home/codex/infra-admin/tsj-bot" tsj_bot_root: "{{ tsj_bot_runtime_root }}/tsj-bot"
tsj_bot_script_name: "tsj_guardian_bot.py" tsj_bot_script_name: "tsj_guardian_bot.py"
tsj_bot_script_dest: "{{ tsj_bot_root }}/{{ tsj_bot_script_name }}" tsj_bot_script_dest: "{{ tsj_bot_root }}/{{ tsj_bot_script_name }}"
tsj_bot_source_local_path: "{{ aw_repo_root }}/proxmox/tsj_guardian_bot.py" tsj_bot_source_local_path: "{{ aw_repo_root }}/proxmox/tsj_guardian_bot.py"
@@ -16,8 +17,8 @@
tsj_bot_openvpn_helper_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_openvpn_helper_name }}" tsj_bot_openvpn_helper_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_openvpn_helper_name }}"
tsj_bot_service_name: "tsj-guardian-bot.service" tsj_bot_service_name: "tsj-guardian-bot.service"
tsj_bot_env_path: "{{ tsj_bot_root }}/.env" tsj_bot_env_path: "{{ tsj_bot_root }}/.env"
tsj_bot_state_dir: "/home/codex/infra-admin/.state" tsj_bot_state_dir: "{{ tsj_bot_runtime_root }}/.state"
tsj_bot_logs_dir: "/home/codex/infra-admin/logs" tsj_bot_logs_dir: "{{ tsj_bot_runtime_root }}/logs"
tsj_bot_default_chat_id: "{{ telegram_default_chat_id | default(telegram_allowed_chat_ids.split(',')[0]) }}" tsj_bot_default_chat_id: "{{ telegram_default_chat_id | default(telegram_allowed_chat_ids.split(',')[0]) }}"
pre_tasks: pre_tasks:
@@ -130,15 +131,16 @@
NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/api/0/info') }} NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/api/0/info') }}
NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }} NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }}
NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }} NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }}
CHECK_SCRIPT={{ tsj_bot_check_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --check') }} INFRA_ADMIN_ROOT={{ tsj_bot_runtime_root }}
HEAL_SCRIPT={{ tsj_bot_heal_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }} CHECK_SCRIPT={{ tsj_bot_check_script | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --check') }}
HEAL_SCRIPT={{ tsj_bot_heal_script | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }}
FS_WARN_PCT={{ tsj_bot_fs_warn_pct | default(85) }} FS_WARN_PCT={{ tsj_bot_fs_warn_pct | default(85) }}
FS_CRIT_PCT={{ tsj_bot_fs_crit_pct | default(92) }} FS_CRIT_PCT={{ tsj_bot_fs_crit_pct | default(92) }}
FS_TARGETS={{ tsj_bot_fs_targets | default('host,200,201,202,203') }} FS_TARGETS={{ tsj_bot_fs_targets | default('host,200,201,202,203') }}
FS_EXCLUDE_TYPES={{ tsj_bot_fs_exclude_types | default('tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs') }} FS_EXCLUDE_TYPES={{ tsj_bot_fs_exclude_types | default('tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs') }}
STATE_FILE={{ tsj_bot_state_file | default('/home/codex/infra-admin/.state/tsj_guardian_state.json') }} STATE_FILE={{ tsj_bot_state_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_state.json') }}
LOG_FILE={{ tsj_bot_log_file | default('/home/codex/infra-admin/logs/tsj_guardian_bot.log') }} LOG_FILE={{ tsj_bot_log_file | default(tsj_bot_runtime_root + '/logs/tsj_guardian_bot.log') }}
HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default('/home/codex/infra-admin/.state/tsj_guardian_heartbeat') }} HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_heartbeat') }}
CHECK_INTERVAL_SEC={{ tsj_bot_check_interval_sec | default(60) }} CHECK_INTERVAL_SEC={{ tsj_bot_check_interval_sec | default(60) }}
OPERATOR_TIMEOUT_SEC={{ tsj_bot_operator_timeout_sec | default(900) }} OPERATOR_TIMEOUT_SEC={{ tsj_bot_operator_timeout_sec | default(900) }}
RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }} RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }}
@@ -170,8 +172,8 @@
OPENVPN_EXPIRY_WARN_TIMEOUT_SEC={{ tsj_bot_openvpn_expiry_warn_timeout_sec | default(120) }} OPENVPN_EXPIRY_WARN_TIMEOUT_SEC={{ tsj_bot_openvpn_expiry_warn_timeout_sec | default(120) }}
OPENVPN_EXPIRY_WARN_INTERVAL_SEC={{ tsj_bot_openvpn_expiry_warn_interval_sec | default(21600) }} OPENVPN_EXPIRY_WARN_INTERVAL_SEC={{ tsj_bot_openvpn_expiry_warn_interval_sec | default(21600) }}
PFSENSE_MCP_BEARER={{ tsj_bot_pfsense_mcp_bearer | default(pfsense_mcp_bearer | default('')) }} PFSENSE_MCP_BEARER={{ tsj_bot_pfsense_mcp_bearer | default(pfsense_mcp_bearer | default('')) }}
SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }} SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }}
UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py') }} UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 ' + tsj_bot_runtime_root + '/scripts/proxmox_lxc_critical_updates.py') }}
UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }} UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }}
AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }} AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }}
AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}
@@ -257,7 +259,7 @@
[Service] [Service]
Type=simple Type=simple
User=root User=root
WorkingDirectory=/home/codex/infra-admin WorkingDirectory={{ tsj_bot_runtime_root }}
EnvironmentFile={{ tsj_bot_env_path }} EnvironmentFile={{ tsj_bot_env_path }}
ExecStart=/usr/bin/python3 {{ tsj_bot_script_dest }} ExecStart=/usr/bin/python3 {{ tsj_bot_script_dest }}
Restart=always Restart=always
+5 -2
View File
@@ -12,21 +12,24 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa
aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}" aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}"
aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610" aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610"
aw_worktime_timezone: "Europe/Moscow" aw_worktime_timezone: "Europe/Moscow"
aw_worktime_influx_enabled: false aw_worktime_influx_enabled: true
aw_worktime_influx_url: "http://10.10.10.10:8086" aw_worktime_influx_url: "http://10.10.10.10:8086"
aw_worktime_influx_org: "proxmox" aw_worktime_influx_org: "proxmox"
aw_worktime_influx_bucket: "aw_metrics" aw_worktime_influx_bucket: "aw_metrics"
aw_worktime_influx_hosts: "SHARKON2025" aw_worktime_influx_hosts: "SHARKON2025"
aw_worktime_influx_days: "today,yesterday" aw_worktime_influx_days: "today,yesterday"
aw_dlp_influx_enabled: false aw_worktime_influx_token: "{{ lookup('env', 'AW_WORKTIME_INFLUX_TOKEN') }}"
aw_dlp_influx_enabled: true
aw_dlp_influx_url: "http://10.10.10.10:8086" aw_dlp_influx_url: "http://10.10.10.10:8086"
aw_dlp_influx_org: "proxmox" aw_dlp_influx_org: "proxmox"
aw_dlp_influx_bucket: "aw_metrics" aw_dlp_influx_bucket: "aw_metrics"
aw_dlp_influx_hosts: "SHARKON2025" aw_dlp_influx_hosts: "SHARKON2025"
aw_dlp_influx_lookback_days: 30 aw_dlp_influx_lookback_days: 30
aw_dlp_influx_event_limit: 2000 aw_dlp_influx_event_limit: 2000
aw_dlp_influx_token: "{{ lookup('env', 'AW_DLP_INFLUX_TOKEN') }}"
aw_monitored_windows_host: "192.168.100.18" aw_monitored_windows_host: "192.168.100.18"
aw_monitored_windows_hostname: "SHARKON2025" aw_monitored_windows_hostname: "SHARKON2025"
aw_rus_health_worktime_api_base: "http://127.0.0.1:5610"
aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health"
aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation"
aw_hayabusa_auto_case_enabled: true aw_hayabusa_auto_case_enabled: true
+6
View File
@@ -30,6 +30,12 @@ aw_hayabusa_staging_dir: "{{ aw_hayabusa_inbox_dir }}/staging"
aw_hayabusa_archive_packages_dir: "{{ aw_hayabusa_archive_dir }}/packages" aw_hayabusa_archive_packages_dir: "{{ aw_hayabusa_archive_dir }}/packages"
aw_hayabusa_archive_extracted_dir: "{{ aw_hayabusa_archive_dir }}/extracted" aw_hayabusa_archive_extracted_dir: "{{ aw_hayabusa_archive_dir }}/extracted"
aw_hayabusa_logs_dir: "{{ aw_hayabusa_state_dir }}/logs" aw_hayabusa_logs_dir: "{{ aw_hayabusa_state_dir }}/logs"
aw_server_backup_retention_days: 7
aw_server_backup_keep_last_db: 2
aw_server_backup_keep_last_json: 2
aw_server_journal_system_max_use: "100M"
aw_server_journal_runtime_max_use: "50M"
aw_server_journal_system_keep_free: "500M"
aw_dlp_policy_engine_enabled: true aw_dlp_policy_engine_enabled: true
aw_dlp_policy_engine_bind_host: "0.0.0.0" aw_dlp_policy_engine_bind_host: "0.0.0.0"
aw_dlp_policy_engine_port: 5601 aw_dlp_policy_engine_port: 5601
+2
View File
@@ -15,6 +15,7 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases
aw_windows_package_zip_path: "" aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025" aw_windows_domain: "SHARKON2025"
aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users: aw_windows_users:
- Администратор - Администратор
- user1 - user1
@@ -58,6 +59,7 @@ aw_windows_evtx_channels:
- Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational
- Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational
aw_windows_logon_marker_enabled: true aw_windows_logon_marker_enabled: true
aw_windows_process_events_enabled: true
aw_windows_skip_hardening: false aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
+3 -2
View File
@@ -4,6 +4,7 @@ telegram_default_chat_id: 123456789
# Path on controller (this machine) to source bot script for deployment. # Path on controller (this machine) to source bot script for deployment.
tsj_bot_source_local_path: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py" tsj_bot_source_local_path: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py"
tsj_bot_runtime_root: "/opt/infra-admin"
# Optional bot tuning # Optional bot tuning
tsj_bot_check_interval_sec: 60 tsj_bot_check_interval_sec: 60
@@ -37,8 +38,8 @@ tsj_bot_openvpn_expiry_warn_enabled: "false"
tsj_bot_openvpn_expiry_warn_days: 30 tsj_bot_openvpn_expiry_warn_days: 30
tsj_bot_openvpn_expiry_warn_timeout_sec: 120 tsj_bot_openvpn_expiry_warn_timeout_sec: 120
tsj_bot_openvpn_expiry_warn_interval_sec: 21600 tsj_bot_openvpn_expiry_warn_interval_sec: 21600
tsj_bot_server_fallback_commands: "/home/codex/infra-admin/scripts/system_self_support.sh --heal" tsj_bot_server_fallback_commands: "/opt/infra-admin/scripts/system_self_support.sh --heal"
tsj_bot_updates_script: "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py" tsj_bot_updates_script: "/usr/bin/python3 /opt/infra-admin/scripts/proxmox_lxc_critical_updates.py"
tsj_bot_update_targets: "auto" tsj_bot_update_targets: "auto"
tsj_bot_pfsense_mcp_bearer: "CHANGE_ME" tsj_bot_pfsense_mcp_bearer: "CHANGE_ME"
+5
View File
@@ -13,6 +13,10 @@ aw_windows_package_version: "v0.13.2"
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip" aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
aw_windows_package_zip_path: "" aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025" aw_windows_domain: "SHARKON2025"
# Localized name of the built-in local Administrator account (SID ending in -500).
# On the current Russian Windows host this must stay "Администратор";
# do not replace it with "Administrator" unless the target OS account is actually named that way.
aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users: aw_windows_users:
- Администратор - Администратор
- user1 - user1
@@ -46,6 +50,7 @@ aw_windows_evtx_channels:
- Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational
- Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational
aw_windows_logon_marker_enabled: true aw_windows_logon_marker_enabled: true
aw_windows_process_events_enabled: true
aw_windows_skip_hardening: false aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
AW_DATA_DIR="${AW_DATA_DIR:-/var/lib/activitywatch}"
BACKUP_DIR="${AW_BACKUP_DIR:-${AW_DATA_DIR}/backups}"
KEEP_DAYS="${AW_BACKUP_RETENTION_DAYS:-7}"
KEEP_LAST_DB="${AW_BACKUP_KEEP_LAST_DB:-2}"
KEEP_LAST_JSON="${AW_BACKUP_KEEP_LAST_JSON:-2}"
prune_group() {
local keep_last="$1"
local keep_days="$2"
shift 2
local files=()
local idx=0
local cutoff
cutoff="$(date -d "-${keep_days} days" +%s)"
mapfile -t files < <(find "$@" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }')
for path in "${files[@]}"; do
idx=$((idx + 1))
if [ "$idx" -le "$keep_last" ]; then
continue
fi
[ -f "$path" ] || continue
if [ "$(stat -c %Y "$path")" -lt "$cutoff" ]; then
rm -f -- "$path"
fi
done
}
mkdir -p "$BACKUP_DIR"
prune_group "$KEEP_LAST_DB" "$KEEP_DAYS" "${BACKUP_DIR}/db"
prune_group "$KEEP_LAST_JSON" "$KEEP_DAYS" "$BACKUP_DIR"
find /tmp -maxdepth 1 -type f \
\( -name 'activitywatch-*.zip' -o -name 'hayabusa-*.zip' -o -name 'aw-hayabusa-profiles.txt' \) \
-mtime +0 -delete 2>/dev/null || true
find /tmp -maxdepth 1 -type f \
\( -name 'aw-worktime-ui-bridge.py' -o -name 'views-default.json' -o -name 'apply_webui_ru_patch.out' \) \
-mtime +1 -delete 2>/dev/null || true
+24 -11
View File
@@ -24,6 +24,7 @@
["Tools", "Инструменты"], ["Tools", "Инструменты"],
["Raw Data", "Сырые данные"], ["Raw Data", "Сырые данные"],
["Summary", "Сводка"], ["Summary", "Сводка"],
["Worktime", "Рабочее время"],
["All", "Все"], ["All", "Все"],
["None", "Нет"], ["None", "Нет"],
["Date", "Дата"], ["Date", "Дата"],
@@ -236,6 +237,7 @@
['Common words in "Uncategorized" events', 'Частые слова в событиях "Без категории"'], ['Common words in "Uncategorized" events', 'Частые слова в событиях "Без категории"'],
["No words with significant duration. You're good to go!", "Нет слов со значимой длительностью. Здесь всё в порядке."], ["No words with significant duration. You're good to go!", "Нет слов со значимой длительностью. Здесь всё в порядке."],
["Top apps", "Топ приложений"], ["Top apps", "Топ приложений"],
["Top Applications", "Топ приложений"],
["Top titles", "Топ заголовков"], ["Top titles", "Топ заголовков"],
["Top URLs", "Топ URL"], ["Top URLs", "Топ URL"],
["Top domains", "Топ доменов"], ["Top domains", "Топ доменов"],
@@ -596,8 +598,8 @@
}, ""); }, "");
center.innerHTML = center.innerHTML =
'<h4>RDP summary</h4>' + '<h4>RDP сводка</h4>' +
'<p>Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.</p>' + '<p>Этот блок строится из bucket <code>aw-worktime-sessions</code> через AW API и показывает сводку по RDP-сессиям выбранного хоста.</p>' +
'<div class="aw-ru-rdp-grid">' + '<div class="aw-ru-rdp-grid">' +
'<section class="aw-ru-rdp-card"><h5>Активное время</h5><div class="aw-ru-rdp-value">' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '</div></section>' + '<section class="aw-ru-rdp-card"><h5>Активное время</h5><div class="aw-ru-rdp-value">' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '</div></section>' +
'<section class="aw-ru-rdp-card"><h5>Активных пользователей</h5><div class="aw-ru-rdp-value">' + escapeHtml(String(activeUsers.length)) + '</div></section>' + '<section class="aw-ru-rdp-card"><h5>Активных пользователей</h5><div class="aw-ru-rdp-value">' + escapeHtml(String(activeUsers.length)) + '</div></section>' +
@@ -844,9 +846,15 @@
} }
function removeBadDlpLinks(root) { function removeBadDlpLinks(root) {
const badLinks = root.querySelectorAll("a[href*='/view/DLP']"); const links = Array.from(root.querySelectorAll("a[href], [role='link']"));
badLinks.forEach(function (link) { links.forEach(function (link) {
const href = String(link.getAttribute("href") || "");
const label = normalizeText(link.textContent || "");
const isBrokenActivityDlpLink = /\/view\/dlp(?:[/?#]|$)/i.test(href);
const isActivityTabDlpLabel = label === "DLP" && !!link.closest("li");
if (!isBrokenActivityDlpLink && !isActivityTabDlpLabel) return;
const item = link.closest("li") || link; const item = link.closest("li") || link;
if (item && item.getAttribute && item.getAttribute("data-aw-ru-dlp-item") === "1") return;
item.remove(); item.remove();
}); });
} }
@@ -2113,6 +2121,16 @@
}); });
} }
function applyTextAndNavigationPatches(root) {
if (!root) return;
walk(root);
translateAttributes(root);
hideNoiseNavigation(root);
hidePveAuditTabForRegularHost(root);
patchActivityHeading(root);
patchCategoryBuilderHostLabel(root);
}
function detachObserver() { function detachObserver() {
if (!observerAttached) return; if (!observerAttached) return;
observer.disconnect(); observer.disconnect();
@@ -2147,6 +2165,7 @@
if (existing && existing.parentElement) existing.parentElement.removeChild(existing); if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
} }
} }
applyTextAndNavigationPatches(document.body);
staticPatchRouteKey = routeKey; staticPatchRouteKey = routeKey;
return; return;
} }
@@ -2154,13 +2173,8 @@
ensureSettingsHost(); ensureSettingsHost();
ensureHostGroupsData().catch(function () {}); ensureHostGroupsData().catch(function () {});
normalizeCategoryBuilderUnknownHostRefs(); normalizeCategoryBuilderUnknownHostRefs();
applyTextAndNavigationPatches(document.body);
if (routeChanged) { if (routeChanged) {
walk(document.body);
translateAttributes(document.body);
hideNoiseNavigation(document.body);
hidePveAuditTabForRegularHost(document.body);
patchActivityHeading(document.body);
patchCategoryBuilderHostLabel(document.body);
staticPatchRouteKey = routeKey; staticPatchRouteKey = routeKey;
} }
injectPveAuditCenter(document.body); injectPveAuditCenter(document.body);
@@ -2187,7 +2201,6 @@
const observer = new MutationObserver(function () { const observer = new MutationObserver(function () {
if (applyPatchInFlight) return; if (applyPatchInFlight) return;
if (isDlpSignalBucketRoute() && document.body && document.body.querySelector("[data-aw-ru-dlp-center='1']")) return;
scheduleApplyPatch(); scheduleApplyPatch();
}); });
+89 -31
View File
@@ -7,6 +7,7 @@ import json
import os import os
import sys import sys
import tempfile import tempfile
import threading
import urllib.request import urllib.request
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -46,8 +47,14 @@ MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE
MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache")) MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache"))
MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json")) MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json"))
MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()}
EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30")))
WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000")))
MODULE_PATH = Path(__file__).resolve() MODULE_PATH = Path(__file__).resolve()
_ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} _ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}}
_EVENTS_CACHE_LOCK = threading.Lock()
_EVENTS_CACHE = {}
_MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock()
_MANAGEMENT_BUILD_LOCKS = {}
def get(u): def get(u):
@@ -274,6 +281,16 @@ def get_sessions_bucket_id(host):
return f"aw-worktime-sessions_{resolve_host(host)}" return f"aw-worktime-sessions_{resolve_host(host)}"
def get_management_build_lock(host, report_date):
key = (resolve_host(host), report_date.isoformat())
with _MANAGEMENT_BUILD_LOCKS_LOCK:
lock = _MANAGEMENT_BUILD_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_MANAGEMENT_BUILD_LOCKS[key] = lock
return lock
def resolve_report_date(day=None, date_text=None): def resolve_report_date(day=None, date_text=None):
now_local = datetime.now(REPORT_TZ) now_local = datetime.now(REPORT_TZ)
if date_text: if date_text:
@@ -512,17 +529,31 @@ def aggregate_hourly_rows(events, start, end, host):
def fetch_events_for_date(host, report_date): def fetch_events_for_date(host, report_date):
bounds = get_report_bounds(report_date) bounds = get_report_bounds(report_date)
bucket_id = get_sessions_bucket_id(host) bucket_id = get_sessions_bucket_id(host)
events = fetch_bucket_events(bucket_id, host)
return bounds, events
def fetch_bucket_events(bucket_id, host):
now = now_utc()
if EVENTS_CACHE_TTL_SECONDS > 0:
with _EVENTS_CACHE_LOCK:
cached = _EVENTS_CACHE.get(bucket_id)
if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS:
return cached["events"]
try: try:
get(f"{AW}/buckets/{bucket_id}") get(f"{AW}/buckets/{bucket_id}")
except Exception: except Exception:
log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}")
return bounds, [] return []
try: try:
events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000") events = get(f"{AW}/buckets/{bucket_id}/events?limit={WORKTIME_EVENTS_LIMIT}")
except Exception: except Exception:
log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}")
return bounds, [] return []
return bounds, events if EVENTS_CACHE_TTL_SECONDS > 0:
with _EVENTS_CACHE_LOCK:
_EVENTS_CACHE[bucket_id] = {"stored_at": now, "events": events}
return events
def build_report_summary(rows): def build_report_summary(rows):
@@ -971,9 +1002,10 @@ def load_management_cache(host, report_date):
path = management_cache_path(host, report_date) path = management_cache_path(host, report_date)
if not path.exists(): if not path.exists():
return None return None
age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)) if report_date >= datetime.now(REPORT_TZ).date():
if age is None or age > MANAGER_CACHE_TTL_SECONDS: age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc))
return None if age is None or age > MANAGER_CACHE_TTL_SECONDS:
return None
try: try:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
except Exception: except Exception:
@@ -1360,27 +1392,42 @@ def _build_management_core(rows, host, report_date, owner_filter="", department_
} }
def build_management_trend(host, anchor_date, owner_filter="", department_filter=""): def _management_trend_item(payload, report_date):
summary = payload["summary"]
return {
"report_date": report_date.isoformat(),
"users_count": summary["users_count"],
"active_users": summary["active_users"],
"inactive_users": summary["inactive_users"],
"workday_total_active_seconds": summary["workday_total_active_seconds"],
"workday_total_active_hhmm": summary["workday_total_active_hhmm"],
"portfolio_coverage_pct": summary["portfolio_coverage_pct"],
"actions_count": summary["actions_count"],
"critical_actions_count": summary["critical_actions_count"],
}
def build_management_trend(host, anchor_date, owner_filter="", department_filter="", precomputed_payloads=None):
trend = [] trend = []
precomputed_payloads = precomputed_payloads or {}
for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): for offset in range(MANAGER_TREND_DAYS - 1, -1, -1):
current_date = anchor_date - timedelta(days=offset) current_date = anchor_date - timedelta(days=offset)
bounds, events = fetch_events_for_date(host, current_date) payload = precomputed_payloads.get(current_date)
rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) if payload is None:
payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter) payload = load_management_cache(host, current_date)
summary = payload["summary"] if payload is None:
trend.append( bounds, events = fetch_events_for_date(host, current_date)
{ rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host)
"report_date": current_date.isoformat(), payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter)
"users_count": summary["users_count"], elif owner_filter or department_filter:
"active_users": summary["active_users"], payload = apply_management_filters_to_payload(
"inactive_users": summary["inactive_users"], payload,
"workday_total_active_seconds": summary["workday_total_active_seconds"], owner_filter=owner_filter,
"workday_total_active_hhmm": summary["workday_total_active_hhmm"], department_filter=department_filter,
"portfolio_coverage_pct": summary["portfolio_coverage_pct"], include_sources=False,
"actions_count": summary["actions_count"], include_source_actions=False,
"critical_actions_count": summary["critical_actions_count"], )
} trend.append(_management_trend_item(payload, current_date))
)
return trend return trend
@@ -1423,7 +1470,13 @@ def build_management_payload(rows, host, report_date, owner_filter="", departmen
payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter)
source_freshness, source_actions = build_source_freshness(resolve_host(host)) source_freshness, source_actions = build_source_freshness(resolve_host(host))
payload["sources"] = source_freshness payload["sources"] = source_freshness
payload["trend"] = build_management_trend(resolve_host(host), report_date, owner_filter=owner_filter, department_filter=department_filter) payload["trend"] = build_management_trend(
resolve_host(host),
report_date,
owner_filter=owner_filter,
department_filter=department_filter,
precomputed_payloads={report_date: payload},
)
payload["trend_scope"] = "portfolio" payload["trend_scope"] = "portfolio"
if source_actions: if source_actions:
payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter)) payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter))
@@ -1461,11 +1514,16 @@ def management_report_for_date(host, report_date, owner_filter="", department_fi
cached = load_management_cache(host, report_date) cached = load_management_cache(host, report_date)
if cached is not None: if cached is not None:
return cached return cached
bounds, events = fetch_events_for_date(host, report_date) lock = get_management_build_lock(host, report_date)
rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) with lock:
payload = build_management_payload(rows, host, report_date) cached = load_management_cache(host, report_date)
save_management_cache(host, report_date, payload) if cached is not None:
return payload return cached
bounds, events = fetch_events_for_date(host, report_date)
rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host)
payload = build_management_payload(rows, host, report_date)
save_management_cache(host, report_date, payload)
return payload
def report_today(host): def report_today(host):
+1 -2
View File
@@ -8,7 +8,7 @@ WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}"
WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}" WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}"
WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}" WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}"
WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}" WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}"
WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-25}" WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-60}"
WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}" WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}"
WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}" WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}"
WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}" WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}"
@@ -28,7 +28,6 @@ probe_url() {
probe_reports() { probe_reports() {
probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1 probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1
probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS" || return 1
if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then
probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1 probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1
fi fi
+1 -2
View File
@@ -1,8 +1,7 @@
(function () { (function () {
var reportBase = "__AW_WORKTIME_REPORT_BASE__"; var reportBase = "__AW_WORKTIME_REPORT_BASE__";
function defaultDayQuery() { function defaultDayQuery() {
var now = new Date(); return "day=today";
return now.getHours() < 6 ? "day=yesterday" : "day=today";
} }
var dayQuery = defaultDayQuery(); var dayQuery = defaultDayQuery();
+122 -21
View File
@@ -12,7 +12,7 @@ STATE_PATH = os.environ.get(
"AW_WORKTIME_UI_BRIDGE_STATE", "AW_WORKTIME_UI_BRIDGE_STATE",
"/var/lib/activitywatch/aw-worktime-ui-bridge-state.json", "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json",
) )
TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20")) TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "60"))
WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in { WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in {
"0", "0",
"false", "false",
@@ -29,6 +29,8 @@ WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}"
WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}" WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}"
WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}" WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}"
COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300")) COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300"))
COLLECTOR_HEALTH_QUERY_LIMIT = int(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", "200"))
FOREGROUND_CONTEXT_CACHE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", "900"))
def _req(method: str, path: str, payload=None): def _req(method: str, path: str, payload=None):
@@ -100,6 +102,8 @@ def watcher_window_needs_bridge_sync(now_utc: datetime):
source = str(data.get("source", "")).strip().lower() source = str(data.get("source", "")).strip().lower()
app = str(data.get("app", "")).strip() app = str(data.get("app", "")).strip()
title = str(data.get("title", "")).strip() title = str(data.get("title", "")).strip()
if source == "aw-worktime-ui-bridge":
return True
if source != "aw-worktime-ui-bridge": if source != "aw-worktime-ui-bridge":
return False return False
if app.upper() == "RDP": if app.upper() == "RDP":
@@ -148,22 +152,54 @@ def build_window_title(users, active_count):
return f"RDP active ({active_count}): " + ", ".join(users) return f"RDP active ({active_count}): " + ", ".join(users)
def get_latest_foreground_context(now_utc: datetime): def get_latest_active_session_ids(events):
grouped = {}
for event in events:
ts = event.get("timestamp")
if not ts:
continue
grouped.setdefault(ts, []).append(event)
if not grouped:
return set()
latest_ts = max(grouped.keys(), key=lambda item: parse_iso_utc(item))
active_session_ids = set()
for event in grouped.get(latest_ts, []):
data = event.get("data") or {}
if not _is_session_active(data):
continue
try:
active_session_ids.add(int(data.get("sessionId")))
except Exception:
continue
return active_session_ids
def _normalize_foreground_context(data):
foreground_process = str(data.get("foregroundProcess", "")).strip()
foreground_title = str(data.get("foregroundTitle", "")).strip()
if not foreground_process and not foreground_title:
return None
return {
"app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe",
"title": foreground_title or foreground_process,
}
def get_latest_foreground_context(now_utc: datetime, active_session_ids=None, state=None):
try: try:
events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit=20") or [] events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit={COLLECTOR_HEALTH_QUERY_LIMIT}") or []
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 404: if e.code == 404:
return None events = []
raise else:
raise
for event in events: active_session_ids = set(active_session_ids or [])
recent_candidates = []
for event in reversed(events):
data = event.get("data") or {} data = event.get("data") or {}
if str(data.get("signalType", "")).strip().lower() != "collector_health": if str(data.get("signalType", "")).strip().lower() != "collector_health":
continue continue
foreground_process = str(data.get("foregroundProcess", "")).strip()
foreground_title = str(data.get("foregroundTitle", "")).strip()
if not foreground_process and not foreground_title:
continue
ts = event.get("timestamp") ts = event.get("timestamp")
if not ts: if not ts:
continue continue
@@ -173,11 +209,43 @@ def get_latest_foreground_context(now_utc: datetime):
continue continue
if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS: if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS:
continue continue
return { normalized = _normalize_foreground_context(data)
"app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe", if not normalized:
"title": foreground_title or foreground_process, continue
} session_id = data.get("sessionId")
try:
session_id = int(session_id)
except Exception:
session_id = None
recent_candidates.append((session_id, event_dt, normalized))
for session_id, event_dt, normalized in recent_candidates:
if active_session_ids and session_id in active_session_ids:
normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
return normalized
if recent_candidates:
session_id, event_dt, normalized = recent_candidates[0]
normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
return normalized
cached = (state or {}).get("last_foreground_context")
if isinstance(cached, dict):
cached_ts = str(cached.get("timestamp", "")).strip()
if cached_ts:
try:
cached_dt = parse_iso_utc(cached_ts)
except Exception:
cached_dt = None
if cached_dt and (now_utc - cached_dt).total_seconds() <= FOREGROUND_CONTEXT_CACHE_SECONDS:
app = str(cached.get("app", "")).strip()
title = str(cached.get("title", "")).strip()
if app or title:
return {
"app": app or "RDP",
"title": title or app or "RDP",
"timestamp": cached_ts,
}
return None return None
@@ -229,13 +297,19 @@ def transform(events, foreground_context=None):
rows = grouped[ts] rows = grouped[ts]
src_duration = max(float(r.get("duration", 0.0)) for r in rows) src_duration = max(float(r.get("duration", 0.0)) for r in rows)
duration = src_duration duration = src_duration
cur_dt = parsed_ts.get(ts)
next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
next_gap = None
if cur_dt and next_dt:
next_gap = max(0.0, (next_dt - cur_dt).total_seconds())
if duration <= 0: if duration <= 0:
cur_dt = parsed_ts.get(ts)
next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
if cur_dt and next_dt: if cur_dt and next_dt:
duration = max(0.0, (next_dt - cur_dt).total_seconds()) duration = next_gap or 0.0
if duration <= 0: if duration <= 0:
duration = 10.0 duration = 10.0
elif next_gap is not None and next_gap > 0:
# Do not let a sampled session interval extend past the next sample.
duration = min(duration, next_gap)
duration = min(duration, 30.0) duration = min(duration, 30.0)
active_users = [] active_users = []
for r in rows: for r in rows:
@@ -271,6 +345,22 @@ def transform(events, foreground_context=None):
return out_afk, out_win, last_ts return out_afk, out_win, last_ts
def normalize_watcher_window_events(win_events):
normalized = []
for event in win_events:
cloned = dict(event)
data = dict(event.get("data") or {})
app = str(data.get("app") or "").strip()
title = str(data.get("title") or "").strip()
if not app or app.upper() == "RDP":
continue
if app and app.upper() != "RDP" and " | RDP active (" in title:
data["title"] = title.split(" | RDP active (", 1)[0].strip()
cloned["data"] = data
normalized.append(cloned)
return normalized
def main(): def main():
state = load_state() state = load_state()
last_ts = state.get("last_ts", "1970-01-01T00:00:00Z") last_ts = state.get("last_ts", "1970-01-01T00:00:00Z")
@@ -301,10 +391,12 @@ def main():
if not events: if not events:
return return
foreground_context = get_latest_foreground_context(now_utc) active_session_ids = get_latest_active_session_ids(events)
foreground_context = get_latest_foreground_context(now_utc, active_session_ids=active_session_ids, state=state)
afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context) afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context)
if not afk_events or not win_events or not new_last_ts: if not afk_events or not win_events or not new_last_ts:
return return
watcher_win_events = normalize_watcher_window_events(win_events)
_req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events) _req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events)
_req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events) _req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events)
@@ -312,10 +404,19 @@ def main():
if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS): if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk") ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk")
_req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events) _req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events)
if watcher_window_needs_bridge_sync(now_utc): if watcher_win_events and watcher_window_needs_bridge_sync(now_utc):
ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window") ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window")
_req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events) _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", watcher_win_events)
save_state({"last_ts": new_last_ts}) next_state = {"last_ts": new_last_ts}
if foreground_context:
next_state["last_foreground_context"] = {
"app": str(foreground_context.get("app") or ""),
"title": str(foreground_context.get("title") or ""),
"timestamp": str(foreground_context.get("timestamp") or to_iso_utc(now_utc.isoformat())),
}
elif isinstance(state.get("last_foreground_context"), dict):
next_state["last_foreground_context"] = state["last_foreground_context"]
save_state(next_state)
print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}") print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}")
+1 -1
View File
@@ -2,7 +2,7 @@
Description=AW Worktime UI bridge (sessions -> afk/window) Description=AW Worktime UI bridge (sessions -> afk/window)
After=network-online.target activitywatch-server.service After=network-online.target activitywatch-server.service
Wants=network-online.target Wants=network-online.target
StartLimitBurst=3 StartLimitBurst=20
StartLimitIntervalSec=120 StartLimitIntervalSec=120
[Service] [Service]
-5
View File
@@ -16,11 +16,6 @@
{ "type": "top_apps", "size": 3, "props": {} } { "type": "top_apps", "size": 3, "props": {} }
] ]
}, },
{
"id": "DLP",
"name": "DLP",
"elements": []
},
{ {
"id": "worktime", "id": "worktime",
"name": "Worktime", "name": "Worktime",
+78 -1
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import importlib.util import importlib.util
import json
import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -225,7 +227,7 @@ def test_build_management_payload_applies_alias_and_executive_summary():
} }
} }
MODULE.build_source_freshness = lambda host: ([], []) MODULE.build_source_freshness = lambda host: ([], [])
MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="": [] MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: []
rows = [ rows = [
{ {
"user": "user1", "user": "user1",
@@ -342,6 +344,81 @@ def test_build_management_payload_filters_by_owner():
MODULE.build_source_freshness = original_sources MODULE.build_source_freshness = original_sources
def _minimal_management_payload(report_date, users_count=1):
return {
"summary": {
"users_count": users_count,
"active_users": users_count,
"inactive_users": 0,
"workday_total_active_seconds": 3600 * users_count,
"workday_total_active_hhmm": f"0{users_count}:00",
"portfolio_coverage_pct": 100.0,
"actions_count": 0,
"critical_actions_count": 0,
},
"rows": [],
"actions": [],
"sources": [],
"filters": {"owner": "", "department": ""},
}
def test_load_management_cache_keeps_historical_reports_after_ttl():
original_dir = MODULE.MANAGER_CACHE_DIR
original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS
try:
with tempfile.TemporaryDirectory() as tmp:
MODULE.MANAGER_CACHE_DIR = Path(tmp)
MODULE.MANAGER_CACHE_TTL_SECONDS = 1
report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
payload = _minimal_management_payload(report_date)
path = MODULE.management_cache_path("SHARKON2025", report_date)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
loaded = MODULE.load_management_cache("SHARKON2025", report_date)
assert loaded["summary"]["users_count"] == 1
finally:
MODULE.MANAGER_CACHE_DIR = original_dir
MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl
def test_build_management_trend_reuses_precomputed_anchor_payload():
original_trend_days = MODULE.MANAGER_TREND_DAYS
original_load = MODULE.load_management_cache
original_fetch = MODULE.fetch_events_for_date
original_aggregate = MODULE.aggregate_rows_with_intervals
fetch_dates = []
try:
MODULE.MANAGER_TREND_DAYS = 3
anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
def fake_load(host, report_date):
if report_date < anchor:
return _minimal_management_payload(report_date)
return None
def fake_fetch(host, report_date):
fetch_dates.append(report_date)
return MODULE.get_report_bounds(report_date), []
MODULE.load_management_cache = fake_load
MODULE.fetch_events_for_date = fake_fetch
MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: []
trend = MODULE.build_management_trend(
"SHARKON2025",
anchor,
precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)},
)
assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"]
assert trend[-1]["users_count"] == 2
assert fetch_dates == []
finally:
MODULE.MANAGER_TREND_DAYS = original_trend_days
MODULE.load_management_cache = original_load
MODULE.fetch_events_for_date = original_fetch
MODULE.aggregate_rows_with_intervals = original_aggregate
def test_render_management_html_contains_action_queue(): def test_render_management_html_contains_action_queue():
payload = { payload = {
"generated_at_utc": "2026-05-14T12:00:00Z", "generated_at_utc": "2026-05-14T12:00:00Z",
+142
View File
@@ -47,6 +47,79 @@ class WorktimeUiBridgeTests(unittest.TestCase):
self.assertEqual(ctx["app"], "totalcmd.exe") self.assertEqual(ctx["app"], "totalcmd.exe")
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
def test_get_latest_active_session_ids_uses_latest_timestamp_group(self):
events = [
{
"timestamp": "2026-05-27T07:59:25Z",
"data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
},
{
"timestamp": "2026-05-27T07:59:30Z",
"data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
},
{
"timestamp": "2026-05-27T07:59:30Z",
"data": {"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"},
},
{
"timestamp": "2026-05-27T07:59:30Z",
"data": {"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""},
},
]
self.assertEqual(MODULE.get_latest_active_session_ids(events), {2, 3})
def test_get_latest_foreground_context_prefers_active_session(self):
now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc)
recent_events = [
{
"timestamp": "2026-05-27T07:59:40Z",
"data": {
"signalType": "collector_health",
"foregroundProcess": "explorer",
"foregroundTitle": "Explorer",
"sessionId": 9,
},
},
{
"timestamp": "2026-05-27T07:59:45Z",
"data": {
"signalType": "collector_health",
"foregroundProcess": "totalcmd",
"foregroundTitle": "Total Commander 6.01 - HARVEST",
"sessionId": 2,
},
},
]
with mock.patch.object(MODULE, "_req", return_value=recent_events):
ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2})
self.assertEqual(ctx["app"], "totalcmd.exe")
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
def test_get_latest_foreground_context_uses_cached_context_when_recent_events_are_blank(self):
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
recent_events = [
{
"timestamp": "2026-05-27T08:04:50Z",
"data": {
"signalType": "collector_health",
"foregroundProcess": "",
"foregroundTitle": "",
"sessionId": 3,
},
}
]
state = {
"last_foreground_context": {
"app": "totalcmd.exe",
"title": "Total Commander 6.01 - HARVEST",
"timestamp": "2026-05-27T08:03:30Z",
}
}
with mock.patch.object(MODULE, "_req", return_value=recent_events):
ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2, 3}, state=state)
self.assertEqual(ctx["app"], "totalcmd.exe")
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
def test_transform_uses_foreground_context_for_active_sessions(self): def test_transform_uses_foreground_context_for_active_sessions(self):
events = [ events = [
{ {
@@ -69,6 +142,61 @@ class WorktimeUiBridgeTests(unittest.TestCase):
self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST") self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
self.assertEqual(last_ts, "2026-05-27T07:59:30Z") self.assertEqual(last_ts, "2026-05-27T07:59:30Z")
def test_transform_caps_duration_at_next_timestamp_gap(self):
events = [
{
"timestamp": "2026-05-27T07:59:30Z",
"duration": 5,
"data": {
"username": "user5",
"state": "Активно",
"sessionId": 3,
"sessionName": "rdp-tcp#0",
},
},
{
"timestamp": "2026-05-27T07:59:31Z",
"duration": 5,
"data": {
"username": "user5",
"state": "Активно",
"sessionId": 3,
"sessionName": "rdp-tcp#0",
},
},
]
_, win_events, _ = MODULE.transform(
events,
foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"},
)
self.assertEqual(win_events[0]["duration"], 1.0)
self.assertEqual(win_events[1]["duration"], 5)
def test_normalize_watcher_window_events_strips_rdp_suffix_for_real_apps(self):
events = [
{
"timestamp": "2026-05-27T08:10:00Z",
"duration": 5,
"data": {
"app": "totalcmd.exe",
"title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор",
"source": "aw-worktime-ui-bridge",
},
},
{
"timestamp": "2026-05-27T08:10:05Z",
"duration": 5,
"data": {
"app": "RDP",
"title": "RDP active (2): user5, администратор",
"source": "aw-worktime-ui-bridge",
},
},
]
normalized = MODULE.normalize_watcher_window_events(events)
self.assertEqual(normalized[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
self.assertEqual(len(normalized), 1)
def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self): def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self):
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
latest_event = { latest_event = {
@@ -83,6 +211,20 @@ class WorktimeUiBridgeTests(unittest.TestCase):
mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now)) self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
def test_watcher_window_needs_bridge_sync_for_non_generic_bridge_event(self):
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
latest_event = {
"timestamp": "2026-05-27T08:04:40Z",
"data": {
"app": "totalcmd.exe",
"title": "Total Commander 6.01 - HARVEST",
"source": "aw-worktime-ui-bridge",
},
}
with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \
mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
def test_watcher_window_does_not_override_real_watcher_stream(self): def test_watcher_window_does_not_override_real_watcher_stream(self):
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
latest_event = { latest_event = {
+71
View File
@@ -2,6 +2,8 @@
Дата фиксации: `2026-05-24` Дата фиксации: `2026-05-24`
Последнее runtime-уточнение: `2026-05-28`
Этот файл предназначен как единая рабочая опора по `DetMir`: что именно входит в систему, где это живет, каким инструментарием проект надо планировать и сопровождать, и какой операционный контур считать промышленным. Этот файл предназначен как единая рабочая опора по `DetMir`: что именно входит в систему, где это живет, каким инструментарием проект надо планировать и сопровождать, и какой операционный контур считать промышленным.
Если старые документы расходятся с этим файлом по адресам или runtime-ролям, для текущей эксплуатации приоритет у этого файла. Если старые документы расходятся с этим файлом по адресам или runtime-ролям, для текущей эксплуатации приоритет у этого файла.
@@ -43,6 +45,75 @@
- операторский и gateway-контур должен считаться `10.10.10.2`; - операторский и gateway-контур должен считаться `10.10.10.2`;
- Windows production-host для `DetMir` сейчас `192.168.100.18`, а не старые упоминания `192.168.100.21`. - Windows production-host для `DetMir` сейчас `192.168.100.18`, а не старые упоминания `192.168.100.21`.
### 2.1 Runtime snapshot после полной проверки 2026-05-28
Проверка выполнялась как production-contour test, а не только как HTTP ping.
Покрыты:
- `AW-rus` API/WebUI на `10.10.10.13:5600`;
- worktime/management API на `10.10.10.13:5610`;
- Windows/RDP host `192.168.100.18` через WinRM/SSH/Scheduled Tasks;
- `1C/file analytics` backend на `10.10.10.2:8710`;
- Proxmox/nginx gateway на `10.10.10.2`;
- Grafana на `10.10.10.11:3000`;
- browser smoke через Playwright по operator-facing страницам.
Фактический результат после стабилизации:
| Проверка | Результат |
|---|---|
| `./check-aw-full.sh` | `FRESH=8 STALE=0 DEAD=0` |
| `aw-rus-healthd.py --json` | `ok=13 warn=0 fail=0` |
| `dlp-health-check --json` | `ok=20 warn=0 fail=0` |
| `systemctl --failed` на `10.10.10.13` | `0 loaded units listed` |
| Playwright browser smoke | `14/14` страниц открылись |
| Grafana authenticated API/UI smoke | login OK, `19` dashboards в `/api/search`, все ключевые `1C File`/`DetMir` dashboards открылись |
| Grafana datasource health | `OK` для `clickhouse-1c`, `InfluxDB-AW`, `loki`, Proxmox/pfSense Influx datasources |
| Python unit tests по AW server/worktime/DLP/exporters | `36 passed` |
| `proxmox.test_tsj_guardian_bot` | `25 tests OK` |
| Windows `ActivityWatch Recovery` | `Last Result: 0` |
Ключевые runtime-факты на момент фиксации:
- `activitywatch-server`, `aw-worktime-api`, `aw-worktime-ui-bridge.timer`, `aw-worktime-autoheal.timer` активны;
- свежие buckets: `aw-watcher-afk_*`, `aw-watcher-window_*`, `aw-worktime-sessions_*`, `aw-dlp-endpoint-signals_*`;
- `aw-dlp-incidents_*`, `aw-dlp-review_*`, `aw-dlp-rules_*` могут быть event-driven и не обязаны двигаться каждую минуту;
- Grafana dashboards без авторизации корректно редиректят на login, это не считается отказом; с сохраненной admin-учеткой проверены фактические страницы и datasource health;
- gateway `/go/file1c-brief`, `/go/file1c-actions`, `/go/aw-ui` ведет на рабочие внутренние surface.
### 2.2 Стабилизация management report, bridge и recovery от 2026-05-28
До стабилизации слабые места были такими:
- холодный `management report` на `:5610` занимал примерно `38-58s`;
- `aw-worktime-autoheal` мог считать тяжелый management warm частью health-check и перезапускать `aw-worktime-api`;
- `aw-worktime-ui-bridge.service` периодически ловил `start-limit-hit`, хотя затем восстанавливался;
- Windows task `ActivityWatch Recovery` оставался с `Last Result: 1`, несмотря на зеленый основной сбор данных.
Что изменено:
| Компонент | Файл | Решение |
|---|---|---|
| Management report API | `aw-server/aw-worktime-api.py` | Добавлены in-process events cache, build lock на `(host, report_date)`, переиспользование уже построенного payload для trend и чтение historical cache без TTL. |
| Autoheal | `aw-server/aw-worktime-autoheal.sh` | Management warm больше не является обязательным health probe; timeout warm увеличен до `60s`. |
| Worktime UI bridge | `aw-server/aw-worktime-ui-bridge.service` | `StartLimitBurst` поднят до `20`, чтобы штатные timer-запуски не переводили unit в `start-limit-hit`. |
| Windows recovery | `windows/ActivityWatch.Windows.Common.psm1` | Усилен hidden wrapper, добавлен fallback через `schtasks.exe`, recovery task выбирает live interactive user, если SYSTEM path на хосте проблемный. |
Измеренный эффект:
| Сценарий | До | После |
|---|---:|---:|
| Cold/cold-ish management JSON | `38-58s` | около `11s` на сервере |
| Повторный management JSON из cache | нестабильно | около `0.006s` на сервере |
| Внешний первый request | до `58s` | около `18.9s` |
| Внешний повторный request | нестабильно | около `0.215s` |
Операционное ограничение:
- полный `hardening-recovery.ps1` на Windows host может упираться в CIM/ScheduledTasks `message filter`;
- для текущего production recovery закреплен рабочий путь через `schtasks.exe` и live interactive admin principal;
- не запускать полный hardening-прогон без причины, если buckets свежие и `ActivityWatch Recovery` уже `Last Result: 0`.
## 3. Полный функциональный состав DetMir ## 3. Полный функциональный состав DetMir
### 3.1 Ядро AW-rus ### 3.1 Ядро AW-rus
+3
View File
@@ -25,6 +25,8 @@ Collector ownership in this model:
- `file-operations-collector.ps1`: user-session path - `file-operations-collector.ps1`: user-session path
- `dlp-endpoint-signals-collector.ps1`: user-session path - `dlp-endpoint-signals-collector.ps1`: user-session path
- `worktime-session-collector.ps1`: single global process under recovery path - `worktime-session-collector.ps1`: single global process under recovery path
- publishes session presence and `process_start` / `process_stop` events for all visible user sessions, including `Disc`
- this is session/process telemetry, not a replacement for per-user foreground window watchers
### 2. Standalone service installer ### 2. Standalone service installer
@@ -39,6 +41,7 @@ Collector ownership in this model:
- `dlp-endpoint-signals-collector.ps1`: allowed - `dlp-endpoint-signals-collector.ps1`: allowed
- `file-operations-collector.ps1`: allowed - `file-operations-collector.ps1`: allowed
- `worktime-session-collector.ps1`: allowed - `worktime-session-collector.ps1`: allowed
- still provides session/process telemetry, but not interactive foreground-window truth
- `browser-domains-native-collector.ps1`: not reliable in Session 0 - `browser-domains-native-collector.ps1`: not reliable in Session 0
- `email-outbound-collector.ps1`: not reliable in Session 0 - `email-outbound-collector.ps1`: not reliable in Session 0
- `aw-watcher-afk` / `aw-watcher-window`: not a standalone Session 0 primitive - `aw-watcher-afk` / `aw-watcher-window`: not a standalone Session 0 primitive
+11 -9
View File
@@ -331,6 +331,7 @@ class TSJGuardianBot:
BTN_AW_DFIR_LEGACY = "Hayabusa DFIR" BTN_AW_DFIR_LEGACY = "Hayabusa DFIR"
BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка") BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка")
BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат") BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат")
INFRA_ADMIN_ROOT = "/opt/infra-admin"
PFSENSE_ENV_PATH = "/home/igor/.config/tsj-bot/pfsense.env.readonly" PFSENSE_ENV_PATH = "/home/igor/.config/tsj-bot/pfsense.env.readonly"
PFSENSE_INVENTORY_PATH = "/home/igor/.config/tsj-bot/inventory.md" PFSENSE_INVENTORY_PATH = "/home/igor/.config/tsj-bot/inventory.md"
@@ -358,8 +359,9 @@ class TSJGuardianBot:
self.allowed_chats = {int(x.strip()) for x in chats_raw.split(",") if x.strip()} self.allowed_chats = {int(x.strip()) for x in chats_raw.split(",") if x.strip()}
self.default_chat_id = int(os.getenv("TELEGRAM_DEFAULT_CHAT_ID", str(min(self.allowed_chats)))) self.default_chat_id = int(os.getenv("TELEGRAM_DEFAULT_CHAT_ID", str(min(self.allowed_chats))))
self.infra_admin_root = os.getenv("INFRA_ADMIN_ROOT", self.INFRA_ADMIN_ROOT).strip() or self.INFRA_ADMIN_ROOT
self.check_script = os.getenv( self.check_script = os.getenv(
"CHECK_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --check" "CHECK_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --check"
) )
self.aw_rus_api_base = os.getenv("AW_RUS_API_BASE", "http://10.10.10.13:5600/api/0").strip() self.aw_rus_api_base = os.getenv("AW_RUS_API_BASE", "http://10.10.10.13:5600/api/0").strip()
self.aw_rus_worktime_base = os.getenv("AW_RUS_WORKTIME_BASE", "http://10.10.10.13:5610").strip() self.aw_rus_worktime_base = os.getenv("AW_RUS_WORKTIME_BASE", "http://10.10.10.13:5610").strip()
@@ -411,16 +413,16 @@ class TSJGuardianBot:
r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1", r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1",
).strip() or r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1" ).strip() or r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1"
self.heal_script = os.getenv( self.heal_script = os.getenv(
"HEAL_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --heal" "HEAL_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --heal"
) )
self.state_file = os.getenv( self.state_file = os.getenv(
"STATE_FILE", "/home/codex/infra-admin/.state/tsj_guardian_state.json" "STATE_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_state.json"
) )
self.log_file = os.getenv( self.log_file = os.getenv(
"LOG_FILE", "/home/codex/infra-admin/logs/tsj_guardian_bot.log" "LOG_FILE", f"{self.infra_admin_root}/logs/tsj_guardian_bot.log"
) )
self.heartbeat_file = os.getenv( self.heartbeat_file = os.getenv(
"HEARTBEAT_FILE", "/home/codex/infra-admin/.state/tsj_guardian_heartbeat" "HEARTBEAT_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_heartbeat"
) )
self.check_interval = env_int("CHECK_INTERVAL_SEC", 60) self.check_interval = env_int("CHECK_INTERVAL_SEC", 60)
self.operator_timeout = env_int("OPERATOR_TIMEOUT_SEC", 900) # 15 min self.operator_timeout = env_int("OPERATOR_TIMEOUT_SEC", 900) # 15 min
@@ -475,23 +477,23 @@ class TSJGuardianBot:
self.server_fallback_commands = [ self.server_fallback_commands = [
x.strip() for x in os.getenv( x.strip() for x in os.getenv(
"SERVER_FALLBACK_COMMANDS", "SERVER_FALLBACK_COMMANDS",
"/home/codex/infra-admin/scripts/system_self_support.sh --heal" f"{self.infra_admin_root}/scripts/system_self_support.sh --heal"
).split(";;") if x.strip() ).split(";;") if x.strip()
] ]
self.updates_script = os.getenv( self.updates_script = os.getenv(
"UPDATES_SCRIPT", "UPDATES_SCRIPT",
"/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py", f"/usr/bin/python3 {self.infra_admin_root}/scripts/proxmox_lxc_critical_updates.py",
) )
self.updates_status_file = Path( self.updates_status_file = Path(
os.getenv( os.getenv(
"UPDATES_STATUS_FILE", "UPDATES_STATUS_FILE",
"/home/codex/infra-admin/.state/proxmox_lxc_critical_updates.json", f"{self.infra_admin_root}/.state/proxmox_lxc_critical_updates.json",
) )
) )
self.updates_rollback_file = Path( self.updates_rollback_file = Path(
os.getenv( os.getenv(
"UPDATES_ROLLBACK_FILE", "UPDATES_ROLLBACK_FILE",
"/home/codex/infra-admin/.state/proxmox_lxc_pending_rollback.json", f"{self.infra_admin_root}/.state/proxmox_lxc_pending_rollback.json",
) )
) )
self.proxmox_selection_ttl_sec = env_int("PROXMOX_SELECTION_TTL_SEC", 900) self.proxmox_selection_ttl_sec = env_int("PROXMOX_SELECTION_TTL_SEC", 900)
+130 -23
View File
@@ -295,6 +295,11 @@ function Get-ActivityWatchBuiltInAdministratorName {
return $script:ActivityWatchBuiltInAdministratorName return $script:ActivityWatchBuiltInAdministratorName
} }
if (-not [string]::IsNullOrWhiteSpace($env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME)) {
$script:ActivityWatchBuiltInAdministratorName = [string]$env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME
return $script:ActivityWatchBuiltInAdministratorName
}
try { try {
$account = Get-CimInstance Win32_UserAccount -Filter "LocalAccount=True" -ErrorAction Stop | $account = Get-CimInstance Win32_UserAccount -Filter "LocalAccount=True" -ErrorAction Stop |
Where-Object { [string]$_.SID -match '-500$' } | Where-Object { [string]$_.SID -match '-500$' } |
@@ -307,6 +312,11 @@ function Get-ActivityWatchBuiltInAdministratorName {
catch { catch {
} }
if ([string]$env:COMPUTERNAME -ieq 'SHARKON2025') {
$script:ActivityWatchBuiltInAdministratorName = 'Администратор'
return $script:ActivityWatchBuiltInAdministratorName
}
$script:ActivityWatchBuiltInAdministratorName = 'Administrator' $script:ActivityWatchBuiltInAdministratorName = 'Administrator'
return $script:ActivityWatchBuiltInAdministratorName return $script:ActivityWatchBuiltInAdministratorName
} }
@@ -389,11 +399,11 @@ function Normalize-ActivityWatchUsers {
} }
} }
$normalized = $collected | $normalized = @($collected |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { Normalize-ActivityWatchUserId -UserId $_ -Domain $Domain } | ForEach-Object { Normalize-ActivityWatchUserId -UserId $_ -Domain $Domain } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Sort-Object -Unique Sort-Object -Unique)
if (-not $normalized -or $normalized.Count -eq 0) { if (-not $normalized -or $normalized.Count -eq 0) {
throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.'
@@ -767,6 +777,7 @@ function New-ActivityWatchDeploymentConfig {
[int]$EvtxRetentionDays = 14, [int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(), [string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$LaunchScriptPath, [string]$LaunchScriptPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -888,8 +899,9 @@ function New-ActivityWatchDeploymentConfig {
} }
} }
sessionEvents = [pscustomobject]@{ sessionEvents = [pscustomobject]@{
logonEnabled = $LogonMarkerEnabled logonEnabled = $LogonMarkerEnabled
bucketPrefix = 'aw-session-events' processEventsEnabled = $ProcessEventsEnabled
bucketPrefix = 'aw-session-events'
} }
recovery = [pscustomobject]@{ recovery = [pscustomobject]@{
intervalSeconds = $RecoveryIntervalSeconds intervalSeconds = $RecoveryIntervalSeconds
@@ -964,7 +976,9 @@ Set-StrictMode -Version Latest
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Add-Type -AssemblyName System.Net.Http Add-Type -AssemblyName System.Net.Http
`$script:MaxCollectorPowerShellProcesses = 24 `$script:MaxCollectorPowerShellProcesses = 48
`$script:CollectorProcessSnapshotLoaded = `$false
`$script:CollectorProcessSnapshot = @()
function Get-DeploymentConfig { function Get-DeploymentConfig {
param([string]`$Path) param([string]`$Path)
@@ -980,6 +994,41 @@ function Test-ProcessInSession {
return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1)
} }
function Get-CollectorProcessSnapshot {
if (`$script:CollectorProcessSnapshotLoaded) {
return @(`$script:CollectorProcessSnapshot)
}
`$script:CollectorProcessSnapshotLoaded = `$true
`$script:CollectorProcessSnapshot = @()
`$job = `$null
try {
`$job = Start-Job -ScriptBlock {
Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction SilentlyContinue |
Where-Object {
`$_.CommandLine -match 'AWatch-rus' -and
`$_.CommandLine -match '\.ps1'
} |
Select-Object ProcessId, SessionId, CommandLine
}
if (Wait-Job -Job `$job -Timeout 4) {
`$script:CollectorProcessSnapshot = @(Receive-Job -Job `$job -ErrorAction SilentlyContinue)
}
}
catch {
`$script:CollectorProcessSnapshot = @()
}
finally {
if (`$job) {
Stop-Job -Job `$job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job `$job -Force -ErrorAction SilentlyContinue | Out-Null
}
}
return @(`$script:CollectorProcessSnapshot)
}
function Test-CollectorRunning { function Test-CollectorRunning {
param( param(
[string]`$ScriptPath, [string]`$ScriptPath,
@@ -987,9 +1036,8 @@ function Test-CollectorRunning {
) )
`$escapedCollector = [Regex]::Escape(`$ScriptPath) `$escapedCollector = [Regex]::Escape(`$ScriptPath)
`$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | `$processes = Get-CollectorProcessSnapshot |
Where-Object { Where-Object {
(`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
`$_.SessionId -eq `$SessionId -and `$_.SessionId -eq `$SessionId -and
`$_.CommandLine -match `$escapedCollector `$_.CommandLine -match `$escapedCollector
} }
@@ -998,14 +1046,7 @@ function Test-CollectorRunning {
} }
function Get-CollectorPowerShellProcessCount { function Get-CollectorPowerShellProcessCount {
`$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | return @(Get-CollectorProcessSnapshot).Count
Where-Object {
(`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
`$_.CommandLine -match 'AWatch-rus' -and
`$_.CommandLine -match '\.ps1'
}
return @(`$processes).Count
} }
function New-LaunchLock { function New-LaunchLock {
@@ -1908,8 +1949,15 @@ function Write-ActivityWatchHiddenPowerShellWrapper {
$escapedConfigPath = $ConfigPath.Replace('"', '""') $escapedConfigPath = $ConfigPath.Replace('"', '""')
$content = @" $content = @"
On Error Resume Next
Set shell = CreateObject("WScript.Shell") Set shell = CreateObject("WScript.Shell")
shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False q = Chr(34)
command = q & "$escapedPowerShellExe" & q & " -NoProfile -ExecutionPolicy Bypass -File " & q & "$escapedScriptPath" & q & " -ConfigPath " & q & "$escapedConfigPath" & q
shell.Run command, 0, False
If Err.Number <> 0 Then
WScript.Quit 1
End If
WScript.Quit 0
"@ "@
Set-Content -LiteralPath $Path -Value $content -Encoding ASCII Set-Content -LiteralPath $Path -Value $content -Encoding ASCII
@@ -1927,7 +1975,7 @@ function Remove-LegacyActivityWatchEntries {
) )
foreach ($taskName in $legacyTaskNames) { foreach ($taskName in $legacyTaskNames) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue Remove-ActivityWatchScheduledTask -TaskName $taskName
} }
$legacyTaskPatterns = @( $legacyTaskPatterns = @(
@@ -1943,7 +1991,15 @@ function Remove-LegacyActivityWatchEntries {
'ActivityWatch File1C Upload' 'ActivityWatch File1C Upload'
) )
foreach ($task in @(Get-ScheduledTask -ErrorAction SilentlyContinue)) { $scheduledTasks = @()
try {
$scheduledTasks = @(Get-ScheduledTask -ErrorAction Stop)
}
catch {
$scheduledTasks = @()
}
foreach ($task in $scheduledTasks) {
$taskName = [string]$task.TaskName $taskName = [string]$task.TaskName
if ([string]::IsNullOrWhiteSpace($taskName) -or $managedTaskNames -contains $taskName -or $taskName -like 'ActivityWatch Launch *') { if ([string]::IsNullOrWhiteSpace($taskName) -or $managedTaskNames -contains $taskName -or $taskName -like 'ActivityWatch Launch *') {
continue continue
@@ -1985,11 +2041,28 @@ function Remove-ActivityWatchScheduledTask {
[string]$TaskName [string]$TaskName
) )
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue try {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop
}
catch {
}
& cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null
if ($LASTEXITCODE -eq 0) {
return
}
for ($attempt = 0; $attempt -lt 10; $attempt++) { for ($attempt = 0; $attempt -lt 10; $attempt++) {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue $task = $null
try {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
}
catch {
& cmd.exe /c "schtasks /Query /TN `"$TaskName`" >nul 2>&1" | Out-Null
if ($LASTEXITCODE -ne 0) {
return
}
}
if (-not $task) { if (-not $task) {
return return
} }
@@ -2138,11 +2211,45 @@ function Register-ActivityWatchRecoveryTask {
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
$trigger = New-ScheduledTaskTrigger -AtStartup $sessionRecords = @()
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest try {
$sessionRecords = @(Get-ActivityWatchSessionRecords)
}
catch {
$sessionRecords = @()
}
$liveSession = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords) | Select-Object -First 1
$interactiveUserId = $null
if ($liveSession -and -not [string]::IsNullOrWhiteSpace([string]$liveSession.UserName)) {
$rawUser = [string]$liveSession.UserName
$interactiveUserId = if ($rawUser -match '^[^\\]+\\') { $rawUser } else { ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) }
}
if ($interactiveUserId) {
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $interactiveUserId
$principal = New-ScheduledTaskPrincipal -UserId $interactiveUserId -LogonType Interactive -RunLevel Highest
}
else {
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
}
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null try {
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -ErrorAction Stop | Out-Null
}
catch {
$taskCommand = ('"{0}" {1}' -f $wscriptExe, $action.Arguments)
if ($interactiveUserId) {
& schtasks.exe /Create /TN $TaskName /SC ONLOGON /RU $interactiveUserId /IT /RL HIGHEST /F /TR $taskCommand | Out-Null
}
else {
& schtasks.exe /Create /TN $TaskName /SC ONSTART /RU SYSTEM /RL HIGHEST /F /TR $taskCommand | Out-Null
}
if ($LASTEXITCODE -ne 0) {
throw
}
}
} }
function Register-ActivityWatchHayabusaAutoUploadTask { function Register-ActivityWatchHayabusaAutoUploadTask {
+2
View File
@@ -27,6 +27,7 @@ param(
[int]$EvtxRetentionDays = 14, [int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(), [string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[string]$AwHostname, [string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
@@ -140,6 +141,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EvtxRetentionDays $EvtxRetentionDays ` -EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels ` -EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname ` -AwHostname $AwHostname `
-PolicyMode $PolicyMode ` -PolicyMode $PolicyMode `
-PolicyEngineEnabled $PolicyEngineEnabled ` -PolicyEngineEnabled $PolicyEngineEnabled `
+3
View File
@@ -27,6 +27,7 @@ param(
[int]$EvtxRetentionDays = 14, [int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(), [string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[string]$AwHostname, [string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
@@ -99,6 +100,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-EvtxRetentionDays $EvtxRetentionDays ` -EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels ` -EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname ` -AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath ` -CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath ` -CustomPolicyPath $CustomPolicyPath `
@@ -145,6 +147,7 @@ if (-not $SkipHardening) {
-EvtxRetentionDays $EvtxRetentionDays ` -EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels ` -EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname ` -AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath ` -CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath ` -CustomPolicyPath $CustomPolicyPath `
+2
View File
@@ -25,6 +25,7 @@ param(
[int]$EvtxRetentionDays = 14, [int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(), [string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[string]$AwHostname, [string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath [string]$CustomPolicyPath
@@ -102,6 +103,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EvtxRetentionDays $EvtxRetentionDays ` -EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels ` -EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname ` -AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath ` -LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath ` -RecoveryScriptPath $recoveryScriptPath `
+14 -5
View File
@@ -170,10 +170,19 @@ function Get-1CFileInfobases {
} }
function Get-HostSample { function Get-HostSample {
$os = Get-CimInstance Win32_OperatingSystem $cpu = 0.0
$cpuSample = Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | $ramPct = 0.0
Measure-Object -Property LoadPercentage -Average try {
$cpu = if ($cpuSample.Count -gt 0 -and $null -ne $cpuSample.Average) { [double]$cpuSample.Average } else { 0 } Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
$computerInfo = New-Object Microsoft.VisualBasic.Devices.ComputerInfo
$totalMemory = [double]$computerInfo.TotalPhysicalMemory
$availableMemory = [double]$computerInfo.AvailablePhysicalMemory
if ($totalMemory -gt 0) {
$ramPct = (($totalMemory - $availableMemory) / $totalMemory) * 100
}
} catch {
Write-RunLog ("warning: host memory sample fallback reason={0}" -f $_.Exception.Message)
}
$disk = Get-PSDrive -Name E -ErrorAction SilentlyContinue $disk = Get-PSDrive -Name E -ErrorAction SilentlyContinue
$rdp = (quser 2>$null | Select-Object -Skip 1 | Measure-Object).Count $rdp = (quser 2>$null | Select-Object -Skip 1 | Measure-Object).Count
@@ -181,7 +190,7 @@ function Get-HostSample {
ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
host = $env:COMPUTERNAME host = $env:COMPUTERNAME
cpu_pct = [math]::Round($cpu, 2) cpu_pct = [math]::Round($cpu, 2)
ram_pct = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 2) ram_pct = [math]::Round($ramPct, 2)
disk_free_gb = if ($disk) { [math]::Round($disk.Free / 1GB, 2) } else { 0 } disk_free_gb = if ($disk) { [math]::Round($disk.Free / 1GB, 2) } else { 0 }
disk_latency_ms = 0 disk_latency_ms = 0
smb_errors = 0 smb_errors = 0
+3
View File
@@ -24,6 +24,7 @@ param(
[int]$EvtxRetentionDays, [int]$EvtxRetentionDays,
[string[]]$EvtxChannels, [string[]]$EvtxChannels,
[bool]$LogonMarkerEnabled, [bool]$LogonMarkerEnabled,
[bool]$ProcessEventsEnabled,
[string]$AwHostname, [string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
@@ -93,6 +94,7 @@ $effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot')
$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 } $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 { @() } $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 } $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 }
$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } 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 } $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' } $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' } $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' }
@@ -202,6 +204,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EvtxRetentionDays $effectiveEvtxRetentionDays ` -EvtxRetentionDays $effectiveEvtxRetentionDays `
-EvtxChannels $effectiveEvtxChannels ` -EvtxChannels $effectiveEvtxChannels `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled `
-ProcessEventsEnabled $effectiveProcessEventsEnabled `
-AwHostname $effectiveAwHostname ` -AwHostname $effectiveAwHostname `
-PolicyMode $effectivePolicyMode ` -PolicyMode $effectivePolicyMode `
-PolicyEngineEnabled $effectivePolicyEngineEnabled ` -PolicyEngineEnabled $effectivePolicyEngineEnabled `
+15
View File
@@ -36,6 +36,15 @@ $queueMaxDepth = 1000
$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } $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 } $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 } $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true }
$sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null }
$sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false }
$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $true }
$sessionEventsBucketId = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$sessionEventsConfig.bucketPrefix)) {
('{0}_{1}' -f [string]$sessionEventsConfig.bucketPrefix, $awHostname)
}
else {
'aw-session-events_' + $awHostname
}
function Get-LoggedOnUsers { function Get-LoggedOnUsers {
param( param(
@@ -535,6 +544,12 @@ $result = [ordered]@{
jobTitlePolicyEnabled = $printJobTitlePolicyEnabled jobTitlePolicyEnabled = $printJobTitlePolicyEnabled
ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled) ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled)
} }
sessionEvents = [ordered]@{
bucketId = $sessionEventsBucketId
logonEnabled = [bool]$sessionLogonEnabled
processEventsEnabled = [bool]$sessionProcessEventsEnabled
ok = $true
}
forensics = [ordered]@{ forensics = [ordered]@{
evtxExportRoot = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$config.forensics.evtxExportRoot } else { $null } 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 } retentionDays = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$config.forensics.retentionDays } else { $null }
+277 -2
View File
@@ -94,11 +94,13 @@ function Ensure-Bucket {
param( param(
[Parameter(Mandatory = $true)][string]$ApiBase, [Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId, [Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue [Parameter(Mandatory = $true)][string]$HostnameValue,
[string]$ClientName = 'aw-worktime-session-collector',
[string]$BucketType = 'aw.worktime.session'
) )
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" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }
$body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress $body = @{ client=$ClientName; type=$BucketType; hostname=$HostnameValue } | ConvertTo-Json -Compress
$attempts = 0 $attempts = 0
while ($attempts -lt 3) { while ($attempts -lt 3) {
$attempts++ $attempts++
@@ -230,16 +232,282 @@ function Get-CanonicalUserId {
return "$HostnameValue\$normalizedUser" return "$HostnameValue\$normalizedUser"
} }
function Get-SessionEventsBucketId {
param(
[pscustomobject]$Config,
[string]$HostnameValue
)
$prefix = 'aw-session-events'
if (
$Config -and
$Config.PSObject.Properties.Name -contains 'sessionEvents' -and
$Config.sessionEvents -and
$Config.sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and
-not [string]::IsNullOrWhiteSpace([string]$Config.sessionEvents.bucketPrefix)
) {
$prefix = [string]$Config.sessionEvents.bucketPrefix
}
return ('{0}_{1}' -f $prefix, $HostnameValue)
}
function Test-SessionProcessEventsEnabled {
param([pscustomobject]$Config)
if (
$Config -and
$Config.PSObject.Properties.Name -contains 'sessionEvents' -and
$Config.sessionEvents -and
$Config.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled'
) {
return [bool]$Config.sessionEvents.processEventsEnabled
}
return $true
}
function Get-ProcessStatePath {
param([pscustomobject]$Config)
$stateRoot = ''
if ($Config -and $Config.PSObject.Properties.Name -contains 'paths' -and $Config.paths) {
if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') {
$stateRoot = [string]$Config.paths.stateRoot
}
}
if ([string]::IsNullOrWhiteSpace($stateRoot)) {
$stateRoot = 'C:\ProgramData\AWatch-rus'
}
return (Join-Path $stateRoot 'session-process-state.json')
}
function Load-ProcessState {
param([string]$Path)
$map = @{}
try {
if (Test-Path -LiteralPath $Path) {
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
if (-not [string]::IsNullOrWhiteSpace($raw)) {
$obj = $raw | ConvertFrom-Json -ErrorAction Stop
foreach ($item in @($obj.processes)) {
if (-not $item) { continue }
$key = [string]$item.key
if ([string]::IsNullOrWhiteSpace($key)) { continue }
$map[$key] = $item
}
}
}
}
catch {
Write-Verbose "Process state load error: $($_.Exception.Message)"
}
return $map
}
function Save-ProcessState {
param(
[string]$Path,
[hashtable]$Map
)
try {
$dir = Split-Path -Path $Path -Parent
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}
$items = @()
foreach ($entry in $Map.GetEnumerator()) {
$value = $entry.Value
if ($null -eq $value) { continue }
$items += [pscustomobject]@{
key = [string]$entry.Key
processId = [int]$value.processId
sessionId = [int]$value.sessionId
username = [string]$value.username
userId = [string]$value.userId
state = [string]$value.state
processName = [string]$value.processName
commandLine = [string]$value.commandLine
createdAt = [string]$value.createdAt
hostname = [string]$value.hostname
}
}
$payload = [pscustomobject]@{ processes = $items }
$payload | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding UTF8
}
catch {
Write-Verbose "Process state save error: $($_.Exception.Message)"
}
}
function Test-ExcludedSessionProcess {
param(
[string]$Name,
[string]$CommandLine
)
$n = [string]$Name
if ([string]::IsNullOrWhiteSpace($n)) { return $true }
if ($n -match '^(Idle|System|Registry|svchost|services|lsass|winlogon|csrss|fontdrvhost|dwm|taskhostw|sihost|explorer)\.exe$') { return $true }
if ($n -match '^(aw-watcher-afk|aw-watcher-window|conhost)\.exe$') { return $true }
return $false
}
function Get-SessionProcessSnapshot {
param(
[pscustomobject]$Config,
[string]$HostnameValue,
[object[]]$SessionRecords
)
$bySession = @{}
foreach ($rec in @($SessionRecords)) {
if ($null -eq $rec) { continue }
$sid = [int]$rec.sessionId
$bySession[$sid] = [pscustomobject]@{
username = [string]$rec.username
userId = Get-CanonicalUserId -Config $Config -HostnameValue $HostnameValue -Username ([string]$rec.username)
state = [string]$rec.state
}
}
$snapshot = @{}
if ($bySession.Count -eq 0) {
return $snapshot
}
try {
$procs = Get-Process -ErrorAction Stop | Where-Object { $bySession.ContainsKey([int]$_.SessionId) }
}
catch {
Write-Verbose "Process snapshot error: $($_.Exception.Message)"
return $snapshot
}
foreach ($proc in @($procs)) {
try {
$sid = [int]$proc.SessionId
}
catch {
continue
}
if (-not $bySession.ContainsKey($sid)) { continue }
$name = [string]$proc.ProcessName
if ($name -and $name -notmatch '\.exe$') {
$name = "$name.exe"
}
$commandLine = ''
if (Test-ExcludedSessionProcess -Name $name -CommandLine $commandLine) { continue }
$createdAt = ''
try {
if ($proc.StartTime) {
$createdAt = $proc.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
}
}
catch {
$createdAt = ''
}
if ([string]::IsNullOrWhiteSpace($createdAt)) {
$createdAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
}
$key = ('{0}|{1}|{2}' -f $sid, [int]$proc.Id, $createdAt)
$sessionMeta = $bySession[$sid]
$snapshot[$key] = [pscustomobject]@{
processId = [int]$proc.Id
sessionId = $sid
username = [string]$sessionMeta.username
userId = [string]$sessionMeta.userId
state = [string]$sessionMeta.state
processName = $name
commandLine = $commandLine
createdAt = $createdAt
hostname = $HostnameValue
}
}
return $snapshot
}
function Publish-SessionProcessEvents {
param(
[string]$ApiBase,
[string]$BucketId,
[hashtable]$Previous,
[hashtable]$Current
)
foreach ($entry in $Current.GetEnumerator()) {
if ($Previous.ContainsKey($entry.Key)) { continue }
$item = $entry.Value
$payload = [pscustomobject]@{
timestamp = [string]$item.createdAt
duration = 0
data = [pscustomobject]@{
eventType = 'process_start'
username = [string]$item.username
userId = [string]$item.userId
sessionId = [int]$item.sessionId
state = [string]$item.state
processId = [int]$item.processId
processName = [string]$item.processName
commandLine = [string]$item.commandLine
createdAt = [string]$item.createdAt
hostname = [string]$item.hostname
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
[void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload)
}
catch {
Write-Verbose "Process start publish error: $($_.Exception.Message)"
}
}
$nowUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
foreach ($entry in $Previous.GetEnumerator()) {
if ($Current.ContainsKey($entry.Key)) { continue }
$item = $entry.Value
$payload = [pscustomobject]@{
timestamp = $nowUtc
duration = 0
data = [pscustomobject]@{
eventType = 'process_stop'
username = [string]$item.username
userId = [string]$item.userId
sessionId = [int]$item.sessionId
state = [string]$item.state
processId = [int]$item.processId
processName = [string]$item.processName
commandLine = [string]$item.commandLine
createdAt = [string]$item.createdAt
hostname = [string]$item.hostname
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
[void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload)
}
catch {
Write-Verbose "Process stop publish error: $($_.Exception.Message)"
}
}
}
# Main # Main
$cfg = Get-Config -Path $ConfigPath $cfg = Get-Config -Path $ConfigPath
$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 } $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.' } 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 $bucketId = 'aw-worktime-sessions_' + $hostValue
$sessionEventsBucketId = Get-SessionEventsBucketId -Config $cfg -HostnameValue $hostValue
$processEventsEnabled = Test-SessionProcessEventsEnabled -Config $cfg
$processStatePath = Get-ProcessStatePath -Config $cfg
$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) $pulse = [Math]::Max($sleepSec * 3, 30)
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
if ($processEventsEnabled) {
Ensure-Bucket -ApiBase $apiBase -BucketId $sessionEventsBucketId -HostnameValue $hostValue -ClientName 'aw-session-events' -BucketType 'aw.session.event'
$previousProcessState = Load-ProcessState -Path $processStatePath
}
else {
$previousProcessState = @{}
}
while ($true) { while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
@@ -265,6 +533,13 @@ while ($true) {
) )
} }
if ($processEventsEnabled) {
$currentProcessState = Get-SessionProcessSnapshot -Config $cfg -HostnameValue $hostValue -SessionRecords $records
Publish-SessionProcessEvents -ApiBase $apiBase -BucketId $sessionEventsBucketId -Previous $previousProcessState -Current $currentProcessState
Save-ProcessState -Path $processStatePath -Map $currentProcessState
$previousProcessState = $currentProcessState
}
foreach ($rec in $records) { foreach ($rec in $records) {
$canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username) $canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username)
$payloadObj = [PSCustomObject]@{ $payloadObj = [PSCustomObject]@{