feat(pfsense): add AW poller and host group sections

This commit is contained in:
igor04091968
2026-04-27 04:41:32 +03:00
parent 70ffd1f3e8
commit e80ae5fbc3
13 changed files with 702 additions and 3 deletions
+5
View File
@@ -13,6 +13,7 @@
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox. - `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI. - `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT). - `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`). - `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`).
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок. - `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
@@ -37,6 +38,10 @@
- `ansible/deploy_aw_windows_phase2.yml` - `ansible/deploy_aw_windows_phase2.yml`
Для внешнего pfSense poller'а:
- `ansible/deploy_aw_pfsense_poller.yml`
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают: Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
- `secrets/deploy.secrets.env` - `secrets/deploy.secrets.env`
+23
View File
@@ -5,6 +5,7 @@
- деплой на уже существующий Debian host/CT; - деплой на уже существующий Debian host/CT;
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch. - полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch.
- централизованный деплой Windows phase-2 collectors по WinRM. - централизованный деплой Windows phase-2 collectors по WinRM.
- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM.
## Файлы ## Файлы
@@ -12,11 +13,13 @@
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml` — full-stack playbook для Proxmox. - `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml` — full-stack playbook для Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT). - `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов. - `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml` — deployment pfSense poller'а.
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory. - `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных. - `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox. - `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT. - `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2. - `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а.
## Быстрый запуск ## Быстрый запуск
@@ -86,11 +89,31 @@ Playbook:
- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов; - `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов;
- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта. - `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта.
## pfSense poller rollout
1. Подготовьте vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml`
2. Добавьте inventory group `[aw_pfsense_pollers]`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
```
Playbook:
- ставит `python3`;
- копирует `pfsense-aw-poller.py`;
- пишет `/etc/aw-pfsense/poller.json`;
- поднимает `aw-pfsense-poller.service`.
## Результат ## Результат
- Установлен ActivityWatch Server. - Установлен ActivityWatch Server.
- Создан systemd-unit `activitywatch-server.service`. - Создан systemd-unit `activitywatch-server.service`.
- Установлен RU Web UI patch. - Установлен RU Web UI patch.
- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя. - Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя.
- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`.
- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`. - Выполнена валидация API `http://127.0.0.1:5600/api/0/info`.
- Для full-stack сценария CT создаётся автоматически через `pct create`. - Для full-stack сценария CT создаётся автоматически через `pct create`.
+66
View File
@@ -0,0 +1,66 @@
---
- name: Deploy pfSense ActivityWatch poller
hosts: aw_pfsense_pollers
become: true
gather_facts: true
vars:
aw_pfsense_install_root: "/opt/aw-pfsense"
aw_pfsense_config_dir: "/etc/aw-pfsense"
aw_pfsense_service_name: "aw-pfsense-poller.service"
tasks:
- name: Install required packages
ansible.builtin.apt:
name:
- python3
state: present
update_cache: true
- name: Ensure directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ aw_pfsense_install_root }}"
- "{{ aw_pfsense_config_dir }}"
- name: Install pfSense poller script
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
mode: "0755"
- name: Install systemd service
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
mode: "0644"
notify:
- Reload systemd
- name: Write pfSense poller config
ansible.builtin.copy:
dest: "{{ aw_pfsense_config_dir }}/poller.json"
mode: "0600"
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
notify:
- Restart pfSense poller
- name: Enable and start pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
enabled: true
state: restarted
daemon_reload: true
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
state: restarted
+2
View File
@@ -11,6 +11,7 @@
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap" aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}" aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}" aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}" aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}" aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
@@ -92,6 +93,7 @@
- { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
notify: notify:
- Reload systemd - Reload systemd
- Restart activitywatch - Restart activitywatch
@@ -0,0 +1,30 @@
aw_pfsense_poller_config:
poll_interval_seconds: 60
aw:
server_host: "10.10.10.13"
server_port: 5600
hostname: "PFSENSE-EDGE01"
pulse_time_seconds: 120
timeout_seconds: 15
pfsense:
name: "pfSense Edge 01"
host: "10.10.10.1"
scheme: "https"
verify_tls: false
timeout_seconds: 15
headers:
X-API-Key: "replace-me"
X-API-Secret: "replace-me"
endpoints:
- name: "system-status"
path: "/api/v2/status/system"
bucket_prefix: "aw-pfsense-health"
bucket_type: "aw.pfsense.health"
- name: "interfaces"
path: "/api/v2/interface"
bucket_prefix: "aw-pfsense-interfaces"
bucket_type: "aw.pfsense.interfaces"
- name: "gateways"
path: "/api/v2/status/gateways"
bucket_prefix: "aw-pfsense-gateways"
bucket_type: "aw.pfsense.gateways"
+3
View File
@@ -12,6 +12,7 @@ source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-/opt/activitywatch/webui-ru}" WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-/opt/activitywatch/webui-ru}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js" PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js" SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
INDEX_HTML="$WEBUI_DIR/index.html" INDEX_HTML="$WEBUI_DIR/index.html"
SERVICE_WORKER="$WEBUI_DIR/service-worker.js" SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
TS=$(date +%Y%m%d%H%M%S) TS=$(date +%Y%m%d%H%M%S)
@@ -22,11 +23,13 @@ TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; } [[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; } [[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; } [[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
install -d "$WEBUI_DIR/js" install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$WEBUI_DIR/js/aw-ru-patch.js" install -m 0644 "$PATCH_JS_SRC" "$WEBUI_DIR/js/aw-ru-patch.js"
install -m 0644 "$SW_CLEANUP_SRC" "$WEBUI_DIR/js/aw-sw-cleanup.js" install -m 0644 "$SW_CLEANUP_SRC" "$WEBUI_DIR/js/aw-sw-cleanup.js"
install -m 0644 "$HOST_GROUPS_SRC" "$WEBUI_DIR/js/aw-host-groups.json"
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS" cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML" sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
+30
View File
@@ -0,0 +1,30 @@
{
"groups": [
{
"id": "windows-rdp",
"name": "Windows RDP",
"description": "Пользовательские Windows/RDP хосты с активностью, DLP и рабочим временем.",
"patterns": [
"^(SHARKON|WIN|RDP|TERM|TS-|WS-)"
],
"links": [
{ "label": "Активность", "type": "activity" },
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
]
},
{
"id": "virtual-infra",
"name": "Virtual servers + Proxmox",
"description": "Инфраструктурные VM и сетевые узлы. Здесь должны лежать Proxmox, pfSense, Debian и Ubuntu серверы.",
"patterns": [
"^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"
],
"links": [
{ "label": "pfSense health", "type": "bucket", "bucket_prefix": "aw-pfsense-health_" },
{ "label": "pfSense gateways", "type": "bucket", "bucket_prefix": "aw-pfsense-gateways_" },
{ "label": "Все бакеты", "type": "buckets" }
]
}
],
"ungrouped_name": "Прочие хосты"
}
+223 -3
View File
@@ -1,6 +1,6 @@
(function () { (function () {
window.__awRuPatchVersion = "template-v4-dlp-review"; window.__awRuPatchVersion = "template-v5-dlp-review-host-groups";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v4-dlp-review"); document.documentElement.setAttribute("data-aw-ru-patch", "template-v5-dlp-review-host-groups");
const exact = new Map([ const exact = new Map([
["ActivityWatch", "АктивВотч"], ["ActivityWatch", "АктивВотч"],
@@ -322,7 +322,17 @@
'.aw-ru-dlp-message { margin-top: 8px; font-size: 12px; }', '.aw-ru-dlp-message { margin-top: 8px; font-size: 12px; }',
'.aw-ru-dlp-actions { display: flex; gap: 6px; flex-wrap: wrap; }', '.aw-ru-dlp-actions { display: flex; gap: 6px; flex-wrap: wrap; }',
'.aw-ru-dlp-section { margin-top: 18px; }', '.aw-ru-dlp-section { margin-top: 18px; }',
'.aw-ru-dlp-section h5 { margin: 0 0 8px; }' '.aw-ru-dlp-section h5 { margin: 0 0 8px; }',
'.aw-ru-host-groups { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(20,20,20,.03); }',
'.aw-ru-host-groups-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }',
'.aw-ru-host-group-card { border: 1px solid rgba(120,120,120,.25); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }',
'.aw-ru-host-group-card h4 { margin: 0 0 8px; }',
'.aw-ru-host-group-card p { margin: 0 0 12px; font-size: 13px; opacity: .85; }',
'.aw-ru-host-list { display: flex; flex-direction: column; gap: 8px; }',
'.aw-ru-host-item { border: 1px solid rgba(120,120,120,.2); border-radius: 6px; padding: 8px; }',
'.aw-ru-host-item-title { font-weight: 600; margin-bottom: 6px; }',
'.aw-ru-host-links { display: flex; flex-wrap: wrap; gap: 6px; }',
'.aw-ru-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }'
].join("\n"); ].join("\n");
document.head.appendChild(style); document.head.appendChild(style);
} }
@@ -619,6 +629,215 @@
} }
} }
function isHomeRoute() {
const hash = window.location.hash || "";
return !hash || /^#\/home(?:[/?#]|$)/i.test(hash);
}
function getDefaultHostGroupsConfig() {
return {
groups: [
{
id: "windows-rdp",
name: "Windows RDP",
description: "Пользовательские Windows/RDP хосты.",
patterns: ["^(SHARKON|WIN|RDP|TERM|TS-|WS-)"],
links: [
{ label: "Активность", type: "activity" },
{ label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" }
]
},
{
id: "virtual-infra",
name: "Virtual servers + Proxmox",
description: "Инфраструктурные VM, Proxmox и сетевые узлы.",
patterns: ["^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"],
links: [
{ label: "pfSense health", type: "bucket", bucket_prefix: "aw-pfsense-health_" },
{ label: "pfSense gateways", type: "bucket", bucket_prefix: "aw-pfsense-gateways_" },
{ label: "Все бакеты", type: "buckets" }
]
}
],
ungrouped_name: "Прочие хосты"
};
}
function getHostGroupsState() {
if (!window.__awRuHostGroupsState) {
window.__awRuHostGroupsState = {
config: null,
buckets: null,
loading: false
};
}
return window.__awRuHostGroupsState;
}
async function ensureHostGroupsData() {
const state = getHostGroupsState();
if (state.loading) return state;
if (state.config && state.buckets) return state;
state.loading = true;
try {
if (!state.config) {
try {
state.config = await awApiJson("/js/aw-host-groups.json?v=" + encodeURIComponent(window.__awRuPatchVersion), { method: "GET", headers: {} });
} catch (error) {
state.config = getDefaultHostGroupsConfig();
}
}
if (!state.buckets) {
state.buckets = await awApiJson("/api/0/buckets/", { method: "GET", headers: {} });
}
} finally {
state.loading = false;
}
return state;
}
function extractHostFromBucket(bucketId, bucketMeta) {
if (bucketMeta && bucketMeta.hostname) return String(bucketMeta.hostname);
const prefixes = [
"aw-watcher-window_",
"aw-watcher-afk_",
"aw-dlp-endpoint-signals_",
"aw-dlp-incidents_",
"aw-pfsense-health_",
"aw-pfsense-gateways_",
"aw-pfsense-interfaces_"
];
for (const prefix of prefixes) {
if (bucketId.indexOf(prefix) === 0) {
return bucketId.slice(prefix.length);
}
}
return "";
}
function buildHostBucketMap(rawBuckets) {
const result = new Map();
const entries = Array.isArray(rawBuckets)
? rawBuckets.map(function (item) { return [item.id || "", item]; })
: Object.entries(rawBuckets || {});
entries.forEach(function (entry) {
const bucketId = entry[0];
const meta = entry[1] || {};
const host = extractHostFromBucket(bucketId, meta);
if (!host) return;
if (!result.has(host)) result.set(host, []);
result.get(host).push(bucketId);
});
return result;
}
function matchHostGroup(host, groups) {
for (const group of groups) {
const patterns = Array.isArray(group.patterns) ? group.patterns : [];
for (const pattern of patterns) {
try {
if (new RegExp(pattern, "i").test(host)) {
return group.id;
}
} catch (error) {
}
}
}
return "";
}
function buildHostLink(host, hostBuckets, linkDef) {
if (!linkDef || !linkDef.type) return "";
if (linkDef.type === "activity") {
return '#/activity/' + encodeURIComponent(host) + '/day/' + encodeURIComponent(new Date().toISOString().slice(0, 10)) + '/view/summary';
}
if (linkDef.type === "buckets") {
return "#/buckets";
}
if (linkDef.type === "bucket" && linkDef.bucket_prefix) {
const bucketId = String(linkDef.bucket_prefix) + host;
return hostBuckets.indexOf(bucketId) >= 0 ? '#/buckets/' + encodeURIComponent(bucketId) : "";
}
return "";
}
function renderHostGroupCards(state) {
const config = state.config || getDefaultHostGroupsConfig();
const groups = Array.isArray(config.groups) ? config.groups : [];
const hostBuckets = buildHostBucketMap(state.buckets);
const grouped = new Map();
groups.forEach(function (group) {
grouped.set(group.id, []);
});
grouped.set("__ungrouped__", []);
Array.from(hostBuckets.keys()).sort().forEach(function (host) {
const groupId = matchHostGroup(host, groups) || "__ungrouped__";
grouped.get(groupId).push(host);
});
const cards = [];
groups.forEach(function (group) {
const hosts = grouped.get(group.id) || [];
const items = hosts.map(function (host) {
const links = (group.links || []).map(function (linkDef) {
const href = buildHostLink(host, hostBuckets.get(host) || [], linkDef);
return href ? '<a href="' + escapeHtml(href) + '">' + escapeHtml(linkDef.label || "Открыть") + '</a>' : "";
}).filter(Boolean).join("");
return '<div class="aw-ru-host-item">' +
'<div class="aw-ru-host-item-title">' + escapeHtml(host) + '</div>' +
'<div class="aw-ru-host-links">' + links + '</div>' +
'</div>';
}).join("");
cards.push(
'<section class="aw-ru-host-group-card">' +
'<h4>' + escapeHtml(group.name || group.id) + '</h4>' +
'<p>' + escapeHtml(group.description || "") + '</p>' +
'<div class="aw-ru-host-list">' + (items || '<div class="aw-ru-host-item">Хосты пока не обнаружены.</div>') + '</div>' +
'</section>'
);
});
const ungroupedHosts = grouped.get("__ungrouped__") || [];
if (ungroupedHosts.length) {
cards.push(
'<section class="aw-ru-host-group-card">' +
'<h4>' + escapeHtml(config.ungrouped_name || "Прочие хосты") + '</h4>' +
'<p>Хосты, которые пока не попали под шаблоны группировки.</p>' +
'<div class="aw-ru-host-list">' +
ungroupedHosts.map(function (host) {
return '<div class="aw-ru-host-item"><div class="aw-ru-host-item-title">' + escapeHtml(host) + '</div></div>';
}).join("") +
'</div>' +
'</section>'
);
}
return cards.join("");
}
async function injectHostGroupsCenter(root) {
if (!isHomeRoute()) return;
const heading = root.querySelector("h3");
if (!heading) return;
let center = root.querySelector("[data-aw-ru-host-groups='1']");
if (!center) {
center = document.createElement("section");
center.className = "aw-ru-host-groups";
center.setAttribute("data-aw-ru-host-groups", "1");
center.innerHTML =
'<h4>Разделы хостов</h4>' +
'<p>Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.</p>' +
'<div class="aw-ru-host-groups-grid" data-aw-ru-host-groups-grid><section class="aw-ru-host-group-card"><p>Загрузка...</p></section></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
const state = await ensureHostGroupsData();
center.querySelector("[data-aw-ru-host-groups-grid]").innerHTML = renderHostGroupCards(state);
}
function renderDlpTableRows(center, host) { function renderDlpTableRows(center, host) {
const state = getSuppressionState(); const state = getSuppressionState();
const tbody = center.querySelector("[data-aw-ru-dlp-events]"); const tbody = center.querySelector("[data-aw-ru-dlp-events]");
@@ -1144,6 +1363,7 @@
injectDlpNavigation(document.body); injectDlpNavigation(document.body);
injectDlpReviewCenter(document.body); injectDlpReviewCenter(document.body);
injectDlpAlertsCenter(document.body); injectDlpAlertsCenter(document.body);
injectHostGroupsCenter(document.body).catch(function () {});
redirectBareTrendsRoute(); redirectBareTrendsRoute();
} }
+59
View File
@@ -0,0 +1,59 @@
# pfSense -> ActivityWatch
Для `pfSense` используется внешний poller на `Debian/Ubuntu` utility VM. Сам firewall не трогаем Windows-агентами и не превращаем в desktop-client.
## Схема
- `pfSense` отвечает по API.
- `Debian/Ubuntu` poller VM опрашивает его с интервалом.
- poller отправляет события в общий `AW server`.
## Bucket'ы
Базовая конфигурация пишет:
- `aw-pfsense-health_<HOST>`
- `aw-pfsense-interfaces_<HOST>`
- `aw-pfsense-gateways_<HOST>`
## Файлы
- [pfsense-aw-poller.py](/home/igor/tmp/AWatch-rus/pfsense/pfsense-aw-poller.py)
- [pfsense-aw-poller.service](/home/igor/tmp/AWatch-rus/pfsense/pfsense-aw-poller.service)
- [pfsense-aw-poller.example.json](/home/igor/tmp/AWatch-rus/pfsense/pfsense-aw-poller.example.json)
- [deploy_aw_pfsense_poller.yml](/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml)
- [pfsense-poller.example.yml](/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml)
## Ручной запуск
```bash
sudo install -d /opt/aw-pfsense /etc/aw-pfsense
sudo cp /path/to/pfsense-aw-poller.py /opt/aw-pfsense/
sudo chmod 0755 /opt/aw-pfsense/pfsense-aw-poller.py
sudo cp /path/to/poller.json /etc/aw-pfsense/poller.json
python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json --once
```
## Через Ansible
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cp group_vars/pfsense-poller.example.yml group_vars/pfsense-poller.yml
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
```
Inventory:
```ini
[aw_pfsense_pollers]
aw-poller-01 ansible_host=10.10.10.50
```
## AW Web
На `#/home` добавлен отдельный блок:
- `Windows RDP`
- `Virtual servers + Proxmox`
Для `pfSense` задавай hostname вида `PFSENSE-EDGE01`, тогда он попадёт во вторую группу.
+41
View File
@@ -0,0 +1,41 @@
{
"poll_interval_seconds": 60,
"aw": {
"server_host": "10.10.10.13",
"server_port": 5600,
"hostname": "PFSENSE-EDGE01",
"pulse_time_seconds": 120,
"timeout_seconds": 15
},
"pfsense": {
"name": "pfSense Edge 01",
"host": "10.10.10.1",
"scheme": "https",
"verify_tls": false,
"timeout_seconds": 15,
"headers": {
"X-API-Key": "replace-me",
"X-API-Secret": "replace-me"
}
},
"endpoints": [
{
"name": "system-status",
"path": "/api/v2/status/system",
"bucket_prefix": "aw-pfsense-health",
"bucket_type": "aw.pfsense.health"
},
{
"name": "interfaces",
"path": "/api/v2/interface",
"bucket_prefix": "aw-pfsense-interfaces",
"bucket_type": "aw.pfsense.interfaces"
},
{
"name": "gateways",
"path": "/api/v2/status/gateways",
"bucket_prefix": "aw-pfsense-gateways",
"bucket_type": "aw.pfsense.gateways"
}
]
}
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
import argparse
import json
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
def utc_now_iso():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def load_json(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def build_ssl_context(verify_tls):
if verify_tls:
return None
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def http_json(url, method="GET", headers=None, body=None, timeout=15, ssl_context=None):
payload = None
effective_headers = {"Content-Type": "application/json; charset=utf-8"}
if headers:
effective_headers.update(headers)
if body is not None:
payload = json.dumps(body).encode("utf-8")
request = urllib.request.Request(url, data=payload, headers=effective_headers, method=method)
with urllib.request.urlopen(request, timeout=timeout, context=ssl_context) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else None
def ensure_bucket(aw_base_url, bucket_id, client_name, bucket_type, hostname, timeout, ssl_context):
http_json(
f"{aw_base_url}/buckets/{urllib.parse.quote(bucket_id, safe='')}",
method="POST",
body={
"client": client_name,
"type": bucket_type,
"hostname": hostname,
},
timeout=timeout,
ssl_context=ssl_context,
)
def send_heartbeat(aw_base_url, bucket_id, event, pulse_time, timeout, ssl_context):
http_json(
f"{aw_base_url}/buckets/{urllib.parse.quote(bucket_id, safe='')}/heartbeat?pulsetime={pulse_time}",
method="POST",
body=event,
timeout=timeout,
ssl_context=ssl_context,
)
def normalize_headers(config):
headers = dict(config.get("headers") or {})
auth = config.get("auth") or {}
bearer_token = auth.get("bearer_token")
basic = auth.get("basic")
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
elif basic and basic.get("username") and basic.get("password"):
import base64
token = base64.b64encode(f"{basic['username']}:{basic['password']}".encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {token}"
return headers
def summarize_payload(payload):
if isinstance(payload, dict):
return {
"keys": sorted(payload.keys())[:50],
"size": len(json.dumps(payload, ensure_ascii=False)),
}
if isinstance(payload, list):
return {
"items": len(payload),
"sample_type": type(payload[0]).__name__ if payload else "none",
"size": len(json.dumps(payload, ensure_ascii=False)),
}
return {
"type": type(payload).__name__,
"value": str(payload)[:400],
}
def poll_endpoint(config, endpoint, aw_base_url, aw_timeout, pf_timeout, ssl_context):
pf_host = config["pfsense"]["host"]
scheme = config["pfsense"].get("scheme", "https")
hostname = config["aw"]["hostname"]
path = endpoint["path"]
url = f"{scheme}://{pf_host}{path}"
headers = normalize_headers(config["pfsense"])
try:
payload = http_json(
url,
method=endpoint.get("method", "GET"),
headers=headers,
timeout=pf_timeout,
ssl_context=ssl_context,
)
bucket_prefix = endpoint["bucket_prefix"]
bucket_id = f"{bucket_prefix}_{hostname}"
ensure_bucket(
aw_base_url,
bucket_id,
endpoint.get("client", bucket_prefix),
endpoint.get("bucket_type", "aw.pfsense.metric"),
hostname,
aw_timeout,
ssl_context,
)
event = {
"timestamp": utc_now_iso(),
"duration": 0,
"data": {
"source": "pfsense-aw-poller",
"target": {
"host": pf_host,
"name": config["pfsense"].get("name", hostname),
},
"endpoint": {
"path": path,
"method": endpoint.get("method", "GET"),
"name": endpoint.get("name", bucket_prefix),
},
"summary": summarize_payload(payload),
"payload": payload,
},
}
send_heartbeat(
aw_base_url,
bucket_id,
event,
endpoint.get("pulse_time_seconds", config["aw"].get("pulse_time_seconds", 120)),
aw_timeout,
ssl_context,
)
return {
"endpoint": path,
"bucket_id": bucket_id,
"status": "ok",
}
except urllib.error.HTTPError as error:
return {
"endpoint": path,
"status": "http_error",
"code": error.code,
"reason": str(error),
}
except Exception as error:
return {
"endpoint": path,
"status": "error",
"reason": str(error),
}
def run_once(config):
aw = config["aw"]
aw_base_url = f"http://{aw['server_host']}:{aw.get('server_port', 5600)}/api/0"
aw_timeout = int(aw.get("timeout_seconds", 15))
pf_timeout = int(config["pfsense"].get("timeout_seconds", 15))
verify_tls = bool(config["pfsense"].get("verify_tls", False))
ssl_context = build_ssl_context(verify_tls)
results = []
for endpoint in config.get("endpoints") or []:
results.append(poll_endpoint(config, endpoint, aw_base_url, aw_timeout, pf_timeout, ssl_context))
return results
def main():
parser = argparse.ArgumentParser(description="Poll pfSense API and forward selected telemetry to ActivityWatch.")
parser.add_argument("--config", required=True, help="Path to JSON config.")
parser.add_argument("--once", action="store_true", help="Run one cycle and exit.")
args = parser.parse_args()
config = load_json(args.config)
interval = int(config.get("poll_interval_seconds", 60))
while True:
results = run_once(config)
print(json.dumps({"timestamp": utc_now_iso(), "results": results}, ensure_ascii=False), flush=True)
if args.once:
return 0
time.sleep(max(interval, 15))
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=pfSense ActivityWatch poller
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json
Restart=always
RestartSec=10
User=root
Group=root
[Install]
WantedBy=multi-user.target