Skip to content

Daemon Mode ​

scripts/netvuln_daemon.sh is a persistent scheduling daemon that replaces cron-based scan scheduling. Shipped in v3.0.0, it provides multi-config support, a built-in cron engine, retry with exponential backoff, portal heartbeats with system-info reporting, signal-driven lifecycle management, and soft deregistration handling. It is also the delivery vehicle for Command and Control commands, which ride back on heartbeat responses.

Architecture ​

The daemon is a long-lived process that continuously monitors one or more configuration files. Each *.conf file in the configs directory (default ~/netvuln-tool/configs/, or --configs-dir) defines a scan target, schedule, and portal credentials. On each poll the daemon evaluates cron expressions against the current time and launches scan pipelines when a schedule matches.

Config parsing applies a final overrides layer: _nvd_load_configs() re-applies <configs_dir>/.c2_overrides.conf after the *.conf files, so an allowlisted value pushed remotely wins over the file value and survives a reload. See Command and Control for the override mechanism.

Key Features ​

  • Multi-config support: every *.conf in the configs directory is loaded; each contributes independent targets, schedules, and credentials (the dotfile .c2_overrides.conf is deliberately excluded from the glob).
  • Built-in cron engine: parses standard cron expressions (SCAN_SCHEDULE) without the system cron daemon, evaluating minute, hour, day-of-month, month, and day-of-week fields.
  • Retry with exponential backoff: a failed scan is retried up to DAEMON_SCAN_RETRY_MAX times (default 3). Backoff is DAEMON_SCAN_RETRY_BACKOFF * 2^attempt seconds (default base 60), and the wait is interruptible so signals are still handled during backoff.
  • Portal heartbeats: periodic POST to the portal /api/health endpoint every DAEMON_HEARTBEAT_INTERVAL seconds (default 3600, aligned to the top of the hour so the portal database can sleep between heartbeats), carrying agent id, version, status, uptime, schedule states, client id, a system-info snapshot, and the raw-scan capability self-check result (#215, see Privilege Model).
  • Signal-driven lifecycle: reload, graceful shutdown, and status dump without a restart.
  • Soft deregistration: an HTTP 403 heartbeat response triggers a clean shutdown.
  • Metadata injection: injects scheduled_by=daemon into session metadata, which the portal surfaces in its scheduled-scan dashboard.

Heartbeats and System Info ​

Each heartbeat cycle builds a JSON payload and POSTs it with the x-api-key header to ${api_url%/upload}/health (or DAEMON_HEARTBEAT_URL when set). The idle payload shape:

json
{
  "agent_id": "nvd_89f6e23d",
  "version": "4.3.3",
  "timestamp": "2026-07-18T12:00:00Z",
  "status": "running",
  "uptime_seconds": 3600,
  "schedules": [
    {
      "config": "acme_corp.conf",
      "schedule_id": "sched_ab12cd",
      "last_run": "2026-07-18T06:00:00Z",
      "last_status": "success",
      "next_run": "2026-07-19T06:00:00Z",
      "retry_count": 0
    }
  ],
  "client_id": "acme-corp",
  "system_info": { "os": {}, "hardware": {}, "packages": {}, "network": {}, "config": {} },
  "raw_scan_capable": true,
  "current_command": null
}

While a C2 command is executing, a background "keeper" (#384) posts a busy variant of the same payload every DAEMON_HEARTBEAT_INTERVAL seconds instead, so the portal and daemon_status.json do not go quiet for the command's full duration:

json
{
  "agent_id": "nvd_89f6e23d",
  "version": "4.22.0",
  "timestamp": "2026-09-17T07:40:00Z",
  "status": "busy",
  "uptime_seconds": 3600,
  "current_command": { "id": 76, "type": "run_scan", "started_at": "2026-09-17T07:38:00Z" },
  "schedules": [],
  "client_id": "acme-corp",
  "system_info": { "os": {}, "hardware": {}, "packages": {}, "network": {}, "config": {} },
  "raw_scan_capable": true
}

current_command is non-null only while status is busy; idle heartbeats keep the shape above, just with current_command: null. The keeper is started right before the command's scan subprocess launches and stopped the moment it returns, before the terminal status PATCH, so the portal never sees a busy heartbeat after completion is already reported; DAEMON_KEEPER_HEARTBEAT=0 disables it (see Configuration below).

The system_info block is gathered by _nvd_gather_system_info() and cached, refreshed only on the first heartbeat and then every 10th cycle to avoid overhead:

  • os: name, version, kernel, arch, hostname (reads /etc/os-release, falls back to sw_vers on macOS).
  • hardware: CPU count, total/available RAM (MB), total/available disk (GB) for /.
  • packages: detected versions of nmap, nikto, testssl, nuclei, whatweb, sslscan, and the netvuln-tool version.
  • network: non-loopback IPv4 addresses, public IP (via ifconfig.me), interfaces, DNS resolver, and a connectivity probe.
  • config: config path, log level, scan concurrency, storage path, and enabled modules.

The agent id is derived as nvd_<first 8 hex of md5(hostname)> and can be overridden with NVD_AGENT_ID (used in tests).

Soft Deregistration (HTTP 403) ​

Agents auto-register on their first heartbeat (no approval step). When an administrator deregisters an agent from the portal, the next heartbeat returns HTTP 403. The daemon logs Agent has been unregistered by administrator. Shutting down., sets the shutdown flag, and exits cleanly. Only a heartbeat acts on the 403; the 60 second command poll ignores a 403 (it is not proof of deregistration). With the hourly default heartbeat a deregistered agent keeps running until its next heartbeat, up to one interval later, and purging it before then lets that heartbeat register it again as a new agent. To retire an agent quickly, stop the daemon on the host, or push a shorter DAEMON_HEARTBEAT_INTERVAL first. The database record is retained (hidden from the fleet list) and can be re-enabled. See Command and Control for the full agent lifecycle (register, deregister, purge).

Signals ​

SignalBehavior
SIGHUPReload all config files. New configs are picked up, removed configs dropped, changed schedules take effect, and the persisted overrides file is re-applied.
SIGTERMGraceful shutdown. A running scan is allowed to finish before exit.
SIGINTGraceful shutdown (same as SIGTERM).
SIGUSR1Dump current status to ~/netvuln-tool/logs/daemon_status.json (pid, agent id, version, uptime, loaded configs, per-schedule next/last run and retry state, last heartbeat, raw-scan capability).

CLI Usage ​

Daemon ​

netvuln_daemon.sh [--configs-dir <dir>] [--foreground] [-h]

Scheduled scans need DAEMON_ENABLED="true" in at least one config file. With none enabled, the daemon stays up in idle mode: it runs no scans but keeps sending heartbeats and polling for commands (using the first config that sets both CONSULTATION_API_URL and a key), so the agent appears in the portal and receives C2 commands. Scanning is enabled by setting DAEMON_ENABLED="true" in the config on the host and reloading the daemon (netvuln_daemon_ctl.sh reload, or systemctl reload netvuln-daemon); push_config cannot set it. A C2 run_scan while idle runs without a client config, so the report lacks the client name and assessor. --foreground keeps the process attached instead of daemonizing (useful for debugging and under systemd).

Daemon Control ​

scripts/netvuln_daemon_ctl.sh is the management interface:

netvuln_daemon_ctl.sh <command> [options]

Commands:
  start              Start the daemon in the background
  stop               Stop the running daemon gracefully
  restart            Stop and then start the daemon
  reload             Send SIGHUP to reload configuration
  status             Show daemon status and schedule summary
  install-service    Install the systemd service unit (Linux only)
  uninstall-service  Remove the systemd service unit (Linux only)

The operator control script, netvuln_daemon_ctl.sh, resolves the daemon's base directory (where it looks for the PID file, status file, and logs) in this order: NVD_BASE_DIR if set in the operator's shell; otherwise the NVD_BASE_DIR value baked into the installed netvuln-daemon.service unit (systemctl show -p Environment), when systemctl exists and reports the unit; otherwise /opt/netvuln-tool when that directory exists and $HOME/netvuln-tool does not; otherwise $HOME/netvuln-tool (#405).

The Python CLI also wraps this: python3 -m netvuln daemon <action> forwards to netvuln_daemon_ctl.sh. See Python Orchestrator.

Configuration ​

Daemon behavior is controlled through config-file variables:

VariableDefaultDescription
DAEMON_ENABLEDfalseEnable daemon scheduling for this config
DAEMON_POLL_INTERVAL60Seconds between schedule checks
DAEMON_HEARTBEAT_INTERVAL3600Seconds between portal heartbeats
DAEMON_HEARTBEAT_ALIGN11 sends the periodic heartbeat at the next multiple of the interval plus a stable per-agent offset of 0 to 45 s, 0 one interval after the previous one
DAEMON_SCAN_RETRY_MAX3Max retry attempts on scan failure
DAEMON_SCAN_RETRY_BACKOFF60Base backoff seconds (doubles each retry)
DAEMON_LOG_LEVELinfoVerbosity: debug, info, warning, error
DAEMON_KEEPER_HEARTBEAT1Post busy heartbeats while a C2 command runs (0 to disable)
DAEMON_COMMAND_POLL1Poll for commands on idle ticks instead of waiting for the next heartbeat (0 to disable)
DAEMON_POST_SCAN(none)Comma-separated post-scan modules for scheduled scans: exec_summary, compliance, topology

Every key above except DAEMON_ENABLED (plus ALERT_ENABLED, ALERT_RISK_THRESHOLD, ALERT_ON_NEW_CRITICAL) are the allowlist that a portal push_config command may edit remotely; everything else (targets, schedules, storage path) is file-managed only.

DAEMON_POST_SCAN (per-config key, comma-separated tokens from the same set a C2 run_scan's post_scan array accepts: exec_summary, compliance, topology) requests the same optional post-scan modules for a scheduled scan; an unrecognized token is logged as a warning and dropped. Absent or empty, a scheduled scan runs exactly as before (#409). A scheduled scan's own upload is now the single upload for that scan (NV_SKIP_AUTO_SUBMIT=1 disables run_pipeline.sh's auto-submit, closing #399): it carries the session JSON, pipeline log, HTML report, and the executive summary when generated, the same file set a C2 run_scan upload carries.

An agent that will self-update via the update C2 command needs a git remote it 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.

systemd Integration ​

On Linux the daemon can run as a systemd service:

bash
# Install the service unit (renders templates/netvuln_daemon.service)
sudo ./scripts/netvuln_daemon_ctl.sh install-service

# Enable and start
sudo systemctl enable netvuln-daemon
sudo systemctl start netvuln-daemon

# Check status and follow logs
sudo systemctl status netvuln-daemon
journalctl -u netvuln-daemon -f

install-service writes the unit to /etc/systemd/system/netvuln-daemon.service and runs systemctl daemon-reload. uninstall-service removes it. Both refuse to run on macOS or where systemctl is absent.

bash
sudo ./scripts/netvuln_daemon_ctl.sh uninstall-service

Privilege Model ​

The daemon runs as the invoking non-root user (User=__USER__ in the rendered unit), never as root. Two nmap scan types need a raw socket and normally require root: OS fingerprinting (nmap -O, run automatically by discover_hosts.sh after a successful sweep) and SNMP/UDP enumeration (nmap -sU, used by enum_snmp.sh). Rather than run the whole daemon as root to get that, the systemd unit grants the single capability those scans need, the same rootless approach the Docker image already uses.

Two things happen together, both required:

  1. AmbientCapabilities=CAP_NET_RAW and CapabilityBoundingSet=CAP_NET_RAW in the unit. This gives the daemon process CAP_NET_RAW without giving it any other root-level capability. install-service also runs sudo setcap cap_net_raw+eip on the nmap binary itself as a second, belt-and-suspenders path; a file capability on nmap works even for a manually-run scan outside the daemon, and it is what lets a non-systemd invocation (a scan run by hand in a terminal) still use raw sockets. The setcap step is best-effort: if it fails, install-service warns and continues, because the unit's AmbientCapabilities is the durable mechanism and does not depend on the nmap binary's on-disk state.
  2. NMAP_PRIVILEGED=1 in the environment. nmap decides whether it is allowed to use raw sockets by checking EUID == 0; it does not inspect ambient capabilities or file capabilities on its own, so without this variable a non-root nmap still refuses privileged scan types even when the capability is present. The unit sets it via Environment="NMAP_PRIVILEGED=1". discover_hosts.sh and enum_snmp.sh also export it themselves, belt-and-suspenders, whenever they are about to run a privileged scan type non-root, so a manually-run scan with a setcap'd nmap works even outside the daemon's own environment.

nv_can_raw_scan() in lib/netvuln_common.sh is the single gate both discover_hosts.sh's OS-detection step and any future privileged-scan caller should use. It returns success when any of the following hold: the process is root; the process itself holds CAP_NET_RAW in its effective capability set (read from CapEff in /proc/self/status, the same ambient capability the systemd unit grants); or the process is not root but nmap carries the cap_net_raw file capability (checked via getcap, after resolving symlinks with readlink -f). The middle check is what lets the daemon's own scheduled scans keep working purely off the unit's AmbientCapabilities grant, independent of whatever state the nmap binary's file capability happens to be in. enum_snmp.sh's -sU call has no explicit gate; it simply works once the process has a capability and NMAP_PRIVILEGED=1, the same way it already worked as root.

nmap upgrades: prevention, resilience, and detection (#215) ​

A file capability set with setcap lives on the binary's inode, not in a database, so reinstalling or upgrading nmap through the package manager replaces the binary and silently drops the capability. Three layers now address that:

  • Resilience. Because nv_can_raw_scan() also checks the process's own ambient CAP_NET_RAW (see above), the daemon's own scheduled OS detection and SNMP/UDP scans keep working through an nmap upgrade even before anything reapplies the file capability. Only a manually-run scan outside the daemon, which has no ambient capability of its own, still depends on the file capability.
  • Prevention. install-service drops an idempotent apt post-invoke hook at /etc/apt/apt.conf.d/99-netvuln-nmap-cap that reapplies setcap cap_net_raw+eip on the resolved nmap path after every apt/dpkg run, so the file capability self-heals right after the upgrade that reset it, with no manual step. uninstall-service removes the hook. Like the setcap step itself, this is best-effort: a failure only warns, and the durable AmbientCapabilities grant and the resilience layer above are unaffected either way.
  • Detection. The daemon runs the same nv_can_raw_scan() check at startup and once per heartbeat cycle. A true to false transition, meaning the capability was actually lost, logs a loud WARNING to daemon.log/the journal, rather than the quiet nv_info line a skipped scan used to log on its own. The current state is exposed as raw_scan_capable (boolean) in both daemon_status.json and the portal heartbeat payload, so it is visible without grepping logs.

If the file capability is ever out of date on a host that predates the apt hook, re-run install-service, or sudo setcap cap_net_raw+eip $(command -v nmap) by hand.

Skipped-scan visibility (#256) ​

A per-run OS-detection skip, nv_can_raw_scan() returning false when discover_hosts.sh runs against a target, used to log only a quiet nv_info line: easy to miss unless someone was watching the live console, and nothing recorded it in the session results. discover_hosts.sh now calls nv_warning, naming nmap -O specifically, and records an "OS Detection Skipped" finding (severity info) via nv_json_add_finding(), so the gap also shows up in the report and logs. This is visibility-only: it changes nothing about when OS detection runs, still gated by the same nv_can_raw_scan() check, or about the rootless CAP_NET_RAW privilege model described above (#209).

Apache-2.0 licensed (appliance subtree proprietary)