Smart, self-hosted infrastructure monitoring and endpoint management.
A Susquehanna Syntax product.
Vigil is a lightweight monitoring system where agents on your hosts phone home to a central Django server. The server collects metrics, evaluates alert rules, dispatches signed tasks back to agents, and maintains a hardware inventory of your fleet. Everything runs over HTTPS with Ed25519 task signing and TOFU key pinning.
Key features:
- Real-time metric collection (CPU, memory, disk, network, swap, load average, processes)
- 20 built-in alert rules with auto-resolution and host-offline detection
- Notification dispatch (webhook, email)
- Hardware inventory with OS, CPU, RAM, BIOS, MAC, uptime, timezone, and custom collector columns
- Nessus/Tenable vulnerability integration — ingest scan results, launch scans from the UI ("Scan now"), or have agents request a scan via a task action; high-risk and critical findings raise alerts
- Active Directory computer import with auto-tagging from OU paths
- Tag-based fleet segmentation — deploy tasks by tag or by individual host
- Multistep task authoring (YAML editor) with schedule windows, retry policies, and success criteria
- Live-polled task history with pagination (5 s refresh while the History tab is visible)
- Community task catalog on GitHub — submit your YAML as a pull request from the editor, browse approved entries
- TOTP-based two-factor authentication for task execution and host enrollment approval, with single-use codes (replay protection)
- Signed remote task execution with mode/allowlist enforcement on the agent
- SQSY dark-theme dashboard with Chart.js visualizations
cd server
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
USE_SQLITE=true .venv/bin/python manage.py migrate
USE_SQLITE=true .venv/bin/python manage.py createsuperuser
USE_SQLITE=true .venv/bin/python manage.py runserver- Dashboard: http://localhost:8000
- Health check: http://localhost:8000/api/v1/health/
- Admin: http://localhost:8000/admin/
USE_SQLITE=true switches the database engine to SQLite and bypasses the need for PostgreSQL and TimescaleDB.
cp .env.example .env1. Generate a signing key seed (32-byte Ed25519 seed, base64-encoded — required, every checkin signs with it):
python3 -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
# Output is exactly 44 characters ending in '=' — example: Hk3PtP...P0c=2. Put it in .env (along with DJANGO_SECRET_KEY and POSTGRES_PASSWORD). In a .env file the value is bare, no quotes:
VIGIL_SIGNING_KEY_SEED=Hk3PtP...P0c=
⚠️ If you're pasting the compose stack into Portainer (or any other UI that round-trips through YAML), set the value in the Environment variables tab — never inline it into the YAML. If you must inline it, wrap it in double quotes ("Hk3...P0c=") — the trailing=is base64 padding and unquoted YAML can strip or mangle it. A malformed seed producesbinascii.Error: Incorrect paddingand breaks every agent checkin in the fleet.
3. Verify the seed is good before bringing the stack up:
python3 -c "import base64,sys; s=sys.argv[1]; print('OK',len(base64.b64decode(s)),'bytes')" \
"$(grep '^VIGIL_SIGNING_KEY_SEED=' .env | cut -d= -f2-)"
# Expect: OK 32 bytesAny other output (especially binascii.Error: Incorrect padding or a length that isn't 32) means fix the seed before continuing.
4. Start the stack:
docker compose up -d
docker compose exec web python manage.py createsuperuserThis brings up Django, PostgreSQL + TimescaleDB, Redis, Celery worker, and Celery beat.
| Variable | Default | Description |
|---|---|---|
DJANGO_SECRET_KEY |
insecure-dev-key-… |
Django secret key — change in production |
DJANGO_DEBUG |
true |
Set to false in production |
DJANGO_ALLOWED_HOSTS |
localhost,127.0.0.1 |
Comma-separated allowed hosts |
DJANGO_CSRF_TRUSTED_ORIGINS |
(empty) | Comma-separated origins trusted for POSTs, scheme included — required behind a proxy or external hostname, else Origin checking failed |
USE_SQLITE |
(unset) | Set to true to use SQLite instead of PostgreSQL |
POSTGRES_DB |
vigil |
PostgreSQL database name |
POSTGRES_USER |
vigil |
PostgreSQL user |
POSTGRES_PASSWORD |
vigil |
PostgreSQL password |
POSTGRES_HOST |
localhost |
PostgreSQL host |
CELERY_BROKER_URL |
redis://localhost:6379/0 |
Redis URL for Celery |
VIGIL_SIGNING_KEY_SEED |
(empty) | Base64 Ed25519 seed — required for task deployment |
VIGIL_TIMEZONE |
UTC |
IANA timezone for schedule window evaluation (e.g. America/New_York) |
VIGIL_METRIC_RETENTION_DAYS |
30 |
Days to keep metric history |
VIGIL_MAX_REQUEST_BODY_BYTES |
8388608 (8 MB) |
Largest request body Django will accept. Task results are the big payload — a Trivy scan report. Raising Django's 2.5 MB default matters because the limit is enforced before any view runs: an oversized result fails the whole POST with a bare 400 nothing can annotate, and the task stays DISPATCHED forever |
VIGIL_AGENT_VERSION |
(ignored) | No longer used. The expected agent version is detected from the agent bundled in the build. Leaving it set is harmless — the server logs a note at startup and carries on |
NESSUS_URL |
(empty) | Nessus/Tenable server URL |
NESSUS_ACCESS_KEY |
(empty) | Nessus API access key |
NESSUS_SECRET_KEY |
(empty) | Nessus API secret key |
NESSUS_VERIFY_SSL |
true |
Verify Nessus TLS certificate |
EMAIL_BACKEND |
console |
Django email backend |
EMAIL_HOST |
localhost |
SMTP host |
EMAIL_PORT |
587 |
SMTP port |
VIGIL_NOTIFICATION_FROM_EMAIL |
vigil@localhost |
From address for alert emails |
VIGIL_PUBLIC_URL |
— | External URL for remote access; its host/origin are auto-added to ALLOWED_HOSTS/CSRF_TRUSTED_ORIGINS |
VIGIL_TRUST_PROXY |
false |
Trust X-Forwarded-Proto/Host from a TLS-terminating proxy/tunnel |
TUNNEL_TOKEN |
— | Cloudflare Tunnel token for docker compose --profile tunnel up |
Agents are outbound-only, so reaching Vigil from outside the LAN is just a matter
of exposing the server at a public or overlay address. Set VIGIL_PUBLIC_URL to
your external URL and VIGIL_TRUST_PROXY=true when a proxy terminates TLS. The
repo ships a cloudflared sidecar (docker compose --profile tunnel up -d) for
Cloudflare Tunnel. Full recipes for Cloudflare Tunnel, Tailscale, and a
generic reverse proxy are in docs/REMOTE-ACCESS.md.
cd agent
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp config.example.yml agent.ymlEdit agent.yml:
server_url: http://localhost:8000
mode: monitor # start with monitor, upgrade later
checkin_interval: 15 # faster for testing
data_dir: ./dataRun the agent:
python3 -m vigil_agent -c agent.yml --log-level DEBUGOn first run the agent will:
- Generate a cryptographic token and save it to
agent.yml - Register with the server (creates a pending host)
- Start checking in — the server responds with
{"status": "pending"}until you approve
Approve the host from Settings → Enrollment Queue in the dashboard, or via API:
curl -X POST http://localhost:8000/api/v1/hosts/<host-id>/approve/ \
-H "Cookie: sessionid=<your-session>" \
-H "X-CSRFToken: <csrf-token>"# /etc/vigil/agent.yml on each monitored host
server_url: https://vigil.yourdomain.com
mode: managed
checkin_interval: 60
data_dir: /var/lib/vigil-agent
tags:
- web-server
- prod
allowlist:
- restart_service
- restart_container
- clear_temp_files
- run_package_updatesSecurity notes:
chmod 600 agent.yml— the config contains the agent token- TOFU key pinning: the first public key received from the server is pinned to
data_dir/server_public_key.pin. Any future key change is treated as a potential compromise — all tasks are rejected until the pin file is manually deleted - Agent mode is authoritative — a compromised server cannot escalate an agent's mode or allowlist
- No
shell=Trueanywhere; all task parameters are validated before execution
| Page | Description |
|---|---|
| Dashboard | Host card grid — status dots, CPU/Memory/Disk/Network mini-bars, RDP, Deploy, Remove buttons. Searchable. Inactive agents (90+ days) collapse into a separate section. |
| Inventory | Hardware table for all enrolled hosts. Columns: hostname, IP, OS, CPU, RAM, MAC, BIOS, disks, uptime, last user, timezone, and more. Scrollable, sortable (click header), filterable per-column (=value for exact, default contains), drag-to-reorder columns, column visibility toggled via Columns button. |
| Tasks | YAML task editor (with Submit to Community that opens a GitHub PR) and your private library. The History tab lists every dispatched task with live polling — newest first, paginated. |
| Vulns | Nessus/Tenable scan findings per host, with a Scan now button per row. The Recent scans section below shows every scan request — UI-launched or agent-requested — and its state. |
| Monitor | Select a host for live SVG gauges (CPU, Memory, Disk, Load) and Chart.js time-series with 1h/6h/24h/7d range selector. RDP download button for Windows hosts. |
| Alerts | Firing, acknowledged, and resolved alerts across the fleet. |
| Community | Browse forks of approved community tasks. New submissions live on GitHub at SusquehannaSyntax/Vigil-Approved-Scripts — use Submit to Community in the task editor to open a PR. |
| Settings | Enrollment queue (approve/reject pending hosts), TOTP enrollment, AD import, server timezone display. |
Agents ship a hardware snapshot on an hourly cadence (separate from the 60-second metric checkin). The inventory page shows:
| Field | Source |
|---|---|
| OS (agent) | Agent-reported host.os string |
| OS Name | /etc/os-release PRETTY_NAME |
| OS Version | /etc/os-release VERSION_ID |
| Kernel | platform.release() |
| Architecture | platform.machine() |
| Uptime | time.time() - psutil.boot_time() |
| Last User | psutil.users() — most recent login |
| Manufacturer | /sys/class/dmi/id/sys_vendor |
| Model | /sys/class/dmi/id/product_name |
| Service Tag | /sys/class/dmi/id/product_serial |
| BIOS | /sys/class/dmi/id/bios_version + bios_date |
| RAM | psutil.virtual_memory().total |
| CPU | /proc/cpuinfo model name |
| Cores | psutil.cpu_count(logical=True) |
| MAC | psutil.net_if_addrs() — preferred eth/en interface |
| Disks | psutil.disk_partitions() |
| Timezone | /etc/timezone |
Custom columns — tasks marked with a collect: block in their YAML write key/value pairs into HostInventory.custom_columns, which auto-appear as additional columns on the Inventory page.
Tags are free-form strings attached to hosts. They enable tag-based task deployment and fleet segmentation.
Sources (merged in order):
agent.yml—tags: [web-server, prod, rack-3]— sent at each checkin- Server-side tags — editable in the host detail panel (click any host card)
- Auto-tags — applied at checkin based on OS (
linux,windows,macos) and mode (managed,monitor,full_control) - AD import — tags from OU path segments (e.g.
OU=Servers,OU=IT→servers,it)
Deploy by tag — in the deploy modal, switch the target toggle from "Individual Hosts" to "By Tag" to deploy to all online managed hosts with a given tag.
Tasks are YAML definitions authored in the built-in editor (Tasks → New Task) and deployed across hosts via the deploy modal.
name: Restart nginx and verify
description: Gracefully reload nginx, confirm the service is running.
relevance: web servers
risk: standard # low | standard | high
# Optional inputs — filled in at deploy time
inputs:
- id: service
label: Service name
type: text
default: nginx
required: true
# Optional: restrict dispatch to a maintenance window (server timezone)
schedule:
window:
start_hour: 8 # 0–23
start_minute: 0 # 0–59, default 0
end_hour: 17 # inclusive through end_hour:59
end_minute: 0
days: [mon, tue, wed, thu, fri] # default: all 7
# Optional: retry failed steps
on_failure:
retry:
attempts: 3 # 0 = no retry
delay_seconds: 60
# Optional: validate step output (supports {{ inputs.x }} variables)
success_criteria:
exit_code: 0
output_contains: "active (running)" # substring match
output_regex: "^OK" # regex (applied after output_contains)
# Optional: write this task's output into the host's inventory as a custom column
collect:
column: nginx_version # required — the custom_columns key (≤ 80 chars)
parse: output_line_1 # optional — output_line_1 (default) | output_trim | output_full
actions:
- id: reload
type: reload_service
params:
service_name: "{{ inputs.service }}"
success_criteria:
exit_code: 0
output_contains: "{{ inputs.service }} reloaded"
- id: verify
type: check_service
params:
service_name: "{{ inputs.service }}"
expect: active
# Optional per-step keys
- id: build
type: run_command
when: 'inputs.run_build == "yes"' # skip this step unless it matches
timeout: 1800 # seconds, 1–3600 (default 120)
params:
command: "make -j8 all"when: gates a single step on a predicate over agent.* (platform facts:
os, arch, pkg_manager, hostname) and inputs.* (the values supplied at
deploy time). A step whose predicate is false is skipped and recorded, and does
not block later steps. Referencing an input you did not declare is rejected when
the task is saved — otherwise the step would silently never run.
timeout: raises the per-step limit past the 120-second default, up to one
hour. Use it for source builds, large image pulls, and filesystem scans on big
volumes rather than detaching the work with nohup and polling for it.
Schedule windows are evaluated in the server's VIGIL_TIMEZONE. Tasks outside the window stay PENDING and are dispatched on the next checkin that falls inside the window.
Retry — on step failure the agent re-runs the step after delay_seconds, up to attempts times, before marking the task as failed.
Success criteria — even a zero exit code is treated as failure if output_contains or output_regex doesn't match. Per-step criteria override the top-level criteria.
Collect — a task marked with a top-level collect: block becomes an inventory data collector: when the run finishes on a host, the task's output is written into that host's HostInventory.custom_columns, and the column auto-appears on the Inventory page. The block is a single mapping (not a list, and not one entry per column — one task collects one column), with these keys:
| Key | Required | Meaning |
|---|---|---|
column |
yes | The custom_columns key to write. String, ≤ 80 characters. |
parse |
no | How the task output becomes the stored value. One of output_line_1 (default), output_trim, output_full. |
There is no value: key and no {{ steps.* }} templating — the value always comes from the task's own output, transformed by parse:
output_line_1(default) — the first non-empty line of output, with a leading[OK]step prefix and anylabel:prefix stripped; truncated to 500 characters. Best for a task whose script echoes a single value.output_trim— the entire output, whitespace-trimmed, truncated to 500 characters.output_full— the entire output verbatim, truncated to 2000 characters.
The column is written per host when that host's task completes, so running one collect: task against a tag or fleet fills the column for every targeted host. Worked example — record each host's kernel release into a kernel column:
name: Record kernel version
description: Capture uname -r into the host inventory.
risk: low
collect:
column: kernel
parse: output_line_1
actions:
- id: uname
type: run_command
params:
command: "uname -r"All 49 primitives are defined in server/apps/tasks/spec.py and executed in agent/vigil_agent/executor.py. run_command and execute_script require full_control mode; all others require managed or higher.
Service management
| Action | Params | Optional |
|---|---|---|
restart_service |
service_name |
— |
start_service |
service_name |
— |
stop_service |
service_name |
— |
reload_service |
service_name |
— |
enable_service |
service_name |
— |
disable_service |
service_name |
— |
check_service |
service_name |
expect |
Container management
| Action | Params | Optional |
|---|---|---|
restart_container |
container_name |
— |
start_container |
container_name |
— |
stop_container |
container_name |
— |
remove_container |
container_name |
— |
pull_image |
image |
— |
recreate_container |
container_name |
image |
docker_compose_up |
compose_file |
services |
docker_compose_down |
compose_file |
— |
clear_docker_logs |
— | container_name |
check_docker_updates |
— | — |
Package management
| Action | Params | Optional |
|---|---|---|
install_package |
package_name |
— |
remove_package |
package_name |
— |
update_package |
package_name |
— |
run_package_updates |
— | security_only |
File operations
| Action | Params | Optional |
|---|---|---|
write_file |
path, content |
mode |
create_directory |
path |
owner, group, mode |
delete_path |
path |
recursive |
copy_file |
src, dest |
— |
move_file |
src, dest |
— |
set_permissions |
path |
owner, group, mode |
System
| Action | Params | Optional |
|---|---|---|
clear_temp_files |
— | older_than_days |
execute_script |
script_name |
— |
reboot |
— | delay_seconds |
run_command |
command |
timeout |
set_hostname |
hostname |
— |
Networking
| Action | Params | Optional |
|---|---|---|
add_firewall_rule |
port, protocol |
action |
remove_firewall_rule |
port, protocol |
— |
User management
| Action | Params | Optional |
|---|---|---|
create_user |
username |
groups, shell |
delete_user |
username |
remove_home |
add_user_to_group |
username, group |
— |
Cron
| Action | Params | Optional |
|---|---|---|
create_cron_job |
schedule, command |
user |
delete_cron_job |
pattern |
user |
Vulnerability scanning
| Action | Params | Optional |
|---|---|---|
request_nessus_scan |
— | — |
request_network_scan |
— | engine (nessus | greenbone) |
run_trivy_scan |
— | scope (fs | rootfs | image:<name>) |
trivy_db_update |
— | — |
The two request_* actions only leave a marker: the agent finishes, and the server creates a VulnScan for the central scanner to run. run_trivy_scan is the opposite — the scan is the task, and the findings arrive with its output. See Vulnerability Management.
Agent & baselines
| Action | Params | Optional |
|---|---|---|
baseline |
name |
— |
update_agent |
— | platform |
update_agent replaces the agent executable, so the server stamps the verified SHA-256 of each platform binary into the signed task — TLS alone is not proof enough for that swap.
Reprovisioning (see docs/reprovisioning.md)
| Action | Params | Optional |
|---|---|---|
reprovision_preflight |
— | disk_target, os_family |
reprovision_stage |
job_id, kernel_url, initrd_url, kernel_sha256, initrd_sha256 |
— |
reprovision_commit |
job_id, cmdline |
— |
reprovision_cleanup |
job_id |
— |
All except reprovision_preflight are high-risk and additionally gated by the agent's own allow_reprovision opt-in — a compromised server cannot wipe a host that never opted in.
- Write a task definition in the YAML editor (Tasks → New Task)
- Click Deploy on a library card
- Fill in any inputs on the Inputs tab
- Optionally set a schedule window, retry policy, and success criteria on their tabs
- Select target hosts (or choose a tag) on the Hosts tab
- Enter your 6-digit TOTP code and submit
- Track execution in the run detail view (Tasks → History — the list polls every 5 seconds while open)
The Vigil community catalog lives on GitHub at SusquehannaSyntax/Vigil-Approved-Scripts. Every Vigil instance can browse approved entries; submissions are PR-reviewed by SQSY maintainers.
The task editor's toolbar has a Submit to Community button. Clicking it:
- Reads the YAML currently in the editor and slugifies the task name into a filename (e.g.
restart-nginx-and-verify.yaml). - Auto-injects attribution — adds
author: <your-vigil-username>andcreated: <today>after thename:line if they aren't already declared. The community repo policy requires both fields; auto-injection means you never have to remember. - Opens a modal with two actions: Copy YAML (clipboard) and Open GitHub PR.
- The Open GitHub PR link points at
github.com/SusquehannaSyntax/Vigil-Approved-Scripts/new/main/tasks?filename=<slug>.yaml&value=<your YAML>— GitHub's new-file editor with the body pre-filled. - If you don't have write access on the repo, GitHub forks it into your account automatically; click Propose new file → Create pull request.
- A SQSY maintainer reviews and merges. Once merged, every Vigil instance can see the entry.
| Field | Type | Auto-filled by Submit-to-Community |
|---|---|---|
name |
string | No (must be in your YAML) |
description |
string | No |
risk |
low / standard / high |
No |
actions |
non-empty list | No |
author |
string (your Vigil username) | Yes |
created |
ISO-8601 date (YYYY-MM-DD) |
Yes |
author and created are also optional in the local schema, so private tasks aren't forced to carry them. When present, both fields surface in card meta lines and the editor preview — YAML-declared author takes precedence over the local owner_username so a forked task keeps its original attribution.
This replaces the older local "publish to community" flow — there is no per-server community tab managed by API anymore. The advantage: one curated repo for everyone, version history, audit trail, and no shared DB to operate.
Vigil ships with 20 default alert rules created automatically on first migration. Rules evaluate every 60 seconds via Celery beat. Alerts auto-resolve when the metric returns below the threshold.
CPU
| Rule | Threshold | Severity | Duration |
|---|---|---|---|
| Elevated CPU Usage | > 75% | Warning | 5 min |
| High CPU Usage | > 90% | Critical | 5 min |
| CPU Critical (95%) | > 95% | Critical | 1 min |
| High Load Average (1m) | > 10 | Warning | 2 min |
| High Load Average (5m) | > 8 | Warning | 5 min |
| Sustained High Load (15m) | > 6 | Critical | 10 min |
Memory & Swap
| Rule | Threshold | Severity | Duration |
|---|---|---|---|
| Elevated Memory Usage | > 80% | Warning | 2 min |
| High Memory Usage | > 90% | Critical | 2 min |
| Memory Critical (95%) | > 95% | Critical | 1 min |
| High Swap Usage | > 50% | Warning | 5 min |
| Swap Nearly Exhausted | > 80% | Critical | 2 min |
Disk
| Rule | Threshold | Severity | Duration |
|---|---|---|---|
| Disk Usage High | > 80% | Warning | Instant |
| Disk Nearly Full | > 90% | Critical | Instant |
| Disk Critical (95%) | > 95% | Critical | Instant |
Network
| Rule | Threshold | Severity | Duration |
|---|---|---|---|
| High Network Error Rate (In) | > 100 errors | Warning | 2 min |
| High Network Error Rate (Out) | > 100 errors | Warning | 2 min |
| High Network Drop Rate (In) | > 200 drops | Warning | 2 min |
| High Network Drop Rate (Out) | > 200 drops | Warning | 2 min |
Process
| Rule | Threshold | Severity | Duration |
|---|---|---|---|
| Process CPU Spike | > 95% (single process) | Warning | 2 min |
| Process Memory Spike | > 50% (single process) | Warning | 2 min |
Host offline — when a host misses 5+ minutes of checkins, an alert is automatically created. It auto-resolves on the next successful checkin.
Custom rules can be created via the Django admin.
Configure notification channels in the Django admin under Alerts → Notification channels:
- Webhook — POST JSON payload to a URL. Set a
secretin the config for anX-Vigil-Secretrequest header. - Email — Sent via Django's email backend. Configure
EMAIL_HOST,EMAIL_PORT, etc. in your.env.
Vigil implements RFC 6238 TOTP natively. Task deployments require a 6-digit TOTP code once enrolled.
Enrollment:
- Go to Settings → Two-Factor Authentication
- Click Enroll TOTP — copy the secret into any authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, Aegis)
- Enter a code from the app to confirm
Task deploys are blocked until enrolled. TOTP can be disabled from Settings (requires a current code).
Vigil ingests findings from three scanners into one VulnFinding table, one host score, and one Vulnerabilities tab. Configure whichever you have — none are required, and an unconfigured scanner is skipped silently.
| Scanner | Where it runs | How Vigil gets findings | Setup |
|---|---|---|---|
| Nessus / Tenable | Central server you run | Vigil launches scans over the REST API and polls | NESSUS_* env vars |
| Greenbone / OpenVAS | Container you run | Vigil launches scans over GMP (XML/TLS) and polls | GREENBONE_* env vars |
| Trivy | The monitored host itself | The scan is an agent task; findings arrive with its output | Nothing server-side |
vulns.sync_vulns (hourly beat) walks every registered scanner, skips the unconfigured ones, and isolates failures so one bad scanner can't break the rest. Trivy is event-driven and does nothing during that walk — its findings land when the task completes.
vulns.sync_nessus_vulnsstill exists as a deprecated alias for one release, for pinned callers. New code should callsync_vulns.
Trivy scans the monitored host's own filesystem and installed packages against a CVE database. Nothing listens on the network and there is no central scanner to run. Deploy a task using the run_trivy_scan action:
name: Trivy filesystem scan
risk: low
actions:
- id: scan
type: run_trivy_scan
params: { scope: fs } # or image:<name> for a container imageThe agent needs the trivy binary — use the Install Trivy on this host task template, or install it by hand.
managed-mode agents must allowlist the action. The shippedagent.ymlallowlist does not includerun_trivy_scan, so a managed agent rejects the task and no findings ever arrive. Add it (andtrivy_db_updateif you refresh the DB explicitly) to the host'sallowlist:.full_controlagents ignore the allowlist entirely.
On completion the server writes one VulnFinding per (package, CVE) pair, marks previously-open findings that didn't reappear as FIXED, and recomputes the host score. The outcome is appended to the task's own output, so run details show either [INGEST] trivy: N finding(s) ingested or [INGEST FAILED] <reason>.
Report size. A full trivy fs / report is very large — around 30 MB on a stock Ubuntu workstation, of which roughly 90% is a package inventory Vigil never reads. The agent condenses the report to the fields the server ingests (~240 KB) before sending it. Two consequences worth knowing:
- Agents older than 2026.6.2 send the raw report, which is truncated in transit and cannot be ingested. The Vulnerabilities tab stays empty and run details say the report is truncated. Update the agent.
- If you put a proxy in front of Vigil, its request body limit must clear
VIGIL_MAX_REQUEST_BODY_BYTES(8 MB by default), or results are rejected before Vigil ever sees them.
A report with no Vulnerabilities section is refused rather than ingested. That shape means the scan never ran the vulnerability scanner — ingesting it would mark every existing finding fixed and report the host clean, which is the worst possible failure for a security feature. Existing findings are left untouched and the reason appears in run details.
-
Install Nessus Essentials (free, scans up to 16 IPs) — register at https://www.tenable.com/products/nessus/nessus-essentials for an activation code, then:
curl -o nessus.deb 'https://www.tenable.com/downloads/api/v2/pages/nessus/files/Nessus-latest-debian10_amd64.deb' sudo dpkg -i nessus.deb sudo systemctl enable --now nessusd
Open
https://localhost:8834, complete activation, and wait ~20–30 minutes for plugin compilation. Generate API keys under My Account → API Keys → Generate. -
Wire the keys into Vigil's
.env:NESSUS_URL=https://localhost:8834 NESSUS_ACCESS_KEY=<paste> NESSUS_SECRET_KEY=<paste> NESSUS_VERIFY_SSL=false # self-signed cert by default
When Vigil runs in Docker against a host-installed Nessus, use
https://host.docker.internal:8834(Mac) orhttps://172.17.0.1:8834(Linux bridge). -
Verify the connection:
docker compose exec server python manage.py shell -c \ "from apps.vulns.tasks import sync_nessus_vulns; print(sync_nessus_vulns())"
The vulns.sync_vulns Celery beat (hourly) does three things in order, for each configured network scanner:
- Launches every
VulnScanin therequestedstate by calling Nessus'sBasic Network Scantemplate against the host's IP (or the Greenbone equivalent over GMP). - Polls in-flight scans and updates their state in the dashboard.
- Ingests results from completed scans into
VulnSummary, fires alerts on new criticals and new highs, and resolves them once findings clear.
Three paths, all converge on a VulnScan row visible in the Recent scans list:
| Path | Who triggers | TOTP gate |
|---|---|---|
| Scan now button (Vulnerabilities tab) | Operator from UI | Yes |
| Request a Nessus scan task template | Operator dispatches to a host | Yes (at deploy time) |
request_nessus_scan action in a custom YAML task |
Anyone with a published YAML using this action | Yes (deploy gate) |
One active scan per host is enforced — repeated requests while a scan is in flight return 409 Conflict.
The task editor's Start from template… dropdown covers all three scanners:
| Template | Risk | What it does |
|---|---|---|
| Install Nessus Essentials on this host | high | Downloads, installs and starts nessusd, then echoes the activation URL |
| Request a Nessus scan of this host | low | request_nessus_scan — the server picks up the marker on completion and queues a scan |
| Install Greenbone Community Edition | high | Drops the official CE compose stack into /opt/greenbone and brings it up (Linux, docker-capable hosts only) |
| Request a network scan of this host | low | request_network_scan — engine-agnostic; the server picks Nessus or Greenbone |
| Install Trivy on this host | high | Cross-platform install via apt / dnf / brew / winget, chosen by per-step when: predicates |
| Run a Trivy vulnerability scan | low | run_trivy_scan — the scan runs on the host and the findings come back with the task |
Configure AD in Settings → Active Directory:
- LDAP server URL, bind DN, bind password, base DN, and the OU containing computer objects
- Import Now runs a Celery task that queries LDAP for computer objects, creates
PENDINGhost records for any not already enrolled, and auto-tags them from their OU path (e.g.OU=Servers,OU=IT→ tagsservers,it)
| Mode | Metrics | Tasks |
|---|---|---|
monitor |
Collected | Ignored entirely |
managed |
Collected | Only allowlisted actions |
full_control |
Collected | Any action |
The allowlist is defined in agent.yml and enforced locally by the agent — the server cannot override it.
One action sits outside this table entirely. Remote reprovisioning — wiping
and reinstalling the machine — is not granted by full_control and cannot be
allowlisted. It requires its own flag:
allow_reprovision: true # default false, everywhereThe authority to destroy a machine lives on that machine, so a compromised Vigil server cannot order a fleet to rebuild itself.
Rebuild a host's operating system from the console: pick an image and a profile, confirm with password + authenticator code + the typed hostname, and the machine wipes itself, installs unattended, re-enrols its agent against the same host record, takes a tag you chose, and optionally runs a baseline — taking a drifted or compromised box back to known-good without a site visit.
Ubuntu, Debian, and the RHEL family. Free feature.
This destroys all data on the target disk, and there is no undo once the installer starts. Read docs/reprovisioning-runbook.md before running one — including its note on why rebuild is not a guaranteed eradication path against an attacker with kernel-level persistence. Design rationale is in docs/reprovisioning.md.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/register |
Agent self-registration (creates pending host) |
POST |
/api/v1/checkin |
Metric ingest + hardware inventory + task dispatch |
POST |
/api/v1/tasks/result/ |
Report task execution outcome |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/hosts/ |
List enrolled hosts |
GET DELETE |
/api/v1/hosts/{id}/ |
Host detail / remove host and all data |
POST |
/api/v1/hosts/{id}/approve/ |
Approve pending enrollment |
POST |
/api/v1/hosts/{id}/reject/ |
Reject pending enrollment |
POST |
/api/v1/hosts/{id}/poll/ |
Request immediate checkin |
GET |
/api/v1/hosts/{id}/rdp/ |
Download .rdp file (Windows hosts) |
GET PATCH |
/api/v1/hosts/{id}/tags/ |
Get / update host tags |
GET |
/api/v1/hosts/tags/ |
All tags in use across the fleet with host counts |
GET |
/api/v1/hosts/inventory/ |
Inventory list for all hosts |
GET |
/api/v1/hosts/{id}/inventory/ |
Inventory detail for one host |
GET PUT |
/api/v1/hosts/ad/ |
AD configuration |
POST |
/api/v1/hosts/ad/sync/ |
Trigger AD import now |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/metrics/{host}/{cat}/{metric}/ |
Metric history (supports ?from=, ?to=, ?limit=) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/alerts/ |
List alerts (?state=firing|acknowledged|resolved) |
POST |
/api/v1/alerts/{id}/acknowledge/ |
Acknowledge a firing alert |
POST |
/api/v1/alerts/{id}/silence/ |
Silence a firing alert |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/tasks/actions/ |
Full action registry |
GET POST |
/api/v1/tasks/definitions/ |
List / create task definitions |
POST |
/api/v1/tasks/definitions/validate/ |
Validate YAML without saving |
GET PUT DELETE |
/api/v1/tasks/definitions/{id}/ |
Read / update / delete a definition |
POST |
/api/v1/tasks/definitions/{id}/fork/ |
Fork a community template |
POST |
/api/v1/tasks/definitions/{id}/deploy/ |
Deploy across hosts (requires TOTP) |
GET |
/api/v1/tasks/history/?page=N |
Paginated task history feed (50/page, polled by the History tab) |
GET |
/api/v1/tasks/runs/{id}/ |
Run detail with per-host step status |
The legacy single-action dispatch endpoint (
POST /api/v1/tasks/) was removed in 2026.1.9 — it bypassed the TOTP gate. Use the definition-deploy endpoint instead. Community publish/unpublish endpoints were also removed; the community catalog now lives on GitHub (see Community catalog).
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/vulns/ |
Vulnerability summaries per host |
GET |
/api/v1/vulns/scans/ |
Recent scan requests / runs (newest first, capped at 100) |
POST |
/api/v1/vulns/scans/{host_id}/ |
Queue a Nessus scan for a host (requires TOTP, one active per host) |
POST |
/api/v1/hosts/{id}/approve/ |
Approve a pending host (requires TOTP) |
GET |
/api/v1/accounts/totp/ |
TOTP enrollment status |
POST |
/api/v1/accounts/totp/enroll/ |
Start TOTP enrollment |
POST |
/api/v1/accounts/totp/enroll/confirm/ |
Confirm with 6-digit code |
POST |
/api/v1/accounts/totp/disable/ |
Disable TOTP |
GET |
/api/v1/health/ |
Health check (no auth) |
Vigil/
├── docker-compose.yml
├── .env.example
├── agent/ # Python monitoring agent
│ ├── config.example.yml # Annotated agent config template
│ ├── requirements.txt
│ └── vigil_agent/
│ ├── __main__.py # Main loop: register → checkin → collect → execute
│ ├── client.py # HTTPS client (register, checkin, report result)
│ ├── collector.py # psutil metrics + hardware inventory collection
│ ├── config.py # YAML config loading + token generation
│ ├── executor.py # Task execution, mode/allowlist enforcement
│ ├── runtime.py # Multi-step task runtime with success criteria
│ ├── verify.py # Ed25519 signature verification + TOFU key pinning
│ └── nonce_store.py # Replay protection (SQLite-backed nonce store)
└── server/
├── Dockerfile
├── requirements.txt
├── manage.py
├── vigil/
│ ├── settings.py # All settings (SQLite fallback via USE_SQLITE=true)
│ ├── celery.py
│ ├── signing.py # Ed25519 task signing (key loaded from env)
│ └── urls.py # URL config + dashboard view
├── templates/
│ ├── dashboard.html # Full SQSY single-page dashboard
│ └── _host_card.html # Host card partial (included in dashboard)
└── apps/
├── hosts/ # Host model, enrollment, checkin, inventory, tags, AD import
├── metrics/ # MetricPoint model + metric history API
├── alerts/ # AlertRule, Alert, NotificationChannel, Celery evaluation
├── tasks/ # TaskDefinition (YAML), Task, TaskRun — authoring + deploy
├── vulns/ # Nessus vulnerability sync + findings API
└── accounts/ # UserProfile, TOTP enrollment (RFC 6238 from scratch)
Vigil is free — full monitoring, alerting, unlimited agents, hosts, and
retention, forever. Vigil Business adds the accountability features
(unlimited Sites, audit-log viewer/export, seats + Operator role, branding)
via a signed, instance-bound, offline-verified license. Nothing ever blocks:
an expired license means Business features switch off and monitoring carries
on untouched. See docs/EDITIONS.md.
AGPLv3 for everything except server/apps_business/, which is source-visible
under a commercial license (server/apps_business/LICENSE) and requires a
Vigil Business subscription for production use