FTMON Exchange

security · ftmon-json

Pending apt package updates

Alert when security updates are pending, the non-security backlog grows, or the apt cache goes stale on Debian/Ubuntu hosts.

Why

Debian/Ubuntu hosts accumulate pending packages quietly until someone notices a security advisory or a surprise reboot into a large upgrade. FTMON's built-ins do not watch apt. This recipe counts upgradable packages (total and security pocket), ages the apt cache, and raises confirmed incidents when security work is waiting, the backlog crosses an operator threshold, or metadata goes stale. Trends on updates_total show whether the backlog is growing rather than being cleared.

Install

This is an original FTMON-maintained ftmon-json check (MIT). Install the script from the recipe (do not put it under FTMON's own data/state dirs):

# Dedicated / multi-user host:
sudo install -d -o root -g root -m 0755 /usr/local/lib/ftmon/checks
sudo install -o root -g root -m 0755 \
  extra-monitors/package-updates/scripts/check_apt_updates \
  /usr/local/lib/ftmon/checks/check_apt_updates

# Single-user desktop (daemon uid owns the file):
install -d -m 0755 ~/.local/lib/ftmon/checks
install -m 0755 \
  extra-monitors/package-updates/scripts/check_apt_updates \
  ~/.local/lib/ftmon/checks/check_apt_updates
# then set argv[0] in checks.toml to that absolute path

Verify trust before registration:

ftmon check trust /usr/local/lib/ftmon/checks/check_apt_updates

Requires the host apt CLI. The check only runs apt list --upgradable and reads world-readable cache stamps under /var/lib/apt and /var/cache/apt. It never runs apt-get update or installs packages.

Configure

ftmon recipe install package-updates

Defaults (-w 20, cache stale at 7 days inside the check):

apt list --upgradable lines look like name/suite version arch [upgradable from: …] (four fields before the bracket). The check's regex must match that shape; an extra field matches nothing and silently reports zero packages.

Test

/usr/local/lib/ftmon/checks/check_apt_updates -w 20
echo "$?"   # must be 0; severity is JSON state

Exit status is always **0** for ftmon-json. Severity is the JSON state field (0 OK, 1 warning, 2 critical, 3 unknown). Fixtures under fixtures/ match the live metric labels. Direct behavioral tests (offline):

uv run pytest -q extra-monitors/package-updates/tests
ftmon check
ftmon doctor

Security and permissions

No elevation and no network from the check itself: it invokes local apt and reads cache metadata. Keep the installed file root- or daemon-owned, mode 0755, not group/world-writable, and not a symlink (ftmon check trust enforces this). Remediation (apt update / apt full-upgrade) stays outside FTMON actions unless the operator adds a separate, reviewed action script.

Upstream and licence

Original FTMON recipe script under extra-monitors/package-updates, MIT. FTMON redistributes this script as the recipe's maintained check.

Verified on 2026-07-30 on Ubuntu 24.04.4 LTS: live check_apt_updates -w 20 returned exit 0 with updates_total=22, updates_security=0, cache_age in seconds, state=1 (above warn count); trust check passed for the installed path.

Registry example

[check.package_updates]
# -w must stay aligned with monitor.toml updates_warn_count (plugin_state is a
# convenience mirror; monitor rules are authoritative for alerting). Cache
# staleness defaults to 7 days inside the check (--cache-stale-s).
argv = [
  "/usr/local/lib/ftmon/checks/check_apt_updates",
  "-w", "20",
]
protocol = "ftmon-json"
# Outer FTMON timeout sits above apt list's 8s internal bound.
timeout = "12s"

Monitor definition

schema = 1

[monitor]
name = "package_updates"
description = "Pending apt package updates: security, total, and cache freshness (check_apt_updates)"
version = 1
enabled = false
platforms = ["linux"]
interval = "5m"
source = "external"

[source_options]
check = "package_updates"
entity = "apt"

[[source_options.perfdata]]
label = "updates_total"
metric = "updates_total"
plugin_uom = "packages"
unit = "packages"
kind = "gauge"

[[source_options.perfdata]]
label = "updates_security"
metric = "updates_security"
plugin_uom = "packages"
unit = "packages"
kind = "gauge"

[[source_options.perfdata]]
label = "cache_age"
metric = "cache_age_s"
plugin_uom = "s"
unit = "seconds"
kind = "gauge"

[parameters]
updates_warn_count = { value = 20, doc = "Pending non-security updates that warrant a warning — keep checks.toml -w in sync" }
cache_stale_warn_s = { value = 604800, doc = "Apt cache age (seconds) before warning — default 7 days" }
updates_growth_per_day = { value = 5, doc = "Pending-updates growth rate (per day) before notice" }
updates_growth_confidence_min = { value = 0.8, doc = "Required fraction of rising pending-updates samples" }

[glance]
metric = "updates_total"
unit = "packages"
aggregate = "max"
thresholds = [
  { label = "warn", parameter = "updates_warn_count" },
]

[[derived]]
name = "updates_total_rate_pd"
expr = 'slope(updates_total, "6h") * 86400'

[[derived]]
name = "updates_total_growth_confidence"
expr = 'monot(updates_total, "6h")'

[[derived]]
name = "cache_age_days"
expr = 'cache_age_s / 86400'

[[rule]]
id = "security-pending"
group = "apt-security"
when = "updates_security > 0"
severity = "critical"
confirm_cycles = 2
message = "{updates_security} security update(s) pending; run apt full-upgrade (or unattended-upgrade)"

[[rule]]
id = "cache-stale"
group = "apt-cache"
when = "cache_age_s > cache_stale_warn_s"
severity = "warning"
confirm_cycles = 2
message = "Apt cache is {cache_age_days:.0f} day(s) old; run apt update to refresh"

[[rule]]
id = "many-pending"
group = "apt-pending"
when = "updates_total > updates_warn_count"
severity = "warning"
confirm_cycles = 3
message = "{updates_total} package updates pending (warn at {updates_warn_count})"

[[rule]]
id = "updates-rising"
group = "apt-pending"
when = 'updates_total_rate_pd > updates_growth_per_day and updates_total_growth_confidence >= updates_growth_confidence_min'
severity = "notice"
confirm_cycles = 3
message = "Pending updates are growing steadily ({updates_total_rate_pd:.1f}/day)"

[[rule]]
id = "plugin-unknown"
group = "check-health"
when = "plugin_state == 3"
severity = "warning"
confirm_cycles = 2
message = "Package-updates check is unknown: {plugin_message}"

[[trend]]
id = "updates-growth"
kind = "growth"
title = "Pending package updates trend"
value_metric = "updates_total"
value_unit = "packages"
rate_metric = "updates_total_rate_pd"
rate_unit = "packages/day"
confidence_metric = "updates_total_growth_confidence"
confidence_threshold_param = "updates_growth_confidence_min"
rate_threshold_params = ["updates_growth_per_day"]