Appearance
Command and Control (C2)
Command and Control is the enterprise (business tier and above) subsystem for managing your own fleet of authorized scanner agents from the consultation portal. An agent is a netvuln_daemon.sh process running on a scanner host that you control; C2 lets a portal administrator queue commands for one agent or a group, have the agent execute them, and report results back. It is authorized fleet management, not remote access to third-party systems, and it requires portal connectivity plus jq on the agent.
The agent side is lib/command_handler.sh; the transport is the daemon heartbeat channel described in Daemon Mode.
How Commands Reach an Agent
Commands ride back on the heartbeat response, or are picked up by an idle-tick poll; no inbound port is needed on the agent:
Admin (portal dashboard / API) Agent (netvuln_daemon.sh)
------------------------------ --------------------------
POST /api/agent-commands ── queue ──▶ agent_commands (status = pending)
│
┌── heartbeat POST ───┘ (every DAEMON_HEARTBEAT_INTERVAL)
│ or GET /api/agent-commands (every DAEMON_POLL_INTERVAL, idle ticks)
▼
response/poll returns pending commands (status → sent)
│
▼
nv_process_commands() executes each command
│
PATCH /api/agent-commands (status → acked, running, then completed / failed)
│ ▲
│ keeper posts status: busy heartbeats
│ (current_command) every DAEMON_HEARTBEAT_INTERVAL
│ while the command runs
▼
command_results (exit_code, stdout, stderr, session_id)The heartbeat piggyback returns up to 20 commands per cycle; an explicit GET /api/agent-commands?agent_id=… poll returns up to 50. Since #384 the daemon also polls this endpoint on every idle DAEMON_POLL_INTERVAL tick (default 60s, DAEMON_COMMAND_POLL to disable); the heartbeat piggyback remains a fallback for when the poll is disabled or misses a beat.
Requirements and Gating
- License tier:
business(msp also qualifies, as the higher tier).nv_process_commands()callsnv_require_tier c2; below business, command processing is skipped as a soft gate (a warning, never an error). See License Provisioning. - Portal connectivity: the agent config must define
CONSULTATION_API_URLand an API key (NV_API_KEY, or the legacyCONSULTATION_API_KEY). jqmust be installed on the agent.
Agent Identity and Lifecycle
Identity. Each agent has an id of the form nvd_<first 8 hex of md5(hostname)> (for example nvd_89f6e23d), derived automatically. NVD_AGENT_ID overrides it (used in tests).
Registration. Agents auto-register on their first heartbeat. There is no approval step and no allowlist; the portal records the agent and emits an agent_first_seen audit event.
Deregistration (soft). An administrator can deregister an agent (portal PATCH /api/agents, action: deregister), which sets a deregisteredAt timestamp. On the agent's next heartbeat the portal returns HTTP 403; the daemon logs Agent has been unregistered by administrator and shuts down. The record is retained (hidden from the fleet list) and can be re-enabled with action: reenable.
Purge (hard). An already-deregistered agent can be permanently removed (portal DELETE /api/agents / action: purge), which deletes its heartbeat row, queued commands, results, and group memberships. Audit history is retained and an agent_purged event is appended.
Command Priority
priority is an integer 0-10 set when a command is queued. It is a portal-side dispatch-ordering hint only:
- Both the poll and the heartbeat piggyback select pending commands
ORDER BY priority DESC, created_at ASC, so within a dispatch batch a higher priority is handed over first, oldest-first as the tiebreak. - The agent does not read priority. Once commands reach the agent they run in the order received.
Use a higher priority to jump a command ahead of already-queued work for the same agent (for example a restart ahead of a backlog of scans).
Command Reference
All commands are queued with POST /api/agent-commands; payload is a JSON object. The source of truth for the keys each command reads is lib/command_handler.sh.
run_scan
Runs vulnscan_recon.sh on the agent.
| key | required | default | notes |
|---|---|---|---|
targets | yes | none | host(s)/CIDR(s), comma-separated. Empty fails with "No targets specified for run_scan". |
profile | no | full_recon | full_recon/full add --full; quick/quick_recon add -P quick_recon; any other value adds no mode flag. |
upload | no | false | when true, the resulting session (session JSON + pipeline log + report HTML + exec summary) is pushed to the portal after a successful scan, using the daemon's credentials. |
config | no | none | config basename without .conf (a trailing .conf is tolerated); must match ^[a-zA-Z0-9_-]{1,64}$. A value with a slash, .., or an empty string is never a match (#398). |
run_scan parses the session id it created from the scan output and reports it back, so the portal's Command History row links to the session detail.
Which config a C2 scan runs under: the agent resolves the client config in order: the requested config, if it names a loaded config (reason requested); otherwise the loaded config whose CLIENT_ID matches the agent's own registered client id, the first loaded config's id (reason client_match); otherwise the sole loaded config, if exactly one is loaded (reason single); otherwise no config at all (reason none). The command's stdout names the outcome: Using config for this scan: <name> (reason: requested|client_match|single), or, when nothing matched, no client config matched for this agent; scanning without -c, report will lack client name and assessor. Before this change, run_scan never passed a config, so every C2-triggered report showed a blank client and the OS account as assessor; the scheduled-scan path was unaffected (#398).
push_session
Uploads an existing session on the agent to the portal on demand, using the daemon's credentials.
| key | required | default | notes |
|---|---|---|---|
session_id | no | latest | specific session id (YYYYMMDD_HHMMSS); defaults to the most recent session. The id is validated against the session-name pattern, so it cannot traverse out of the sessions tree. |
Use this when a run_scan was dispatched without upload: true, or to re-push a session.
update_schedule
Updates an in-memory schedule entry loaded from a config file.
| key | required | default | notes |
|---|---|---|---|
schedule_id | yes | none | must match a loaded schedule. |
action | no | update | update, create, or delete. |
cron | no | none | new cron expression. |
profile | no | none | new scan profile. |
targets | no | none | new targets. |
delete is acknowledged but does not remove the config file (that requires manual action).
push_config
Updates a single allowlisted daemon setting (see below).
| key | required | notes |
|---|---|---|
config_key | yes | must be in the allowlist. |
config_value | yes | the value to set (control characters are rejected). |
push_session benchmark token
push_session's post_scan array also accepts benchmark (#430). The agent uploads the session, fetches comparative benchmark data from the portal, refreshes the report, and uploads again, so the Benchmark Comparison card on /collect/ fills for that session; the share token is preserved. A failure at any step is a [WARN] post_scan: benchmark ... line in the output and the push still succeeds. An older daemon drops the unknown token silently.
run_script
Runs a fixed catalog of read-only scripts on the agent, one after another, and returns one output with a section per script (#430). Admins use it from the portal Dispatch tab to check an agent without shell access.
| key | required | default | notes |
|---|---|---|---|
scripts | yes | none | 1 to 10 names matching ^[a-z_]{1,40}$. Never a path or an argument. |
config | no | resolved | config basename (same rules and resolution order as run_scan); supplies the license key to the child scripts |
timeout | no | 600 | total seconds for the whole command, clamped to 1800; a non-number or a value below 1 means the default |
Catalog (v1, latest session only):
| name | runs | own limit |
|---|---|---|
scan_diff | scripts/scan_diff.sh --json -c <latest session>/session_results.json | 120 s |
schedule_status | scripts/schedule_status.sh | 60 s |
schedule_list | scripts/schedule_list.sh --no-cron-check | 60 s |
The agent advertises these names in the heartbeat capabilities.run_script field. The portal offers only advertised names, and an agent that reports none is never sent the command. Output sections look like:
=== schedule_status (exit 0) ===
...
=== scan_diff (exit 2) ===
[scan_diff] NEW_CRITICAL_HIGH=1
...
skipped: schedule_list (time budget exhausted)
Each script is capped at 10 KB, CSI and OSC escape sequences, other control bytes and invalid UTF-8 are stripped, and the whole output stays under 50000 characters. scan_diff exit 2 (new critical or high findings) is a success. The command exits 0 when at least one script ran and every script that ran succeeded, else 1 (1 with stderr run_script: no scripts ran when every entry was skipped). A script that hits its limit shows exit 124; later scripts still run until the total budget is spent, and the rest are skipped: <name> (time budget exhausted). Each child gets a kill grace (5 s, or 1 s when 10 s or less remain) reserved inside the total budget, enforced where timeout(1) is available (without it the child runs with no limit). scan_diff uses the newest finished session (non-empty metadata.end_time), never one still in progress. The skipped: reasons are invalid name, duplicate, not in catalog, no session available, time budget exhausted, and not supported (trimmed by the portal). The schedule scripts read the agent's own base directory (NVD_BASE_DIR, as the daemon does), so they work on /opt installs as well as the home-directory layout.
set_log_level
| key | required | accepted |
|---|---|---|
level | yes | debug, info, warning, error. |
restart
No payload. Unconditionally re-execs the daemon (a full process replacement, same PID), so it re-sources every library and reports the new version within one heartbeat cycle. Works the same in a container as elsewhere, since it does not touch git. Uptime and last-heartbeat reset, which is expected.
shutdown
| key | required | default | notes |
|---|---|---|---|
graceful | no | true | boolean; signals the daemon to shut down. |
update
Self-updates the agent from git (requires the agent to be a git checkout). On success, the daemon re-execs itself (same PID) when the checkout HEAD actually advanced; an already-up-to-date pull still reloads config but does not re-exec. Uptime and last-heartbeat reset after a real update, which is expected. Container installs have no .git directory in the published image, so they cannot self-update this way; update them by re-pulling the published image tag and recreating the container.
| key | required | default | notes |
|---|---|---|---|
branch | no | main | git branch to check out and pull. Must match [A-Za-z0-9._/-]+, must not start with -, and must be a valid git branch name (no .., @{, leading or trailing /, or .lock suffix); anything else fails the command with Invalid branch name: ... before git runs (#429). |
version | no | none | tag vX.Y.Z to check out (takes precedence over branch). |
Credentials for self-update. update needs a git remote the daemon's user can fetch from without an interactive prompt. A checkout of the public repository needs no credential; on a private remote, provision whatever read-only credential your forge supports for the daemon's user first. When authentication fails, update now reports a distinct diagnosis before it ever reaches a checkout, rather than the misleading tag-not-found error a failed fetch used to leave behind. See the C2 Guide's "Credentials for self-update" section (under update in the command reference) for the exact diagnosis text.
Configuration Editing and Persistence
push_config (and the portal's Runtime Config editor) can change only an allowlist of safe keys:
DAEMON_POLL_INTERVAL DAEMON_HEARTBEAT_INTERVAL
DAEMON_SCAN_RETRY_MAX DAEMON_SCAN_RETRY_BACKOFF
DAEMON_LOG_LEVEL DAEMON_KEEPER_HEARTBEAT
DAEMON_COMMAND_POLL ALERT_ENABLED
ALERT_RISK_THRESHOLD ALERT_ON_NEW_CRITICALAny key outside this list is rejected ("Config key not in remote-update allowlist"). Scan targets, schedules, storage path, concurrency, and enabled modules are not remotely editable; they are managed in the config file.
A pushed change is applied to in-memory daemon state (live effect) and persisted so it survives a reload and a full restart:
push_configwrites each acceptedKEY=VALUEto<configs_dir>/.c2_overrides.conf, a dotfile that the*.confschedule glob never picks up, stored mode600and upserted (last write per key wins).- On reload,
_nvd_load_configs()re-applies that file as the final layer after the.conffiles, so a pushed override beats the config-file value. - The allowlist is re-validated on reload, and newline/CR characters are refused, so a tampered overrides file cannot smuggle in an arbitrary key or extra line.
To clear a pushed override, delete or edit .c2_overrides.conf on the agent and reload the daemon (SIGHUP).
Execution Details
- Timeout: each command runs under
NV_CMD_TIMEOUT(default 300 seconds). A command that exceeds it is killed and reportedfailed(exit 124). Afull_reconscan against a large range commonly exceeds 300s; run those on the host directly or raiseNV_CMD_TIMEOUT.run_scriptignoresNV_CMD_TIMEOUTand uses its payloadtimeout(default 600 s, clamp 1800 s) with a per-script limit. - Output capture:
stdoutandstderrare truncated to 10 KB in the result. ANSI escape codes (color and cursor sequences a scanned command's output may carry) are stripped before reporting, so the portal never has to render raw escape bytes.run_scriptbuilds its own per-script 10 KB cap and a 50000-character total (#430). - Console color output: separate from the C2 capture above,
nv_print_color()(used bynv_info/nv_warning/nv_error/nv_success/nv_print_header/nv_print_sectionthroughout the tool) only emits ANSI color when stdout is an interactive terminal andNO_COLOR(see no-color.org) is unset; otherwise it prints plain text, so raw escape sequences no longer leak into pipes, log files, or captured output (#255). One consequence: a script that capturesnv_print_color's own output into a variable via$(...)to build a colored table cell renders that cell plain even when run interactively, because a command-substitution subshell is not a TTY. - Status flow:
pending→sent→acked→running→completedorfailed. Terminal states insert acommand_resultsrow. - Result reporting:
_nv_report_command_status()PATCHes the derived${api_url%/upload}/agent-commandsendpoint with thex-api-keyheader, sendingcommand_id,agent_id,status, and (when present)exit_code,stdout,stderr, andsession_id. Reporting is non-fatal.
Dispatching a Command
From the portal dashboard (recommended)
Admin dashboard → Command and Control → Dispatch: choose an agent (or group), pick a command, fill the guided form (or raw JSON payload), set a priority, and send.
From the API
Admin auth is a Netlify Identity JWT (not the agent API key), sent in the X-Identity-Token header. It cannot travel in Authorization: the portal's edge intercepts any function request carrying a validly-signed Identity JWT there and terminates it before the handler runs.
bash
PORTAL="https://<portal-host>"
JWT="<Netlify Identity admin access token>"
curl -sS -X POST "$PORTAL/api/agent-commands" \
-H "X-Identity-Token: $JWT" \
-H "Content-Type: application/json" \
-d '{
"command_type": "run_scan",
"agent_id": "nvd_89f6e23d",
"payload": { "profile": "full_recon", "targets": "192.168.50.0/24" },
"priority": 0
}'Replace agent_id with group_target for a group broadcast. Track the result (admin, read-only) with the id from the POST response:
bash
curl -sS -H "X-Identity-Token: $JWT" "$PORTAL/api/agent-commands?command_id=1"Troubleshooting
- Agent never runs the command: confirm it is at least business-tiered, not deregistered (a deregistered agent gets 403 and shuts down), and has portal connectivity. Commands sit
pendinguntil the next heartbeat. run_scanfailed at ~300s: the command timeout was hit. Usequick, a smaller target, run it on the host directly, or raiseNV_CMD_TIMEOUT.- A config change reverted after restart: confirm
.c2_overrides.confexists in the agent configs dir; persistence relies on it. - Command shows
completedbut nothing changed: check the returnedstdout/stderrin the History detail (truncated to 10 KB). - History Session ID has no link / 404s: the id only resolves once the session has been uploaded. Dispatch
run_scanwithupload: true, or send apush_sessioncommand.
Related
- Daemon Mode: the heartbeat transport and agent process.
- API Contract: the cross-repo wire contract for agent commands and results.
- License Provisioning: business-tier gating.
