packaging/installer/netdata-support-bundle
Rama master · réplica de lectura del repositorio original.
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
#
# shellcheck disable=SC2016 # the sh -c and awk mini-programs in the collectors
# are intentionally single-quoted: they must reach the child shell/awk unexpanded.
#
# netdata-support-bundle - collect a diagnostic bundle for Netdata support tickets.
#
# What is collected and WHY each item is included is documented in
# packaging/installer/SUPPORT-BUNDLE.md - read it before adding or changing
# a collection item, and keep it in sync with this script.
#
# Design contract:
# - Works with or without a running agent, with or without root (degrades gracefully).
# - Minimize system impact: idle CPU/IO priority, per-command timeouts, global deadline,
# size caps, strictly read-only outside its own staging dir.
# - Standard captures redact secrets, with a streaming configuration exception:
# the streaming API key in stream.conf is kept verbatim (it is what support
# needs to match a child against its parent - netdata/netdata#23448).
# PII (IPs, MACs, emails, hostnames) pseudonymized by default;
# --no-obfuscate disables PII pass only. Explicit --include-snmp-diagnostics
# adds raw SNMP files with neither secret redaction nor PII obfuscation.
# - Collected files keep their original bytes: BOM, line terminators and a
# missing final newline survive redaction, so encoding faults stay diagnosable.
# - Bundle is legible to humans AND AI agents: triage-ordered directories, pristine
# file copies, provenance in MANIFEST.json, human summary.txt, README.md inside.
#
# Usage: sudo netdata-support-bundle [options] (or: sh netdata-support-bundle [options])
# -o, --output DIR where to write the tarball (default: /tmp)
# --since HOURS log window in hours (default: 24)
# --timeout SECONDS per-command timeout (default: 10)
# --no-obfuscate disable PII pseudonymization (secrets STILL redacted)
# --include-snmp-diagnostics include original, UNSANITIZED SNMP evidence
# --include-plugin-debug RUN systemd-journal.plugin in debug mode and capture
# its output (the only option that executes a collector)
# --keep-staging keep staging dir for inspection
# --selftest run the sanitizer regression vectors and exit
# -v, --version print version
# -h, --help this help
demote() {
# --- self-demotion FIRST (before arg parsing consumes "$@"): never compete
# --- with real workloads
if [ -z "${ND_SUPPORT_BUNDLE_DEMOTED:-}" ]; then
ND_SUPPORT_BUNDLE_DEMOTED=1; export ND_SUPPORT_BUNDLE_DEMOTED
# ionice may exist but be denied (e.g. no CAP_SYS_NICE): probe idle class
# once, and only route through it if it actually works; else just nice
if command -v ionice >/dev/null 2>&1 && ionice -c 3 true >/dev/null 2>&1; then
exec nice -n 19 ionice -c 3 sh "$0" "$@"
fi
exec nice -n 19 sh "$0" "$@"
fi
}
init_defaults() {
VERSION="1.3.0"
OUTDIR="/tmp"
SINCE_HOURS=24
CMD_TIMEOUT=10
OBFUSCATE=1
KEEP_STAGING=0
SELFTEST=0
INCLUDE_SNMP=0
INCLUDE_PLUGIN_DEBUG=0
SNMP_FILES=0
SNMP_STATUS=not_requested
# Admission deadline checked before collectors; filesystem operations,
# sanitization and final packaging are not a hard wall-clock bound.
GLOBAL_DEADLINE=240
LOG_CAP=5242880 # 5 MiB per log file
FILE_CAP=1048576 # 1 MiB per config/state file
API_CAP=2097152 # 2 MiB per API response
MAP_LIMIT=4096 # numbered pseudonyms per category
NDPORT=19999
api_ok=0
}
need_val() { [ $# -ge 2 ] || { echo "option $1 needs a value" >&2; exit 1; }; }
parse_options() {
while [ $# -gt 0 ]; do
case "$1" in
-o|--output) need_val "$@"; OUTDIR="$2"; shift 2 ;;
--since) need_val "$@"; SINCE_HOURS="$2"; shift 2 ;;
--timeout) need_val "$@"; CMD_TIMEOUT="$2"; shift 2 ;;
--no-obfuscate) OBFUSCATE=0; shift ;;
--include-snmp-diagnostics) INCLUDE_SNMP=1; shift ;;
--include-plugin-debug) INCLUDE_PLUGIN_DEBUG=1; shift ;;
--keep-staging) KEEP_STAGING=1; shift ;;
--selftest) SELFTEST=1; shift ;;
-v|--version) echo "netdata-support-bundle $VERSION"; exit 0 ;;
-h|--help) sed -n '/^# netdata-support-bundle - collect/,/^# -h, --help/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
done
case "$SINCE_HOURS" in *[!0-9]*|''|0) echo "--since must be a positive integer (hours)" >&2; exit 1 ;; *) : ;; esac
case "$CMD_TIMEOUT" in *[!0-9]*|''|0) echo "--timeout must be a positive integer (seconds)" >&2; exit 1 ;; *) : ;; esac
}
init_staging() {
umask 077
START_TS=$(date +%s)
NOW=$(date -u +%Y%m%d-%H%M%S)
STAGING=$(mktemp -d "${TMPDIR:-/tmp}/netdata-support-bundle.XXXXXX") || exit 1
BUNDLE="netdata-support-bundle-${NOW}-$$"
WORK="$STAGING/$BUNDLE"
mkdir -p "$WORK"
MAP_FILE="$STAGING/map.tsv"; : > "$MAP_FILE"
MANIFEST_ROWS="$STAGING/manifest.rows"; : > "$MANIFEST_ROWS"
ERRORS="$STAGING/errors.txt"; : > "$ERRORS"
# what the sanitizer must preserve for the file it is currently processing
ENC_BOM_BYTES=0; ENC_FINAL=true; ENC_CRONLY=0
trap cleanup EXIT
trap 'cleanup; trap - EXIT; exit 130' INT TERM
}
cleanup() { [ "$KEEP_STAGING" = "1" ] || rm -rf "$STAGING"; }
info() { printf ' [*] %s\n' "$*" >&2; }
now_s() { date +%s; }
deadline_exceeded() { [ $(( $(now_s) - START_TS )) -ge "$GLOBAL_DEADLINE" ]; }
# timeout capability: GNU/FreeBSD support -k (kill-after); busybox does not; macOS has none
detect_timeout() {
have_timeout=0
if command -v timeout >/dev/null 2>&1; then
if timeout -k 2 5 true 2>/dev/null; then have_timeout=2
elif timeout 5 true 2>/dev/null; then have_timeout=1
fi
fi
}
run_capped() { # run_capped <seconds> <cmd...>
_rc_t="$1"; shift
case "$have_timeout" in
2) timeout -k 2 "$_rc_t" "$@" ;;
1) timeout "$_rc_t" "$@" ;;
*)
# no timeout binary (macOS): portable watchdog so a stuck collector
# cannot hang the whole run. KNOWN WEAKER GUARANTEE than GNU/BSD
# timeout (which kills the whole process group): this best-effort path
# SIGKILLs the direct child and, where pkill exists, its children;
# deeper sh -c grandchildren may briefly orphan. Only reached on hosts
# lacking timeout; this is not a process-group runtime guarantee.
"$@" &
_rc_cmdpid=$!
(
_rc_i=0
while [ "$_rc_i" -lt "$_rc_t" ]; do
sleep 1
kill -0 "$_rc_cmdpid" 2>/dev/null || exit 0
_rc_i=$((_rc_i + 1))
done
command -v pkill >/dev/null 2>&1 && pkill -9 -P "$_rc_cmdpid" 2>/dev/null
kill -9 "$_rc_cmdpid" 2>/dev/null
) &
_rc_wdpid=$!
wait "$_rc_cmdpid"
_rc_rc=$?
kill "$_rc_wdpid" 2>/dev/null
wait "$_rc_wdpid" 2>/dev/null
return "$_rc_rc"
;;
esac
}
# --- sanitizer: single awk pass, portable (gawk/mawk/busybox) ---------------
#
# Redaction philosophy (deliberately proportionate, like sosreport/supportconfig):
# redact the WELL-DEFINED, high-value cases robustly - credential-bearing config
# keys, URL/DSN creds, JWT/Bearer/Basic tokens, PEM key blocks, and PII - and
# stop there. We do NOT try to parse arbitrary nested structure (JSON objects,
# YAML block scalars) to prove no secret can ever slip through: a line-based
# tool cannot do that reliably, and every attempt just adds fragile regex for
# encodings that do not occur in the data this bundle actually collects. This is
# best-effort defense-in-depth, and it rests on two things every support-bundle
# tool relies on: it runs on the user's own host, and the user reviews the
# tarball before sending it. Files that are PURE secrets are never collected at
# all (see the never-collect list). Prefer this stable baseline over a brittle
# one chasing completeness. See SUPPORT-BUNDLE.md "Redaction philosophy".
#
# pass 1 (always): credential-key values, URL/DSN creds, JWTs, PEM key blocks
# pass 2 (default): emails, MACs, IPv4 pseudonyms (stable via map), hostnames
init_sanitizer_context() {
HOST_SHORT=$(hostname 2>/dev/null || echo "")
HOST_FQDN=$(hostname -f 2>/dev/null || echo "")
[ "$HOST_SHORT" = "localhost" ] && HOST_SHORT=""
[ "$HOST_FQDN" = "localhost" ] && HOST_FQDN=""
[ ${#HOST_SHORT} -lt 4 ] && HOST_SHORT=""
# the invoking user's name is PII too (ps USER column, /home/<name> paths)
RUN_USER=$(id -un 2>/dev/null || echo "")
case "$RUN_USER" in root|netdata|"") RUN_USER="" ;; *) : ;; esac
[ ${#RUN_USER} -lt 3 ] && RUN_USER=""
}
# encoding_probe <staged-file> - work out what the sanitizer must preserve:
# where the BOM ends, whether the file ends with a terminator, and whether it
# uses CR-only line endings. Redaction used to silently normalize all three
# away (netdata/netdata#23448).
encoding_probe() {
_ep_ef="$1"
ENC_BOM_BYTES=0; ENC_FINAL=true; ENC_CRONLY=0
case "$(head -c 4 "$_ep_ef" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n')" in
efbbbf*) ENC_BOM_BYTES=3 ;;
fffe0000|0000feff) ENC_BOM_BYTES=4 ;;
fffe*|feff*) ENC_BOM_BYTES=2 ;;
*) : ;;
esac
# CR is a line terminator too, so a CR-terminated file is not missing its
# final newline - sanitize_file relies on this to not eat that last byte
case "$(tail -c 1 "$_ep_ef" 2>/dev/null | od -An -tx1 | tr -d ' \n')" in
0a|0d) : ;;
*) ENC_FINAL=false ;;
esac
# CR-only (classic Mac) endings make the whole file ONE awk record, so only
# its first key would ever be examined - sanitize_file translates for the pass
if [ "$(LC_ALL=C tr -dc '\n' < "$_ep_ef" 2>/dev/null | wc -c | tr -d ' ')" = "0" ] &&
[ "$(LC_ALL=C tr -dc '\r' < "$_ep_ef" 2>/dev/null | wc -c | tr -d ' ')" != "0" ]; then
ENC_CRONLY=1
fi
return 0
}
# sanitize_file <staged-file> [context]
# context "stream" marks stream.conf, the one file where the streaming API key
# is kept verbatim (netdata/netdata#23448). Every other file, and every other
# secret key inside stream.conf, is redacted as before.
sanitize_file() {
_san_f="$1"; _san_ctx="${2:-}"
[ -f "$_san_f" ] || return 0
encoding_probe "$_san_f"
# binary/UTF-16 input would make line-based redaction byte-unsafe: withhold
_san_b_all=$(wc -c < "$_san_f")
_san_b_txt=$(LC_ALL=C tr -d '\000' < "$_san_f" | wc -c)
if [ "$_san_b_all" -ne "$_san_b_txt" ]; then
echo "[content withheld: file contains NUL bytes (binary or UTF-16?)]" > "$_san_f"
return 0
fi
# A BOM shifts every ^-anchored rule in the sanitizer: a BOM'd "[<API_KEY>]"
# header did NOT match the section rule and shipped verbatim. Strip the BOM
# for the redaction pass and restore the exact bytes afterwards.
_san_sanin="$_san_f"; _san_bomstripped=0
if [ "$ENC_BOM_BYTES" -gt 0 ]; then
if tail -c "+$((ENC_BOM_BYTES + 1))" "$_san_f" > "$_san_f.nobom" 2>>"$ERRORS"; then
_san_sanin="$_san_f.nobom"; _san_bomstripped=1
fi
fi
# A CR-only file is a single record to awk, so every rule would see one giant
# line and only its FIRST key would be examined - later secrets would ship
# unredacted. Translate CR to LF for the pass and translate back after, which
# redacts correctly and still reproduces the original bytes.
if [ "$ENC_CRONLY" = "1" ]; then
if LC_ALL=C tr '\r' '\n' < "$_san_sanin" > "$_san_f.lf" 2>>"$ERRORS"; then _san_sanin="$_san_f.lf"; fi
fi
if LC_ALL=C awk -v map_limit="$MAP_LIMIT" -v ctx="$_san_ctx" -v obf="$OBFUSCATE" -v mapfile="$MAP_FILE" \
-v host_short="$HOST_SHORT" -v host_fqdn="$HOST_FQDN" -v run_user="$RUN_USER" '
BEGIN {
nsec = split("api key,apikey,token,password,passwd,pwd,secret,community,bearer,webhook,license key,auth,credential,cookie,passphrase,proxy user,proxy pass,username,dsn,private key,access key,session,recipient,account sid,priv key", SK, ",");
for (i = 1; i <= nsec; i++) gsub(/[-_]/, " ", SK[i]);
nip = 0;
while (obf == 1 && (getline line < mapfile) > 0) {
split(line, a, "\t");
if (a[1] == "ip") { ipmap[a[2]] = a[3]; nip++; }
else if (a[1] == "host") hostmap[a[2]] = a[3];
else if (a[1] == "user") { nusers++; usermap[a[2]] = a[3]; if (a[3] ~ /^user-[0-9]+$/) { un = a[3]; sub(/user-/, "", un); if (un + 0 > nusr) nusr = un + 0 } }
else if (a[1] == "ip6") { ip6map[a[2]] = a[3]; nip6++; }
else if (a[1] == "fqdn") {
fqmap[a[2]] = a[3];
if (a[3] ~ /^private-host-[0-9]+$/) nfq++;
index_hostname(a[2]);
}
}
close(mapfile);
}
function normalize_key(key) {
key = tolower(key); gsub(/[-_]/, " ", key); return key;
}
function diagnostic_key(lk) {
# exemptions are decided by the KEY, never the value (a secret can be any
# string, incl. "false" or a path). Keys ENDING in these words describe
# secrets or toggles rather than being secrets: "bearer token protection",
# "netdata management api key file", "TCP SYN cookies".
if (lk ~ /(^| )(file|path|dir|directory|protection|support|mode|level|port|timeout|cookies|secure|log|size|options)$/) return 1;
return 0;
}
function streaming_api_key(lk) {
# netdata/netdata#23448: the STREAMING api key is not treated as a secret -
# support needs its value to tell whether a child and its parent agree.
# Scoped to stream.conf (ctx) and to an EXACT key match, so a third-party
# "api key" in any other file, and any other secret key inside stream.conf,
# is still redacted. Deliberately NOT applied to access logs, where the same
# value appears via stream-receiver-connection.c and a "key=" query param.
return (ctx == "stream" && (lk == "api key" || lk == "proxy api key"));
}
function redact_kv(line, i, k, pos, posc, pose, key, lk) {
# ini/yaml/env style: <key> = value | <key>: value | KEY=value
# (JSON-shaped lines are owned by redact_json, which preserves quoting)
if (line ~ /^[ \t]*"/) return line;
pose = index(line, "="); posc = index(line, ":");
pos = 0;
if (pose > 0 && (posc == 0 || pose < posc)) pos = pose;
else if (posc > 0) pos = posc;
if (pos > 1) {
key = substr(line, 1, pos - 1);
gsub(/^[ \t#]+|[ \t]+$/, "", key);
# only plausible config keys: short, no sentence/shell punctuation
# (prevents prose like "token and ... not collected: X" matching as a key)
if (length(key) > 64 || key !~ /^[A-Za-z0-9]/ || key ~ /["`;|()\/]/) return line;
lk = normalize_key(key);
if (diagnostic_key(lk)) return line;
if (streaming_api_key(lk)) return line;
for (i = 1; i <= nsec; i++) {
if (index(lk, SK[i]) > 0 && substr(line, pos + 1) ~ /[^ \t]/)
return substr(line, 1, pos) " [REDACTED]";
}
}
return line;
}
function redact_json(line, out, rest, key, lk, i, k, m, v, pre, keypart, after, hit) {
# "key": "value" pairs, possibly many per line
out = ""; rest = line;
while (match(rest, /"[^"]+"[ \t]*:[ \t]*"([^"\\]|\\.)*"/)) {
m = substr(rest, RSTART, RLENGTH);
key = m; sub(/^"/, "", key); sub(/".*/, "", key);
lk = normalize_key(key);
if (!diagnostic_key(lk)) {
for (i = 1; i <= nsec; i++) {
if (index(lk, SK[i]) > 0) {
sub(/:[ \t]*"([^"\\]|\\.)*"/, ": \"[REDACTED]\"", m);
break;
}
}
}
out = out substr(rest, 1, RSTART - 1) m;
rest = substr(rest, RSTART + RLENGTH);
}
line = out rest;
# scalar (unquoted) JSON values under secret keys: "key": 12345
out = ""; rest = line;
while (match(rest, /"[^"]+"[ \t]*:[ \t]*[-0-9truefalsnu][0-9truefalsnul.eE+-]*/)) {
m = substr(rest, RSTART, RLENGTH);
key = m; sub(/^"/, "", key); sub(/".*/, "", key);
lk = normalize_key(key);
hit = 0;
for (i = 1; i <= nsec; i++) if (index(lk, SK[i]) > 0) hit = 1;
if (hit && !diagnostic_key(lk)) sub(/:[ \t]*[-0-9truefalsnu][0-9truefalsnul.eE+-]*$/, ": \"[REDACTED]\"", m);
out = out substr(rest, 1, RSTART - 1) m;
rest = substr(rest, RSTART + RLENGTH);
}
line = out rest;
# NOTE (scope): we deliberately do NOT try to balance nested JSON
# arrays/objects under secret keys. A line-based tool cannot reliably find
# a structured value boundary (brackets can appear inside strings), and
# the JSON this bundle collects is the agent API output, which puts
# credentials in string/scalar fields, not nested structures. Chasing that
# case adds fragile bracket-matching for input that does not occur here.
return out rest;
}
function pseudo_ip(ip, p) {
if (ip ~ /^127\./ || ip == "0.0.0.0" || ip ~ /^255\./) return ip;
if (!(ip in ipmap)) {
if (nip >= map_limit) return "redacted-ip"; # cap: non-correlating past 4096
nip++; ipmap[ip] = "ip-" nip;
print "ip\t" ip "\t" ipmap[ip] >> mapfile;
}
return ipmap[ip];
}
function replace_ips(line, out, rest, ip) {
out = ""; rest = line;
while (match(rest, /[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?/)) {
ip = substr(rest, RSTART, RLENGTH);
out = out substr(rest, 1, RSTART - 1) pseudo_ip(ip);
rest = substr(rest, RSTART + RLENGTH);
}
return out rest;
}
function replace_ip6(line, out, rest, cand, pre, nc, t) {
# IPv6 pseudonymization. Candidates are hex-and-colon runs; validated to
# avoid timestamps (13:38:34), file:line refs and C++ :: tokens:
# - not preceded by a word char, >=5 chars, contains ":"
# - >=3 colons or a "::" compression
# - contains a hex letter or a "::" (all-digit uncompressed v6 is skipped)
# ::1 and :: are kept (loopback/wildcard).
if (index(line, ":") == 0) return line;
out = ""; rest = line;
while (match(rest, /[0-9A-Fa-f:]+/)) {
cand = substr(rest, RSTART, RLENGTH);
pre = (RSTART > 1) ? substr(rest, RSTART - 1, 1) : "";
t = cand; nc = gsub(/:/, ":", t);
if (index(cand, ":") == 0 || pre ~ /[A-Za-z0-9._-]/ || length(cand) < 5 || cand ~ /:$/ ||
(nc < 3 && index(cand, "::") == 0) ||
(cand !~ /[A-Fa-f]/ && index(cand, "::") == 0 && nc < 6) ||
cand == "::1") {
out = out substr(rest, 1, RSTART + RLENGTH - 1);
rest = substr(rest, RSTART + RLENGTH);
continue;
}
if (!(cand in ip6map)) {
if (nip6 >= map_limit) { out = out substr(rest, 1, RSTART - 1) "redacted-ip6"; rest = substr(rest, RSTART + RLENGTH); continue; }
nip6++; ip6map[cand] = "ip6-" nip6;
print "ip6\t" cand "\t" ip6map[cand] >> mapfile;
}
out = out substr(rest, 1, RSTART - 1) ip6map[cand];
rest = substr(rest, RSTART + RLENGTH);
}
return out rest;
}
function pseudo_fqdn(h) {
if (!(h in fqmap)) {
# Keep overflow identities recognizable in later files, even though they
# share one non-correlating placeholder after the numbered-map budget.
if (nfq >= map_limit) fqmap[h] = "redacted-host-overflow";
else { nfq++; fqmap[h] = "private-host-" nfq; }
index_hostname(h);
print "fqdn\t" h "\t" fqmap[h] >> mapfile;
}
return fqmap[h];
}
function index_hostname(h, node, i, edge) {
# A prefix index makes matching independent of the number of mapped hosts.
if (length(h) < 4) return;
node = 0;
for (i = 1; i <= length(h); i++) {
edge = node SUBSEP substr(h, i, 1);
if (!(edge in hostedge)) hostedge[edge] = ++hostnodes;
node = hostedge[edge];
}
hostvalue[node] = fqmap[h];
}
function replace_mapped_fqdns(line, out, start, pos, j, node, edge, last, value, len) {
# Preserve the existing word boundaries. Overlapping names use the longest
# complete match, independent of associative-array iteration order.
if (!nfq) return line;
out = ""; start = 1; len = length(line);
for (pos = 1; pos <= len; pos++) {
if (pos > 1 && substr(line, pos - 1, 1) ~ /[A-Za-z0-9.-]/) continue;
node = 0; last = 0;
for (j = pos; j <= len; j++) {
edge = node SUBSEP substr(line, j, 1);
if (!(edge in hostedge)) break;
node = hostedge[edge];
if (node in hostvalue && substr(line, j + 1, 1) !~ /[A-Za-z0-9.-]/) {
last = j; value = hostvalue[node];
}
}
if (last) {
out = out substr(line, start, pos - start) value;
pos = last; start = last + 1;
}
}
return out substr(line, start);
}
function redact_destination(line, pos, head, valpart, n, parts, i, tok, hostp, rest2, cpos, proto) {
# stream.conf destination/proxy destination values are user infrastructure
# hostnames regardless of TLD. Token syntax: [PROTOCOL:]HOST[%IFACE][:PORT][:SSL]
pos = index(line, "=");
if (pos == 0) return line;
head = substr(line, 1, pos);
valpart = substr(line, pos + 1);
n = split(valpart, parts, /[ \t]+/);
valpart = "";
for (i = 1; i <= n; i++) {
tok = parts[i];
if (tok == "") continue;
proto = "";
if (tok ~ /^(tcp|udp|unix):/) {
cpos = index(tok, ":");
proto = substr(tok, 1, cpos);
tok = substr(tok, cpos + 1);
}
# bracketed IPv6 belongs to the IP rules; unix socket paths are not hostnames
if (tok ~ /^\[/ || tok ~ /^\//) { valpart = valpart " " proto tok; continue; }
cpos = index(tok, ":");
if (cpos > 1) { hostp = substr(tok, 1, cpos - 1); rest2 = substr(tok, cpos); }
else { hostp = tok; rest2 = ""; }
cpos = index(hostp, "%");
if (cpos > 1) { rest2 = substr(hostp, cpos) rest2; hostp = substr(hostp, 1, cpos - 1); }
# leave IPs to the IP rules, and never map an existing pseudonym
if (hostp != "" && length(hostp) >= 4 && \
hostp !~ /^[0-9.]+$/ && hostp !~ /^[0-9A-Fa-f:]+$/ && \
hostp !~ /^(ip|ip6|private-host)-[0-9]+$/)
hostp = pseudo_fqdn(hostp);
valpart = valpart " " proto hostp rest2;
}
return head valpart;
}
function replace_private_fqdns(line, out, rest, fq, nxt) {
# hostnames under clearly-private TLDs are user infrastructure -> pseudonymize
out = ""; rest = line;
while (match(rest, /[A-Za-z0-9][A-Za-z0-9.-]*\.(internal|local|lan|corp|intranet|localdomain)/)) {
fq = substr(rest, RSTART, RLENGTH);
nxt = substr(rest, RSTART + RLENGTH, 1);
if (nxt ~ /[A-Za-z0-9-]/) { # partial word (e.g. .locale) - keep as is
out = out substr(rest, 1, RSTART + RLENGTH - 1);
rest = substr(rest, RSTART + RLENGTH);
continue;
}
out = out substr(rest, 1, RSTART - 1) pseudo_fqdn(fq);
rest = substr(rest, RSTART + RLENGTH);
}
return out rest;
}
function replace_host(line, h, p, out, idx) {
if (h == "") return line;
if (!(h in hostmap)) {
hostmap[h] = "redacted-host";
print "host\t" h "\t" hostmap[h] >> mapfile;
}
p = hostmap[h]; out = "";
while ((idx = index(line, h)) > 0) {
out = out substr(line, 1, idx - 1) p;
line = substr(line, idx + length(h));
}
return out line;
}
function gensub_home_one(line, pfx, out, rest, seg, plen, i) {
# <pfx><name> -> <pfx><pseudonym>, e.g. /home/alice -> /home/user-1
out = ""; rest = line; plen = length(pfx);
while (index(rest, pfx) > 0) {
i = index(rest, pfx);
out = out substr(rest, 1, i - 1) pfx;
rest = substr(rest, i + plen);
if (match(rest, /^[A-Za-z0-9._-]+/)) {
seg = substr(rest, 1, RLENGTH);
out = out pseudo_user_name(seg);
rest = substr(rest, RLENGTH + 1);
}
}
return out rest;
}
function gensub_home(line) {
line = gensub_home_one(line, "/home/");
line = gensub_home_one(line, "/Users/");
return line;
}
function pseudo_user_name(u, invoking) {
if (u == "" || u == "root") return u;
if (!(u in usermap)) {
if (nusers >= map_limit) return "redacted-user-overflow";
nusers++;
if (invoking) usermap[u] = "redacted-user";
else { nusr++; usermap[u] = "user-" nusr; }
print "user\t" u "\t" usermap[u] >> mapfile;
}
return usermap[u];
}
function replace_user(line, u, out, idx, pseudonym) {
if (u == "") return line;
pseudonym = pseudo_user_name(u, 1);
out = "";
while ((idx = index(line, u)) > 0) {
out = out substr(line, 1, idx - 1) pseudonym;
line = substr(line, idx + length(u));
}
return out line;
}
function redact_url_credentials(line, m) {
# URL creds: scheme://user:pass@ and Go DSN: user:pass@tcp(
while (match(line, /:\/\/[^:\/@ \t]+:[^@ \t]+@/))
line = substr(line, 1, RSTART - 1) "://[REDACTED]@" substr(line, RSTART + RLENGTH);
while (match(line, /[A-Za-z0-9_]+:[^@ \t]+@(tcp|unix)\(/)) {
m = substr(line, RSTART, RLENGTH);
line = substr(line, 1, RSTART - 1) "[REDACTED]" substr(m, index(m, "@")) substr(line, RSTART + RLENGTH);
}
return line;
}
function redact_authentication(line) {
# JWTs (the eyJ prefix is base64 for double-quote-brace)
while (match(line, /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/))
line = substr(line, 1, RSTART - 1) "[REDACTED-JWT]" substr(line, RSTART + RLENGTH);
# HTTP auth header values anywhere in a line (headers in configs, curl -v, logs).
# The value must contain a digit: real bearer tokens do, English words after
# "bearer" in config prose ("bearer token protection = no") do not.
while (match(line, /[Bb]earer[ \t]+[A-Za-z._~+\/=-]*[0-9][A-Za-z0-9._~+\/=-]*/))
line = substr(line, 1, RSTART - 1) "Bearer [REDACTED]" substr(line, RSTART + RLENGTH);
while (match(line, /[Bb]asic[ \t]+[A-Za-z0-9+\/=][A-Za-z0-9+\/=][A-Za-z0-9+\/=][A-Za-z0-9+\/=][A-Za-z0-9+\/=][A-Za-z0-9+\/=][A-Za-z0-9+\/=][A-Za-z0-9+\/=]+/))
line = substr(line, 1, RSTART - 1) "Basic [REDACTED]" substr(line, RSTART + RLENGTH);
return line;
}
function redact_query_parameters(line, out, rest) {
# secrets passed as URL query parameters (access.log request lines etc.)
out = ""; rest = line;
while (match(rest, /[?&][A-Za-z0-9_.-]*(token|apikey|api_key|access_key|private_key|secret_key|password|passwd|secret|bearer|claim_token|claim_rooms|key|auth)=/)) {
out = out substr(rest, 1, RSTART + RLENGTH - 1) "[REDACTED]";
rest = substr(rest, RSTART + RLENGTH);
sub(/^[^&" \t]+/, "", rest);
}
line = out rest;
return line;
}
function redact_arguments(line, out, rest, m, lk) {
# argv/env-style secrets mid-line (ps output, command lines: -token=X, CLAIM_TOKEN=X)
out = ""; rest = line;
while (match(rest, /[A-Za-z0-9_.-]*(token|TOKEN|Token|password|PASSWORD|Password|passwd|PASSWD|secret|SECRET|Secret|apikey|APIKEY|ApiKey|api_key|API_KEY|community|COMMUNITY|bearer|BEARER)[ ]?[=:][ ]?[^&" \t[]+/)) {
m = substr(rest, RSTART, RLENGTH);
sub(/[ ]?[=:].*/, "", m);
lk = normalize_key(m);
if (diagnostic_key(lk))
out = out substr(rest, 1, RSTART + RLENGTH - 1);
else
out = out substr(rest, 1, RSTART - 1) m "=[REDACTED]";
rest = substr(rest, RSTART + RLENGTH);
}
line = out rest;
# two-word secret keys mid-line ("api key = X" inside a captured command line)
out = ""; rest = line;
while (match(rest, /([Aa][Pp][Ii]|[Ll][Ii][Cc][Ee][Nn][Ss][Ee]|[Aa][Uu][Tt][Hh]|[Aa][Cc][Cc][Ee][Ss][Ss])[ ][Kk][Ee][Yy][ ]?=[ ]?[^&" \t[]+|[Pp][Rr][Oo][Xx][Yy][ ]([Uu][Ss][Ee][Rr]|[Pp][Aa][Ss][Ss]([Ww][Oo][Rr][Dd])?)[ ]?=[ ]?[^&" \t[]+/)) {
m = substr(rest, RSTART, RLENGTH);
sub(/[ ]?=.*/, "", m);
lk = normalize_key(m);
if (diagnostic_key(lk) || streaming_api_key(lk))
out = out substr(rest, 1, RSTART + RLENGTH - 1);
else
out = out substr(rest, 1, RSTART - 1) m " = [REDACTED]";
rest = substr(rest, RSTART + RLENGTH);
}
line = out rest;
return line;
}
function obfuscate_pii(line) {
# emails
while (match(line, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z][A-Za-z]+/))
line = substr(line, 1, RSTART - 1) "[EMAIL]" substr(line, RSTART + RLENGTH);
# MACs
while (match(line, /[0-9A-Fa-f][0-9A-Fa-f]:[0-9A-Fa-f][0-9A-Fa-f]:[0-9A-Fa-f][0-9A-Fa-f]:[0-9A-Fa-f][0-9A-Fa-f]:[0-9A-Fa-f][0-9A-Fa-f]:[0-9A-Fa-f][0-9A-Fa-f]/))
line = substr(line, 1, RSTART - 1) "[MAC]" substr(line, RSTART + RLENGTH);
if (line ~ /^[ \t#]*(proxy )?destination[ \t]*=/) line = redact_destination(line);
line = replace_ips(line);
line = replace_ip6(line);
line = replace_private_fqdns(line);
line = replace_mapped_fqdns(line);
if (host_fqdn != "") line = replace_host(line, host_fqdn);
if (host_short != "") line = replace_host(line, host_short);
if (run_user != "") line = replace_user(line, run_user);
# other local users appear in mount tables / paths when run as root
line = gensub_home(line);
return line;
}
{
if (ctx == "host-seed") {
if (length($0) >= 4 && $0 != "localhost" && $0 != host_short && $0 != host_fqdn)
pseudo_fqdn($0);
next;
}
# NOTE: no {n,m} regex intervals anywhere in this program - older BSD awks
# treat them as literal braces, which would silently disable redaction.
# Split a CRLF terminator off first and re-append it at print time. Two
# reasons: redaction rules used to rebuild lines without the CR (silently
# converting a CRLF file to mixed endings), and a trailing CR otherwise
# becomes part of the matched VALUE ("TOKEN=false\r"), skewing the rules.
line = $0; cr = "";
if (line ~ /\r$/) { cr = "\r"; sub(/\r$/, "", line); }
# PEM private keys are multi-line: withhold the WHOLE block, fail closed
# if the END marker never arrives.
if (inpem) {
if (line ~ /-----END [A-Z ]*PRIVATE KEY/) inpem = 0;
next;
}
if (line ~ /-----BEGIN [A-Z ]*PRIVATE KEY/) {
print "[REDACTED PRIVATE KEY BLOCK]" cr;
inpem = 1;
next;
}
# NOTE (scope): multi-line YAML block scalars (secret: | ...) are NOT
# specially withheld. Their boundary is indentation-based, which a
# line-oriented sanitizer cannot detect robustly (tabs, explicit indent
# indicators, dedents) - every attempt spawns another edge case. The
# common form in the config files here is an inline "secret: value", which
# the key/value rules above DO redact; PEM blocks (clear BEGIN/END markers)
# are still withheld. This matches typical support-bundle tools: redact the
# well-defined cases, and rely on the user reviewing the bundle before
# sending it. See SUPPORT-BUNDLE.md "Redaction philosophy".
# [<UUID>] section headers are API keys (or machine GUIDs). In stream.conf
# they are kept: support needs to see WHICH key each section configures, and
# collapsing them all to one placeholder also made per-key settings
# unattributable. Everywhere else they are still withheld.
if (ctx != "stream" && line ~ /^[ \t]*\[[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]\][ \t]*$/) {
print "[REDACTED-KEY-SECTION]" cr; next;
}
line = redact_kv(line);
if (line ~ /"[^"]+"[ \t]*:[ \t]*/) line = redact_json(line);
line = redact_url_credentials(line);
line = redact_authentication(line);
line = redact_query_parameters(line);
line = redact_arguments(line);
if (obf == 1) line = obfuscate_pii(line);
print line cr;
}' "$_san_sanin" > "$_san_f.san" 2>>"$ERRORS"; then
if [ "$ENC_CRONLY" = "1" ]; then
LC_ALL=C tr '\n' '\r' < "$_san_f.san" > "$_san_f.san2" 2>>"$ERRORS" && mv "$_san_f.san2" "$_san_f.san"
fi
# restore the exact BOM bytes the source had, so an encoding fault in the
# user's file is still visible in the bundle
if [ "$_san_bomstripped" = "1" ]; then
head -c "$ENC_BOM_BYTES" "$_san_f" > "$_san_f.out" 2>>"$ERRORS"
else
: > "$_san_f.out"
fi
cat "$_san_f.san" >> "$_san_f.out"
# awk always terminates its last line: drop that byte again when the source
# had no final newline, so a truncated source file still looks truncated
if [ "$ENC_FINAL" = "false" ]; then
_san_osz=$(wc -c < "$_san_f.out" | tr -d ' ')
_san_lastout=$(tail -c 1 "$_san_f.out" 2>/dev/null | od -An -tx1 | tr -d ' \n')
if [ "${_san_osz:-0}" -gt 0 ] && { [ "$_san_lastout" = "0a" ] || [ "$_san_lastout" = "0d" ]; }; then
head -c "$((_san_osz - 1))" "$_san_f.out" > "$_san_f.out2" 2>>"$ERRORS" && mv "$_san_f.out2" "$_san_f.out"
fi
fi
mv "$_san_f.out" "$_san_f"
rm -f "$_san_f.san" "$_san_f.nobom" "$_san_f.lf" "$_san_f.out2"
else
# fail CLOSED: never ship content the sanitizer could not process
rm -f "$_san_f.san" "$_san_f.san2" "$_san_f.nobom" "$_san_f.lf" "$_san_f.out" "$_san_f.out2"
echo "[netdata-support-bundle] sanitization failed for this file - content withheld for safety" > "$_san_f"
fi
}
# --- manifest ----------------------------------------------------------------
# manifest_add <rel-path> <kind:cmd|file|api> <origin> <title> [raw:0|1]
json_str() { # JSON-escape every control byte, including in source filenames.
printf '%s' "$1" | LC_ALL=C od -An -v -tu1 | LC_ALL=C awk '
{ for (i = 1; i <= NF; i++) {
n = $i + 0;
if (n == 34 || n == 92) printf "\\%c", n;
else if (n < 32) printf "\\u%04x", n;
else printf "%c", n;
} }'
}
# Correct a manifest row's size after its body was replaced. collect_body records
# the row when it writes the file, so any later rewrite leaves a stale size and
# breaks the manifest/on-disk parity CI asserts.
manifest_fix_bytes() {
_mf_rel="$1"; _mf_new=0
[ -f "$WORK/$_mf_rel" ] && _mf_new=$(wc -c < "$WORK/$_mf_rel" | tr -d ' ')
awk -v rel="\"path\":\"$_mf_rel\"" -v n="$_mf_new" '
index($0, rel) > 0 && !done { done = 1; sub(/"bytes":[0-9]+/, "\"bytes\":" n) }
{ print }
' "$MANIFEST_ROWS" > "$MANIFEST_ROWS.fix" && mv "$MANIFEST_ROWS.fix" "$MANIFEST_ROWS" && return 0
rm -f "$MANIFEST_ROWS.fix"
return 1
}
manifest_add() {
_ma_rel="$1"; _ma_kind="$2"; _ma_origin="$3"; _ma_title="$4"; _ma_raw="${5:-0}"
_ma_bytes=0; [ -f "$WORK/$_ma_rel" ] && _ma_bytes=$(wc -c < "$WORK/$_ma_rel" | tr -d ' ')
printf '{"path":"%s","kind":"%s","origin":"%s","title":"%s","bytes":%s,"pii_obfuscated":%s,"sanitized":%s}
' \
"$(json_str "$_ma_rel")" "$_ma_kind" "$(json_str "$_ma_origin")" "$(json_str "$_ma_title")" "$_ma_bytes" \
"$([ "$OBFUSCATE" = "1" ] && [ "$_ma_raw" = "0" ] && echo true || echo false)" \
"$([ "$_ma_raw" = "0" ] && echo true || echo false)" >> "$MANIFEST_ROWS"
}
# --- SNMP diagnostics ----------------------------------------------------------
# Only the Agent-owned layout is accepted. No decompression or text sanitization:
# changing returned bytes or identifiers would destroy the evidence being copied.
snmp_note() {
printf '%s\n' "$1" >> "$WORK/06-state/snmp-diagnostics-status.txt"
}
snmp_copy() {
_sc_src="$1"; _sc_rel="$2"; _sc_out="$WORK/06-state/snmp-diagnostics/$_sc_rel"
if deadline_exceeded; then
snmp_note "$_sc_rel: skipped (deadline)"; SNMP_STATUS=partial; return 1
fi
if [ -h "$_sc_src" ] || [ ! -f "$_sc_src" ] || [ ! -r "$_sc_src" ]; then
snmp_note "$_sc_rel: unavailable (missing, unreadable, or symlink)"; SNMP_STATUS=partial; return 1
fi
# cp -P preserves a leaf symlink instead of following it if the source changes.
# A staged symlink is rejected below, never included as evidence.
if mkdir -p "$(dirname "$_sc_out")" &&
run_capped "$CMD_TIMEOUT" cp -P "$_sc_src" "$_sc_out.tmp" 2>>"$ERRORS" &&
[ ! -h "$_sc_out.tmp" ] && [ -f "$_sc_out.tmp" ] &&
mv "$_sc_out.tmp" "$_sc_out"; then
SNMP_FILES=$((SNMP_FILES + 1))
manifest_add "06-state/snmp-diagnostics/$_sc_rel" file "snmp/diagnostics/$_sc_rel" "Original SNMP evidence (UNSANITIZED)" 1
snmp_note "$_sc_rel: copied"
return 0
fi
rm -f "$_sc_out.tmp"
snmp_note "$_sc_rel: copy failed (withheld)"; SNMP_STATUS=partial; return 1
}
snmp_runs() {
# Parse only the small, fixed index written by the Agent: current, then optional
# previous. Field contents are never treated as paths until canonical UUID checks.
awk '
function uuid(s, a) {
return s !~ /[^0-9a-f-]/ && split(s,a,"-")==5 &&
length(a[1])==8 && length(a[2])==4 && length(a[3])==4 && length(a[4])==4 && length(a[5])==12
}
{ text=text $0 "\n" }
END {
n=split(text,v,/["]/)
if ((n!=5 && n!=9) || v[1]!~/^[[:space:]]*[{][[:space:]]*$/ ||
v[2]!="current" || v[3]!~/^[[:space:]]*:[[:space:]]*$/ || !uuid(v[4]) ||
v[n]!~/^[[:space:]]*[}][[:space:]]*$/) exit 1
if (n==9 && (v[5]!~/^[[:space:]]*,[[:space:]]*$/ || v[6]!="previous" ||
v[7]!~/^[[:space:]]*:[[:space:]]*$/ || !uuid(v[8]) || v[4]==v[8])) exit 1
print v[4]; if (n==9) print v[8]
}' "$1"
}
snmp_directory() {
[ ! -h "$1" ] && [ -d "$1" ] && [ -r "$1" ] && [ -x "$1" ]
}
collect_snmp_diagnostics() {
mkdir -p "$WORK/06-state"
: > "$WORK/06-state/snmp-diagnostics-status.txt"
if [ "$INCLUDE_SNMP" != "1" ]; then
snmp_note "Not requested. Use --include-snmp-diagnostics to include original, UNSANITIZED evidence."
elif [ -z "$LIBDIR" ] || [ ! -e "$LIBDIR/snmp/diagnostics" ]; then
SNMP_STATUS=unavailable
snmp_note "No SNMP diagnostic directory found."
else
SNMP_STATUS=complete
_sd_root="$LIBDIR/snmp/diagnostics"
snmp_note "Raw SNMP evidence requested: no secret redaction or PII obfuscation. Share privately."
if ! snmp_directory "$LIBDIR/snmp" || ! snmp_directory "$_sd_root"; then
SNMP_STATUS=partial
snmp_note "Diagnostic directory unavailable or symlinked (withheld)."
else
_sd_lifecycle_missing=0
if [ -e "$_sd_root/lifecycle.zst" ] || [ -h "$_sd_root/lifecycle.zst" ]; then
snmp_copy "$_sd_root/lifecycle.zst" lifecycle.zst || :
else
_sd_lifecycle_missing=1
snmp_note "lifecycle.zst: missing"
fi
if [ -e "$_sd_root/topology" ] || [ -h "$_sd_root/topology" ]; then
if snmp_directory "$_sd_root/topology"; then
for _sd_file in "$_sd_root"/topology/checkpoint-*.zst; do
_sd_name=${_sd_file##*/}
printf '%s\n' "$_sd_name" | grep -Eq '^checkpoint-[0-9]{20}\.zst$' || continue
snmp_copy "$_sd_file" "topology/$_sd_name" || :
done
else
SNMP_STATUS=partial; snmp_note "topology: directory unavailable or symlinked (withheld)."
fi
fi
if [ -e "$_sd_root/normal" ] || [ -h "$_sd_root/normal" ]; then
if snmp_directory "$_sd_root/normal" && snmp_copy "$_sd_root/normal/runs.json" normal/runs.json; then
if _sd_runs=$(snmp_runs "$WORK/06-state/snmp-diagnostics/normal/runs.json"); then
for _sd_run in $_sd_runs; do
if ! snmp_directory "$_sd_root/normal/$_sd_run"; then
SNMP_STATUS=partial; snmp_note "normal/$_sd_run: directory unavailable or symlinked (withheld)."; continue
fi
for _sd_file in "$_sd_root/normal/$_sd_run"/device-*.zst; do
_sd_name=${_sd_file##*/}
printf '%s\n' "$_sd_name" | grep -Eq '^device-[0-9]{20}\.zst$' || continue
snmp_copy "$_sd_file" "normal/$_sd_run/$_sd_name" || :
done
done
else
SNMP_STATUS=partial; snmp_note "normal/runs.json: invalid index; normal device files withheld."
fi
else
SNMP_STATUS=partial; snmp_note "normal: directory or run index unavailable (device files withheld)."
fi
fi
if [ "$SNMP_FILES" -gt 0 ] && [ "$_sd_lifecycle_missing" = 1 ]; then
SNMP_STATUS=partial
fi
if [ "$SNMP_FILES" = "0" ] && [ "$SNMP_STATUS" = complete ]; then
SNMP_STATUS=unavailable; snmp_note "No completed SNMP diagnostic files found."
fi
fi
fi
snmp_note "Result: $SNMP_STATUS; complete files copied: $SNMP_FILES."
manifest_add 06-state/snmp-diagnostics-status.txt file generated "SNMP evidence collection status"
}
# --- end SNMP diagnostics ------------------------------------------------------
# --- collectors ---------------------------------------------------------------
# command_origin <argv...> - keep command provenance on one readable line.
command_origin() {
printf '%s' "$*" | tr '\n\t' ' ' | tr -s ' '
}
# capture_output <file> <byte-limit> <merge-stderr:0|1> <command...>
# The caller supplies the timeout wrapper. Results: CAPTURE_RC, CAPTURE_BYTES.
# Keep the producer status separately: POSIX pipelines only report the consumer.
capture_output() {
_co_out="$1"; _co_limit="$2"; _co_merge="$3"; shift 3
{
if [ "$_co_merge" = 1 ]; then "$@" 2>&1
else "$@" 2>>"$ERRORS"
fi
printf '%s\n' "$?" > "$_co_out.rc"
} | head -c "$_co_limit" > "$_co_out"
_co_write_rc=$?
CAPTURE_RC=$(cat "$_co_out.rc" 2>/dev/null || echo unknown)
rm -f "$_co_out.rc"
CAPTURE_BYTES=$(wc -c < "$_co_out" | tr -d ' ')
[ "$_co_write_rc" = 0 ] || CAPTURE_RC="$_co_write_rc"
}
# collect_cmd [--cap BYTES] <rel-path> <title> <command...>
collect_cmd() {
_cc_cap="$API_CAP"
if [ "${1:-}" = "--cap" ]; then _cc_cap="$2"; shift 2; fi
_cc_rel="$1"; _cc_title="$2"; shift 2
_cc_out="$WORK/$_cc_rel"
mkdir -p "$(dirname "$_cc_out")"
if deadline_exceeded; then
echo "SKIPPED: global deadline reached" > "$_cc_out"
manifest_add "$_cc_rel" cmd skipped "$_cc_title (skipped: deadline)"
return 0
fi
_cc_start=$(now_s)
_cc_origin=$(command_origin "$@")
capture_output "$_cc_out.raw" "$((_cc_cap * 4))" 1 run_capped "$CMD_TIMEOUT" "$@"
{
printf '# netdata-support-bundle v%s | command: %s | captured: %s\n' "$VERSION" "$_cc_origin" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
LC_ALL=C awk -v cap="$_cc_cap" 'BEGIN { b = 0 } { b += length($0) + 1; if (b > cap) exit; print }' "$_cc_out.raw"
[ "$CAPTURE_BYTES" -gt "$_cc_cap" ] && printf '### TRUNCATED: captured %s bytes, first ~%s kept (line-aligned) ###\n' "$CAPTURE_BYTES" "$_cc_cap"
printf '# exit: %s | duration: %ss\n' "$CAPTURE_RC" "$(( $(now_s) - _cc_start ))"
} > "$_cc_out"
rm -f "$_cc_out.raw"
sanitize_file "$_cc_out"
manifest_add "$_cc_rel" cmd "$_cc_origin" "$_cc_title"
}
# collect_file <rel-path> <title> <source-file> [cap-bytes]
collect_file() {
_cf_rel="$1"; _cf_title="$2"; _cf_src="$3"; _cf_cap="${4:-$FILE_CAP}"
deadline_exceeded && return 0
[ -f "$_cf_src" ] && [ -r "$_cf_src" ] || return 0
# stream.conf is the one file whose streaming API key is kept verbatim; the
# context is keyed on the SOURCE name, never on the bundle path
_cf_fctx=""
case "${_cf_src##*/}" in stream.conf) _cf_fctx="stream" ;; *) : ;; esac
# a symlinked final component is not a real config/log file we chose to
# collect; refuse it so a swapped link cannot redirect us to another target
# (symlinked parent DIRECTORIES resolve normally - only the leaf is checked)
if [ -h "$_cf_src" ]; then
_cf_out="$WORK/$_cf_rel"; mkdir -p "$(dirname "$_cf_out")"
echo "[content withheld: source is a symlink]" > "$_cf_out"
manifest_add "$_cf_rel" "file" "$_cf_src (symlink, withheld)" "$_cf_title"
return 0
fi
_cf_out="$WORK/$_cf_rel"; mkdir -p "$(dirname "$_cf_out")"
_cf_size=$(wc -c < "$_cf_src" 2>/dev/null | tr -d ' ')
if [ "${_cf_size:-0}" -gt "$_cf_cap" ]; then
# cap at a LINE boundary (drop the first, possibly partial, line) so a
# secret can never straddle the cut and dodge the line-based sanitizer
tail -c "$_cf_cap" "$_cf_src" 2>>"$ERRORS" | tail -n +2 > "$_cf_out"
if [ ! -s "$_cf_out" ]; then
# the whole tail was one giant line: withhold rather than risk a
# mid-token cut hiding a secret from the line-based sanitizer
echo "[content withheld: file tail exceeds the cap without a line break]" > "$_cf_out"
fi
_cf_origin="$_cf_src (last ~$_cf_cap of $_cf_size bytes, line-aligned)"
else
cat "$_cf_src" > "$_cf_out" 2>>"$ERRORS"
_cf_origin="$_cf_src"
fi
sanitize_file "$_cf_out" "$_cf_fctx"
manifest_add "$_cf_rel" "file" "$_cf_origin" "$_cf_title"
}
# collect_body <rel-path> <title> <kind> <origin> <command...>
# Pristine captures have no text provenance. Failures/overflow replace the whole
# body with a JSON marker; successful empty output remains omitted.
collect_body() {
_cb_rel="$1"; _cb_title="$2"; _cb_kind="$3"; _cb_origin="$4"; shift 4
deadline_exceeded && return 0
_cb_out="$WORK/$_cb_rel"
mkdir -p "$(dirname "$_cb_out")"
capture_output "$_cb_out" "$((API_CAP + 1))" 0 "$@"
if [ "$CAPTURE_BYTES" -gt "$API_CAP" ]; then
echo '{"error":"output exceeded the cap and was withheld"}' > "$_cb_out"
elif [ "$CAPTURE_RC" != 0 ]; then
printf '{"error":"capture failed; partial output withheld","exit_code":"%s"}\n' "$CAPTURE_RC" > "$_cb_out"
fi
if [ -s "$_cb_out" ]; then
sanitize_file "$_cb_out"
manifest_add "$_cb_rel" "$_cb_kind" "$_cb_origin" "$_cb_title"
else
rm -f "$_cb_out"
fi
}
# collect_cmd_raw <rel-path> <title> <command...>
collect_cmd_raw() {
_cr_rel="$1"; _cr_title="$2"; shift 2
collect_body "$_cr_rel" "$_cr_title" cmd "$(command_origin "$@")" run_capped "$CMD_TIMEOUT" "$@"
}
# api_request <seconds> <url-path> - all local reads share the same transport.
# Subshell confines proxy overrides to the request, preserving Cloud probes.
api_request() (
_ar_seconds="$1"; _ar_url="http://127.0.0.1:${NDPORT}$2"
unset http_proxy HTTP_PROXY https_proxy HTTPS_PROXY all_proxy ALL_PROXY
no_proxy='*'; NO_PROXY='*'; export no_proxy NO_PROXY
if command -v curl >/dev/null 2>&1; then
run_capped "$_ar_seconds" curl -q -sf --noproxy '*' --max-time "$_ar_seconds" "$_ar_url"
elif command -v wget >/dev/null 2>&1; then
if wget --help 2>&1 | grep -q -- '--no-proxy'; then
run_capped "$_ar_seconds" wget --no-proxy -q -T "$_ar_seconds" -O - "$_ar_url"
else
run_capped "$_ar_seconds" wget -Y off -q -T "$_ar_seconds" -O - "$_ar_url"
fi
else
return 127
fi
)
# collect_api <rel-path> <title> <url-path>
collect_api() {
collect_body "$1" "$2" api "$3" api_request "$CMD_TIMEOUT" "$3"
}
# --- sanitizer regression vectors (run with --selftest; extend when adding
# --- redaction rules; a vector that fails here must never ship) -------------
run_selftest() {
_tf="$STAGING/selftest.txt"
_fails=0
cat > "$_tf" <<'VECTORS'
api key = SENTINEL-1
password: SENTINEL-3
"claim_token": "SENTINEL-4"
url: https://admin:SENTINEL-5@app.example.com/x
dsn: user:SENTINEL-6@tcp(10.1.2.3:3306)/db
TELEGRAM_BOT_TOKEN="SENTINEL-8"
TOKEN=false
PASSWORD=/etc/SENTINEL-9
GET /api/v1/data?chart=x&token=SENTINEL-10&after=-60
/usr/sbin/netdata-claim.sh -token=SENTINEL-11 -rooms=abc
cmdline: /usr/sbin/agent -token=SENTINEL-14 --verbose
connect user:SENTINEL-15@unix(/run/x)/db ok
/etc/netdata/claim_token: SENTINEL-16
cmdline: claim.sh api key = SENTINEL-12 end
password: q
"api_token": 731942
[11111111-2222-3333-4444-555555555555]
-----BEGIN RSA PRIVATE KEY-----
U0VOVElORUwtMTMtUEVNLUJPRFk=
-----END RSA PRIVATE KEY-----
bearer token protection = no
netdata management api key file = /var/lib/netdata/netdata.api.key
TCP SYN cookies = auto
destination = parent.bigcorp.example:19999
destination = tcp:protoparent.example.com:19999
# destination = old-parent.example.org:19999
destination = [2001:db8::77]:19999 unix:/run/nd.sock 10.7.7.7:19999
tcp LISTEN 0 4096 later-line
server at 10.1.2.3 and 2606:4700:10::ac42:aad8 and 2001:470:26:307:0:0:0:1
mail ops@example.com mac aa:bb:cc:dd:ee:ff at 2026-07-16T13:38:34Z
"password_escq": "ab\"SENTINEL-ESCQ"
PWD=SENTINEL-PWD
"api_token": -98765
home /home/alice/x and /Users/bob/y
VECTORS
# assembled at runtime so secret scanners do not flag the source as a
# committed credential; the sanitized bytes are identical
_bw="Bea"; _bw="${_bw}rer"
printf 'Authorization: %s SENTINEL-2abc\n' "$_bw" >> "$_tf"
_obf_save="$OBFUSCATE"; OBFUSCATE=1
sanitize_file "$_tf"
OBFUSCATE="$_obf_save"
t_absent() {
_pat="$1"; _msg="$2"
if grep -q "$_pat" "$_tf"; then echo "FAIL (leak): $_msg" >&2; _fails=$((_fails + 1)); fi
}
t_present() {
_pat="$1"; _msg="$2"
if [ "$_pat" = "--" ]; then _pat="$2"; _msg="$3"; fi
if ! grep -qF -- "$_pat" "$_tf"; then echo "FAIL (over-redaction): $_msg" >&2; _fails=$((_fails + 1)); fi
}
t_absent "SENTINEL-" "a planted secret survived"
t_absent "U0VOVElORUw" "PEM body survived"
t_absent "TOKEN=false" "TOKEN=false survived (values are never exempt)"
t_absent "731942" "scalar JSON secret survived"
t_absent "password: q" "one-character secret survived"
t_absent "2606:4700" "compressed IPv6 survived"
t_absent "2001:470:26:307:0:0:0:1" "uncompressed numeric IPv6 survived"
t_absent "10\.1\.2\.3" "IPv4 survived"
t_absent "10\.7\.7\.7" "IP destination not pseudonymized as an IP"
t_absent "parent.bigcorp.example" "stream destination hostname survived"
t_absent "protoparent.example.com" "protocol-prefixed destination hostname survived"
t_absent "old-parent.example.org" "commented-out destination hostname survived"
t_absent "2001:db8::77" "bracketed IPv6 destination leaked"
t_absent "ops@example.com" "email survived"
t_absent "aa:bb:cc:dd:ee:ff" "MAC survived"
t_absent "SENTINEL-ESCQ" "escaped-quote JSON value leaked its suffix"
t_absent "SENTINEL-PWD" "PWD= secret alias survived"
t_absent "98765" "negative-number JSON scalar survived"
t_absent "/home/alice" "other user home path not pseudonymized"
t_absent "/Users/bob" "other user Users path not pseudonymized"
t_present "destination = tcp:" "destination protocol prefix lost"
t_present "unix:/run/nd.sock" "socket-path destination was mangled"
t_present "tcp LISTEN 0 4096 later-line" "literal tcp corrupted by fqmap pollution"
t_present "bearer token protection = no" "diagnostic option lost (key-based exemption broken)"
t_present "api key file = /var/lib/netdata/netdata.api.key" "key-file path lost"
t_present "TCP SYN cookies = auto" "SYN cookies value lost"
t_present "[REDACTED PRIVATE KEY BLOCK]" "PEM block marker missing"
t_present "2026-07-16T13:38:34Z" "timestamp mangled by IPv6 rule"
t_present -- "--verbose" "path-bearing argv line was eaten by the kv rule"
t_present "@unix(/run/x)/db ok" "mid-line unix( DSN rule broke the tail"
# cross-file pseudonym stability: a NEW user in a second file must not reuse
# a pseudonym already assigned in the first (counter restored from the map)
_obf_save2="$OBFUSCATE"; OBFUSCATE=1
printf 'home /home/seconduser/data\n' > "$_tf.u2"
sanitize_file "$_tf.u2"
OBFUSCATE="$_obf_save2"
if grep -q "/home/seconduser" "$_tf.u2"; then echo "FAIL (leak): second-file home user not pseudonymized" >&2; _fails=$((_fails + 1)); fi
if grep -q "user-1" "$_tf.u2"; then echo "FAIL: cross-file pseudonym collision (reused user-1)" >&2; _fails=$((_fails + 1)); fi
printf 'nul-test \000 password=SENTINEL-NUL\n' > "$_tf.nul"
sanitize_file "$_tf.nul"
grep -q "content withheld" "$_tf.nul" || { echo "FAIL: NUL-bearing file was not withheld" >&2; _fails=$((_fails + 1)); }
# --- stream.conf context (netdata/netdata#23448): the STREAMING api key is
# --- kept verbatim, while every other secret in the same file is redacted
_sf="$_tf.stream"
cat > "$_sf" <<'STREAMVEC'
[stream]
enabled = yes
api key = 11111111-2222-3333-4444-555555555555
proxy api key = 99999999-8888-7777-6666-555555555555
password = SENTINEL-STREAM-PW
[aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee]
enabled = yes
STREAMVEC
_obf_save3="$OBFUSCATE"; OBFUSCATE=1
sanitize_file "$_sf" stream
OBFUSCATE="$_obf_save3"
s_present() {
grep -qF -- "$1" "$_sf" || { echo "FAIL (over-redaction): $2" >&2; _fails=$((_fails + 1)); }
}
s_present "api key = 11111111-2222-3333-4444-555555555555" "streaming api key was redacted in stream context"
s_present "proxy api key = 99999999-8888-7777-6666-555555555555" "proxy api key was redacted in stream context"
s_present "[aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee]" "stream.conf [<API_KEY>] section header was redacted"
grep -q "SENTINEL-STREAM-PW" "$_sf" && \
{ echo "FAIL (leak): a non-api-key secret survived in stream.conf" >&2; _fails=$((_fails + 1)); }
# the SAME api key line must still be redacted WITHOUT the stream context
_sf2="$_tf.nostream"
printf 'api key = 11111111-2222-3333-4444-555555555555\n[aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee]\n' > "$_sf2"
sanitize_file "$_sf2"
grep -q "11111111-2222" "$_sf2" && \
{ echo "FAIL (leak): api key kept outside stream.conf (context leaked)" >&2; _fails=$((_fails + 1)); }
grep -q "REDACTED-KEY-SECTION" "$_sf2" || \
{ echo "FAIL (leak): [<UUID>] section not redacted outside stream.conf" >&2; _fails=$((_fails + 1)); }
# --- encoding fidelity (netdata/netdata#23448): a BOM, CRLF endings and a
# --- missing final newline must all survive redaction, and a BOM must NOT
# --- shift the ^-anchored rules (it used to defeat the section redaction)
_ef="$_tf.enc"
printf '\357\273\277[11111111-2222-3333-4444-555555555555]\r\npassword = SENTINEL-ENC\r\nlast line without newline' > "$_ef"
_obf_save4="$OBFUSCATE"; OBFUSCATE=1
sanitize_file "$_ef"
OBFUSCATE="$_obf_save4"
[ "$(head -c 3 "$_ef" 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "efbbbf" ] || \
{ echo "FAIL: the UTF-8 BOM was stripped by sanitization" >&2; _fails=$((_fails + 1)); }
grep -q "REDACTED-KEY-SECTION" "$_ef" || \
{ echo "FAIL (leak): a BOM defeated the [<UUID>] section redaction" >&2; _fails=$((_fails + 1)); }
grep -q "11111111-2222" "$_ef" && \
{ echo "FAIL (leak): BOM'd api key section survived" >&2; _fails=$((_fails + 1)); }
grep -q "SENTINEL-ENC" "$_ef" && \
{ echo "FAIL (leak): secret survived in the encoding vector" >&2; _fails=$((_fails + 1)); }
[ "$(LC_ALL=C tr -dc '\r' < "$_ef" | wc -c | tr -d ' ')" -ge 2 ] || \
{ echo "FAIL: CRLF line endings were lost (including on the redacted line)" >&2; _fails=$((_fails + 1)); }
[ "$(tail -c 1 "$_ef" | wc -l | tr -d ' ')" = "0" ] || \
{ echo "FAIL: a final newline was added to a source that had none" >&2; _fails=$((_fails + 1)); }
# --- CR-only (classic Mac) endings: awk sees ONE record, so without the
# --- translation only the first key would be examined and later secrets
# --- would ship unredacted
_cf="$_tf.cronly"
printf '[stream]\r enabled = yes\r password = SENTINEL-CRONLY\r' > "$_cf"
sanitize_file "$_cf"
grep -q "SENTINEL-CRONLY" "$_cf" && \
{ echo "FAIL (leak): secret in a CR-only file was not redacted" >&2; _fails=$((_fails + 1)); }
[ "$(LC_ALL=C tr -dc '\r' < "$_cf" | wc -c | tr -d ' ')" = "3" ] || \
{ echo "FAIL: CR-only line endings were not reproduced" >&2; _fails=$((_fails + 1)); }
[ "$(LC_ALL=C tr -dc '\n' < "$_cf" | wc -c | tr -d ' ')" = "0" ] || \
{ echo "FAIL: CR-only file gained LF terminators" >&2; _fails=$((_fails + 1)); }
# a CR-only file that does NOT end in a terminator must not gain one
_cf2="$_tf.cronly2"
printf 'a = 1\rpassword = SENTINEL-CRONLY2\rlast' > "$_cf2"
sanitize_file "$_cf2"
grep -q "SENTINEL-CRONLY2" "$_cf2" && \
{ echo "FAIL (leak): secret in a terminator-less CR-only file survived" >&2; _fails=$((_fails + 1)); }
[ "$(tail -c 1 "$_cf2" | od -An -tx1 | tr -d ' \n')" = "0d" ] && \
{ echo "FAIL: CR-only file without a final terminator gained one" >&2; _fails=$((_fails + 1)); }
# a BOM on a CR-only file must still survive (the CR translation must not
# bypass the BOM restore)
_cf3="$_tf.bomcr"
printf '\357\273\277a = 1\rpassword = SENTINEL-BOMCR\r' > "$_cf3"
sanitize_file "$_cf3"
[ "$(head -c 3 "$_cf3" | od -An -tx1 | tr -d ' \n')" = "efbbbf" ] || \
{ echo "FAIL: BOM lost on a CR-only file" >&2; _fails=$((_fails + 1)); }
grep -q "SENTINEL-BOMCR" "$_cf3" && \
{ echo "FAIL (leak): secret in a BOM'd CR-only file survived" >&2; _fails=$((_fails + 1)); }
if [ "$_fails" -eq 0 ]; then
echo "netdata-support-bundle selftest: ALL PASS"
exit 0
fi
echo "netdata-support-bundle selftest: $_fails FAILURE(S)" >&2
exit 1
}
# Discover hostnames before collecting files so pseudonyms correlate throughout.
seed_hostnames() {
[ "$OBFUSCATE" = 1 ] || return 0
deadline_exceeded && return 0
# Discovery must retain every returned name, including on large parents.
# The request is timed; storage and matching memory scale with the response.
# API artifacts still use their ordinary size cap through collect_api.
api_request 5 /api/v2/node_instances > "$STAGING/node-seed.json" 2>>"$ERRORS" || return 0
tr ',{' '\n' < "$STAGING/node-seed.json" | awk '
match($0, /"(nm|hostname)" *: *"/) {
value = substr($0, RSTART + RLENGTH); sub(/".*/, "", value);
if (value != "") print value;
}' > "$STAGING/host-seed.txt"
sanitize_file "$STAGING/host-seed.txt" host-seed
}
# find_netdatacli - sets NETDATACLI (empty when not found): PATH first, then
# next to the daemon binary (installs under a prefix keep both in usr/sbin),
# then the static install's bin/ directory. Neither of the last two is on PATH;
# kickstart.sh resolves netdatacli the same way.
find_netdatacli() {
NETDATACLI=$(command -v netdatacli 2>/dev/null) || NETDATACLI=""
if [ -z "$NETDATACLI" ] && [ -n "${NETDATA_BIN:-}" ] && [ -x "${NETDATA_BIN%/*}/netdatacli" ]; then
NETDATACLI="${NETDATA_BIN%/*}/netdatacli"
fi
if [ -z "$NETDATACLI" ] && [ -x /opt/netdata/bin/netdatacli ]; then
NETDATACLI=/opt/netdata/bin/netdatacli
fi
return 0
}
discover_environment() {
# --- environment detection ------------------------------------------------------
# Prefer the exact daemon command name: pidof can also return Netdata helper
# processes whose executable names or invocation paths contain "netdata".
NETDATA_PID=$(ps -eo pid=,comm= 2>/dev/null | awk '$2=="netdata"{print $1; exit}')
[ -z "${NETDATA_PID:-}" ] && NETDATA_PID=$(pidof netdata 2>/dev/null | awk '{print $1}')
export NETDATA_PID
# path candidates per install type: FHS packages, static (/opt/netdata),
# FreeBSD ports (/usr/local + /var/db), Homebrew (incl. Apple Silicon prefix)
CONFDIR=""
for d in /etc/netdata /opt/netdata/etc/netdata /usr/local/etc/netdata /opt/homebrew/etc/netdata; do
[ -d "$d" ] && { CONFDIR="$d"; break; }
done
LOGDIR=""
for d in /var/log/netdata /opt/netdata/var/log/netdata /usr/local/var/log/netdata /opt/homebrew/var/log/netdata; do
[ -d "$d" ] && { LOGDIR="$d"; break; }
done
LIBDIR=""
for d in /var/lib/netdata /opt/netdata/var/lib/netdata /var/db/netdata /usr/local/var/lib/netdata /opt/homebrew/var/lib/netdata; do
[ -d "$d" ] && { LIBDIR="$d"; break; }
done
CACHEDIR=""
for d in /var/cache/netdata /opt/netdata/var/cache/netdata /var/db/netdata/cache /usr/local/var/cache/netdata /opt/homebrew/var/cache/netdata; do
[ -d "$d" ] && { CACHEDIR="$d"; break; }
done
NETDATA_BIN=$(command -v netdata 2>/dev/null)
[ -z "${NETDATA_BIN:-}" ] && [ -n "${NETDATA_PID:-}" ] && [ -r "/proc/$NETDATA_PID/exe" ] && \
NETDATA_BIN=$(readlink -f "/proc/$NETDATA_PID/exe" 2>/dev/null)
[ -z "${NETDATA_BIN:-}" ] && [ -x /opt/netdata/usr/sbin/netdata ] && NETDATA_BIN=/opt/netdata/usr/sbin/netdata
find_netdatacli
# probe /api/v3/info first: it stays reachable even under bearer protection,
# where /api/v1/* is locked (so a protected-but-running agent isn't mis-flagged)
for _probe in /api/v3/info /api/v1/info; do
if api_request 3 "$_probe" >/dev/null 2>&1; then
api_ok=1; break
fi
done
[ "$api_ok" = "1" ] && seed_hostnames
IS_CONTAINER=0
[ -f /.dockerenv ] && IS_CONTAINER=1
grep -qE '(docker|containerd|kubepods|lxc)' /proc/1/cgroup 2>/dev/null && IS_CONTAINER=1
info "netdata-support-bundle $VERSION"
info "agent pid: ${NETDATA_PID:-not running} | api: $([ $api_ok = 1 ] && echo up || echo unreachable) | config: ${CONFDIR:-not found} | container: $IS_CONTAINER"
}
# =============================================================================
# 01-system
# =============================================================================
collect_system() {
info "collecting: system"
collect_cmd 01-system/uname.txt "Kernel and architecture" uname -a
collect_cmd 01-system/os-release.txt "OS distribution (first of os-release/lsb-release)" sh -c '
for f in /etc/os-release /usr/lib/os-release /etc/lsb-release; do
[ -r "$f" ] && { echo "# source: $f"; cat "$f"; break; }
done'
collect_cmd 01-system/uptime-load.txt "Uptime and load" uptime
collect_cmd 01-system/cpu-count.txt "CPU count" sh -c 'nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null'
collect_cmd 01-system/memory.txt "Memory overview" sh -c '
if command -v free >/dev/null; then free -m;
elif [ -r /proc/meminfo ]; then head -6 /proc/meminfo;
else sysctl -n hw.memsize hw.physmem 2>/dev/null; command -v vm_stat >/dev/null && vm_stat; fi 2>/dev/null; true'
collect_cmd 01-system/disk-usage.txt "Filesystem usage" df -h
collect_cmd 01-system/virtualization.txt "Virtualization/container detection" sh -c 'command -v systemd-detect-virt >/dev/null && systemd-detect-virt || echo "systemd-detect-virt not available"'
[ -d /sys/fs/cgroup ] && collect_cmd 01-system/cgroups.txt "cgroup version" stat -fc %T /sys/fs/cgroup
collect_cmd 01-system/clock-timesync.txt "Clock and time sync (drift breaks streaming/cloud)" sh -c 'date -u; command -v timedatectl >/dev/null && timedatectl status || true'
collect_cmd 01-system/mountinfo.txt "Mount table (namespace visibility issues)" sh -c 'cat /proc/self/mountinfo 2>/dev/null || mount'
collect_cmd 01-system/selinux-apparmor.txt "MAC status" sh -c '
o="";
command -v getenforce >/dev/null && o="selinux: $(getenforce 2>/dev/null)";
[ -d /sys/kernel/security/apparmor ] && o="$o apparmor: present";
[ -n "$o" ] && echo "$o" || echo "(no SELinux/AppArmor detected)"'
collect_cmd 01-system/kernel-messages.txt "Kernel messages: OOM/segfault/netdata (evidence of kills and crashes)" \
sh -c 'out=""; command -v journalctl >/dev/null && out=$(journalctl -k --no-pager --since "-'"$SINCE_HOURS"' hours" 2>/dev/null | grep -iE "oom|out of memory|segfault|netdata" | tail -300);
[ -z "$out" ] && out=$(dmesg 2>/dev/null | grep -iE "oom|out of memory|segfault|netdata" | tail -300);
if [ -n "$out" ]; then printf "%s\n" "$out"; else echo "(no matching kernel messages, or kernel log not readable in this environment)"; fi; true'
}
# =============================================================================
# 02-install
# =============================================================================
collect_install() {
info "collecting: install"
for envf in "$CONFDIR/.environment" /etc/netdata/.environment /opt/netdata/etc/netdata/.environment; do
[ -f "$envf" ] && { collect_file 02-install/environment-file.txt "Install-time environment (method, flags, channel; contains no secrets)" "$envf"; break; }
done
for itf in "$CONFDIR/.install-type" /etc/netdata/.install-type /opt/netdata/etc/netdata/.install-type; do
[ -f "$itf" ] && { collect_file 02-install/install-type.file.txt "Install type marker (kickstart-build|kickstart-static|oci|custom|binpkg-*)" "$itf"; break; }
done
collect_cmd 02-install/package-info.txt "Netdata packages installed (name/version/status)" sh -c '
found=0;
if command -v dpkg-query >/dev/null; then
out=$(dpkg-query -W -f "\${Package} \${Version} [\${Status}]\n" "*netdata*" 2>/dev/null);
printf "%s\n" "$out";
printf "%s" "$out" | grep -q "install ok installed" && found=1;
fi;
if command -v rpm >/dev/null; then
out=$(rpm -qa "*netdata*" 2>/dev/null); [ -n "$out" ] && { printf "%s\n" "$out"; found=1; };
fi;
if command -v apk >/dev/null; then
out=$(apk list --installed 2>/dev/null | grep -i netdata); [ -n "$out" ] && { printf "%s\n" "$out"; found=1; };
fi;
[ "$found" = "0" ] && echo "(no netdata OS package installed via dpkg/rpm/apk - normal for docker, static and from-source installs; a \"not-installed\" stub above just means another package references the name. See install-type.txt for how this agent was installed.)";
true'
collect_cmd 02-install/install-type.txt "Install type inference" sh -c '
o=0;
[ -d /opt/netdata/etc/netdata ] && { echo "static build (/opt/netdata)"; o=1; };
[ -f /.dockerenv ] && { echo "docker container (/.dockerenv present)"; o=1; };
[ -f /etc/netdata/.environment ] && { echo "kickstart-managed (/etc/netdata/.environment present)"; o=1; };
command -v netdata >/dev/null && { echo "netdata binary: $(command -v netdata)"; o=1; };
[ "$o" = "0" ] && echo "(no netdata installation detected on this system)";
true'
if [ "$IS_CONTAINER" = "1" ]; then
collect_cmd 02-install/container-context.txt "Container context (pid1, env, cgroup)" sh -c '
echo "== /proc/1/comm =="; cat /proc/1/comm 2>/dev/null;
echo "== /proc/1/cgroup =="; cat /proc/1/cgroup 2>/dev/null;
echo "== container env (NETDATA_*/DOCKER_*) ==";
tr "\0" "\n" < /proc/1/environ 2>/dev/null | grep -E "^(NETDATA_|DOCKER_|DO_NOT)" \
|| env | grep -E "^(NETDATA_|DOCKER_|DO_NOT)" \
|| echo "(no NETDATA_*/DOCKER_* env vars visible)";
true'
fi
}
# =============================================================================
# 03-process
# =============================================================================
collect_process() {
info "collecting: process"
collect_cmd 03-process/ps-netdata.txt "Netdata process tree with CPU/memory" sh -c 'ps aux 2>/dev/null | head -1; ps aux 2>/dev/null | grep -E "[n]etdata|[g]o.d|[e]bpf|[a]pps.plugin|[c]harts.d|[p]ython.d" | grep -v "netdata-support-bundle" | head -50'
if [ -n "${NETDATA_PID:-}" ]; then
collect_cmd 03-process/threads-cpu.txt "Per-thread CPU of netdata (which thread is hot)" sh -c "
if ps -L -o pid,tid,pcpu,pmem,comm -p $NETDATA_PID >/dev/null 2>&1; then
ps -L -o pid,tid,pcpu,pmem,comm -p $NETDATA_PID | head -1;
ps -L -o pid,tid,pcpu,pmem,comm -p $NETDATA_PID | tail -n +2 | sort -k3 -rn | head -40;
else ps -M -p $NETDATA_PID 2>/dev/null | head -40 || ps -H -p $NETDATA_PID 2>/dev/null | head -40; fi; true"
if [ -d "/proc/$NETDATA_PID" ]; then
collect_cmd 03-process/proc-status.txt "Process status (RSS, threads, ctx switches)" cat "/proc/$NETDATA_PID/status"
collect_cmd 03-process/proc-limits.txt "Process limits" cat "/proc/$NETDATA_PID/limits"
collect_cmd 03-process/fd-count.txt "Open file descriptors" sh -c "ls /proc/$NETDATA_PID/fd 2>/dev/null | wc -l"
collect_cmd 03-process/process-environ.txt "Netdata process environment (proxy/claim vars; values sanitized)" \
sh -c "if tr '\0' '\n' < /proc/$NETDATA_PID/environ 2>/dev/null; then :; else
echo '(/proc/$NETDATA_PID/environ not readable - containers need CAP_SYS_PTRACE for this)';
echo '-- fallback: NETDATA_*/proxy vars visible to this shell (docker exec inherits container env) --';
env | grep -iE '^(NETDATA_|https?_proxy|no_proxy|all_proxy)' || echo '(none)';
echo '-- on the docker HOST you can also run: docker inspect -f \"{{.Config.Env}}\" <container> --';
fi"
fi
fi
collect_cmd 03-process/zombies.txt "Zombie processes (plugin reaping issues in containers)" \
sh -c 'z=$(ps -eo pid=,ppid=,stat=,comm= 2>/dev/null | awk "\$3 ~ /Z/" | head -30); [ -n "$z" ] && printf "%s\n" "$z" || echo "(no zombie processes)"'
}
# =============================================================================
# 04-config
# =============================================================================
collect_config() {
info "collecting: config"
if [ "$api_ok" = "1" ]; then
collect_api 04-config/effective-netdata.conf "EFFECTIVE running config (merged, annotated) - authoritative over on-disk file" /netdata.conf
fi
if [ -n "$CONFDIR" ]; then
collect_cmd 04-config/config-tree.txt "User config dir tree (files here = user-customized; ssl/ and key material excluded)" sh -c '
ls -laR '"$CONFDIR"' 2>/dev/null | head -2000 | awk "
/\/ssl:\$/ { print; skip = 1; print \" [ssl directory contents withheld]\"; next }
skip && /^\$/ { skip = 0; print; next }
skip { next }
/\.(pem|key)\$/ { next }
{ print }"'
collect_file 04-config/netdata.conf "On-disk main config" "$CONFDIR/netdata.conf"
collect_file 04-config/stream.conf "Streaming config (parent/child; the streaming api key is KEPT VERBATIM - see README.md)" "$CONFDIR/stream.conf"
collect_file 04-config/exporting.conf "Exporting engine config (credentials redacted)" "$CONFDIR/exporting.conf"
collect_file 04-config/go.d.conf "go.d orchestrator config (module enable/disable)" "$CONFDIR/go.d.conf"
# every user-customized config, nested dirs included (go.d/sd/, go.d/ss/,
# otel.d/, vnodes/, ...), relative paths preserved; ssl and key material
# excluded; capped at 200 files
find "$CONFDIR" -type f \( -name '*.conf' -o -name '*.yml' -o -name '*.yaml' \) 2>/dev/null | head -200 | while IFS= read -r f; do
case "$f" in */ssl/*|*.pem|*.key) continue ;; *) : ;; esac
_relc=${f#"$CONFDIR"/}
case "$_relc" in netdata.conf|stream.conf|exporting.conf|go.d.conf) continue ;; *) : ;; esac
collect_file "04-config/$_relc" "User config (secrets redacted)" "$f" 262144
done
fi
if [ -n "$LIBDIR" ] && [ -f "$LIBDIR/cloud.d/cloud.conf" ]; then
collect_file 04-config/cloud.conf "Cloud connection config (token redacted)" "$LIBDIR/cloud.d/cloud.conf"
fi
}
# =============================================================================
# 05-logs
# =============================================================================
collect_logs() {
info "collecting: logs (last ${SINCE_HOURS}h, capped)"
if command -v journalctl >/dev/null 2>&1; then
collect_cmd --cap "$LOG_CAP" 05-logs/journal-netdata.txt "systemd journal for netdata unit" \
sh -c "journalctl -u netdata --no-pager -o short-iso --since '-${SINCE_HOURS} hours' 2>/dev/null | tail -n 20000; true"
collect_cmd --cap "$LOG_CAP" 05-logs/journal-namespace-netdata.txt "netdata journal namespace (some installs log here)" \
sh -c "journalctl --namespace=netdata --no-pager -o short-iso --since '-${SINCE_HOURS} hours' 2>/dev/null | tail -n 20000; true"
fi
if [ -n "$LOGDIR" ]; then
for lf in error.log daemon.log collector.log health.log aclk.log debug.log; do
collect_file "05-logs/$lf" "Agent log file: $lf" "$LOGDIR/$lf" "$LOG_CAP"
done
collect_file 05-logs/access.log "API access log (clients pseudonymized)" "$LOGDIR/access.log" 1048576
# docker images symlink logs to /dev/stdout|stderr - history only exists in `docker logs`
DOCKER_LOGS_NEEDED=0
if [ -L "$LOGDIR/daemon.log" ] || [ -L "$LOGDIR/error.log" ]; then
_lt=$(readlink "$LOGDIR/daemon.log" 2>/dev/null || readlink "$LOGDIR/error.log" 2>/dev/null)
case "$_lt" in
/dev/std*)
DOCKER_LOGS_NEEDED=1
mkdir -p "$WORK/05-logs"
{
echo "This agent logs to the container stdout/stderr; capture its history on the Docker host."
echo "The output is UNSANITIZED. Keep it private until you review and redact credentials and PII."
echo
echo " umask 077"
printf ' docker logs --since %sh <netdata-container> > netdata-docker.log 2>&1\n' "$SINCE_HOURS"
echo
echo "Attach only the reviewed, redacted copy through your restricted support ticket."
} > "$WORK/05-logs/LOGS-ARE-IN-DOCKER.txt"
manifest_add 05-logs/LOGS-ARE-IN-DOCKER.txt file generated "Instruction: agent logs live in 'docker logs' on the host"
;;
*) : ;;
esac
fi
fi
if command -v journalctl >/dev/null 2>&1; then
collect_cmd 05-logs/journal-updater.txt "Auto-updater service journal (updater keeps no persistent log file)" \
sh -c "journalctl -u netdata-updater.service --no-pager -o short-iso 2>/dev/null | tail -200; true"
fi
collect_cmd 05-logs/coredumps.txt "Recent coredump METADATA for netdata (not the dumps)" \
sh -c 'if command -v coredumpctl >/dev/null; then coredumpctl list --no-pager 2>/dev/null | awk "NR==1 || tolower(\$0) ~ /netdata/" | tail -21; else echo "coredumpctl not available"; fi; true'
}
# =============================================================================
# 06-state
# =============================================================================
collect_state() {
info "collecting: state"
collect_snmp_diagnostics
# status file: agent writes to first writable of these; newest mtime wins (status-file-io.c)
NEWEST_STATUS=""
_status_candidates=""
[ -n "$LIBDIR" ] && _status_candidates="$_status_candidates $LIBDIR/status-netdata.json"
[ -n "$CACHEDIR" ] && _status_candidates="$_status_candidates $CACHEDIR/status-netdata.json"
# Shared /tmp is not trusted crash evidence. Restrict transient fallbacks to
# service runtime directories and only when an installation is present.
if [ -n "$CONFDIR$LIBDIR" ] || [ -n "${NETDATA_PID:-}" ]; then
_status_candidates="$_status_candidates /run/status-netdata.json /var/run/status-netdata.json"
fi
for sf in $_status_candidates; do
if [ ! -f "$sf" ] || [ ! -r "$sf" ] || [ -h "$sf" ]; then continue; fi
# shellcheck disable=SC3013 # supported by dash, BusyBox, bash and FreeBSD sh
if [ -z "$NEWEST_STATUS" ] || [ "$sf" -nt "$NEWEST_STATUS" ]; then NEWEST_STATUS="$sf"; fi
done
[ -n "$NEWEST_STATUS" ] && collect_file 06-state/status-file.json "Daemon status file: LAST EXIT/CRASH RECORD incl. fatal stack trace (read this first for crashes)" "$NEWEST_STATUS"
if [ -n "$LIBDIR" ]; then
# Aggregate metadata only: a state filename can itself be a credential.
collect_cmd 06-state/state-tree.txt "State directory aggregate inventory (filenames withheld)" sh -c '
if stat -c %s "$1" >/dev/null 2>&1; then
find "$1" -type f -exec stat -c %s {} + 2>/dev/null
else
find "$1" -type f -exec stat -f %z {} + 2>/dev/null
fi | awk "{ n++; bytes += \$1 } END { printf \"files: %d\\nlogical bytes: %.0f\\n\", n, bytes }"
' sh "$LIBDIR"
collect_cmd 06-state/cloud-state.txt "Cloud claim state (claimed_id is safe; token/private.pem are never collected)" sh -c '
echo "== cloud.d listing =="; ls -la '"$LIBDIR"'/cloud.d/ 2>/dev/null;
echo "== claimed_id ==";
cat '"$LIBDIR"'/cloud.d/claimed_id 2>/dev/null || echo "(no claimed_id file - agent not claimed)"; echo;
echo "(token and private.pem intentionally NOT collected)"; true'
collect_file 06-state/health-silencers.json "Persisted alert silencers" "$LIBDIR/health.silencers.json"
# python.d is the only plugin still dumping job state to disk; go.d moved its
# job state into dyncfg, captured live in 07-runtime/dyncfg-tree.json
collect_file 06-state/pythond-job-statuses.json "python.d collector job states (go.d job state is in 07-runtime/dyncfg-tree.json)" "$LIBDIR/pythond-jobs-statuses.json"
if [ -d "$LIBDIR/config" ]; then
for dc in "$LIBDIR/config"/*.dyncfg; do
[ -f "$dc" ] || continue
collect_file "06-state/dyncfg/$(basename "$dc")" "Dynamic config created via UI/API (secrets redacted)" "$dc" 262144
done
fi
fi
if [ -n "$CACHEDIR$LIBDIR" ]; then
collect_cmd 06-state/db-disk-usage.txt "Database disk usage per tier + sqlite sizes + corruption sentinels" sh -c '
[ -n "'"$CACHEDIR"'" ] && du -sh '"$CACHEDIR"'/* 2>/dev/null | sort -rh | head -30;
[ -n "'"$LIBDIR"'" ] && ls -la '"$LIBDIR"'/*.db* 2>/dev/null;
echo "== sqlite corruption/recovery sentinels (presence = past corruption) ==";
ls -la '"$CACHEDIR"'/*.bad* '"$CACHEDIR"'/.*.recover '"$CACHEDIR"'/*.recover 2>/dev/null || echo "(none found)";
true'
fi
}
# =============================================================================
# 07-runtime (API captures when the agent's API answers; netdatacli captures
# whenever the agent runs and netdatacli was found)
# =============================================================================
# collect_dyncfg_tree_cli - the dyncfg tree through the local command pipe.
# The pipe is reachable only by root, the netdata user and the netdata group,
# and the agent answers it as a member of its own space; the web API needs a
# signed-in member, which the bundle cannot be. Returns non-zero, leaving no
# file and no manifest row, when netdatacli was not found, the agent predates
# the command, or the call fails, so the caller can fall back to the API under
# the same path.
collect_dyncfg_tree_cli() {
[ -n "${NETDATACLI:-}" ] && [ -n "${NETDATA_PID:-}" ] || return 1
deadline_exceeded && return 1
_dtc_rel=07-runtime/dyncfg-tree.json
_dtc_out="$WORK/$_dtc_rel"
mkdir -p "$(dirname "$_dtc_out")"
capture_output "$_dtc_out.cli" "$((API_CAP + 1))" 0 run_capped "$CMD_TIMEOUT" "$NETDATACLI" dyncfg-tree
if [ "$CAPTURE_RC" = 0 ] && [ "$CAPTURE_BYTES" -gt 0 ] && [ "$CAPTURE_BYTES" -le "$API_CAP" ] && \
mv -f "$_dtc_out.cli" "$_dtc_out"; then
sanitize_file "$_dtc_out"
manifest_add "$_dtc_rel" cmd "netdatacli dyncfg-tree" "Dynamic configuration tree: go.d/scripts.d job states (accepted/running/failed/disabled/orphan/incomplete/none), service discovery, vnodes, secret stores, alert prototypes - THE source of collector job state"
return 0
fi
rm -f "$_dtc_out.cli"
return 1
}
collect_runtime() {
# The command pipe does not need the web API, so the tree is tried through it
# whatever api_ok says - but at the tree's own place in the order: sanitizing
# a large tree can take minutes, and done first it would spend the global
# deadline that the smaller runtime captures before it need.
_rt_dyncfg_cli=0
if [ "$api_ok" = "1" ]; then
info "collecting: runtime (agent is up)"
collect_api 07-runtime/info-v3.json "BEST SINGLE CALL: buildinfo, features, cloud status, per-tier retention (works even under bearer protection)" /api/v3/info
collect_api 07-runtime/info-v1.json "Agent info v1: version, cloud/stream booleans, mirrored hosts" /api/v1/info
collect_api 07-runtime/node-instances.json "Node instances: children, streaming state, db_size per tier, metric counts" /api/v2/node_instances
collect_api 07-runtime/stream-info.json "Streaming diagnostics" /api/v3/stream_info
collect_api 07-runtime/aclk.json "Cloud/ACLK connection state" /api/v1/aclk
collect_api 07-runtime/alerts-active.json "Currently raised alerts" "/api/v3/alerts?options=active"
collect_api 07-runtime/alerts-all.json "All alert instances (summary)" "/api/v1/alarms?all"
collect_api 07-runtime/functions.json "Registered functions (which plugins expose what)" /api/v1/functions
collect_dyncfg_tree_cli && _rt_dyncfg_cli=1
# agents that predate `netdatacli dyncfg-tree`, or a bundle run as a user
# who cannot reach the command pipe, fall back to the API
[ "$_rt_dyncfg_cli" = "1" ] || \
collect_api 07-runtime/dyncfg-tree.json "Dynamic configuration tree: go.d/scripts.d job states (accepted/running/failed/disabled/orphan/incomplete/none), service discovery, vnodes, secret stores, alert prototypes - THE source of collector job state" "/api/v3/config?action=tree"
# /api/v3/info answers without any access check, so api_ok can be 1 while
# this endpoint - which sits behind the dyncfg ACL - refuses us. The bundle
# cannot authenticate: bearer token FILENAMES are themselves live tokens and
# are never collected. Say so, rather than leaving a bare exit code, so the
# reader does not read the absence as "no collector jobs exist".
if [ "$_rt_dyncfg_cli" != "1" ] && [ -f "$WORK/07-runtime/dyncfg-tree.json" ] && \
grep -q '"error":"capture failed' "$WORK/07-runtime/dyncfg-tree.json" 2>/dev/null; then
# Keep the observed failure rather than asserting a cause: a refusal, a
# timeout, a transport error and a 5xx all land here identically, and
# naming one of them would be the exact over-reading this marker exists
# to prevent. Every step below can fail, and a half-written marker with a
# stale manifest row is worse than the plain capture error it replaces,
# so anything short of full success keeps the original body.
_dt_new="$WORK/07-runtime/dyncfg-tree.json.new"
_dt_ok=0
if _dt_detail=$(tr -d "\n" < "$WORK/07-runtime/dyncfg-tree.json" 2>/dev/null) && [ -n "$_dt_detail" ]; then
if printf '%s\n' "{\"error\":\"dyncfg tree was not collected\",\"observed\":${_dt_detail},\"hint\":\"netdatacli dyncfg-tree was not usable (netdatacli was not found, the agent predates the command, the bundle did not run as root, the netdata user or a member of the netdata group, or the call failed), so the bundle fell back to /api/v3/config, which only signed-in members of the agent's space may list on current agents, and which a bearer-protected API refuses on older ones - a timeout, transport error or HTTP failure is indistinguishable here. The bundle never collects bearer tokens and cannot authenticate. Run 'netdatacli dyncfg-tree' as root, or ask for the collector job state separately.\",\"means\":\"NOT evidence that no collector jobs exist\"}" > "$_dt_new"; then
if mv -f "$_dt_new" "$WORK/07-runtime/dyncfg-tree.json"; then
_dt_ok=1
fi
fi
fi
rm -f "$_dt_new"
if [ "$_dt_ok" = "1" ]; then
if ! manifest_fix_bytes 07-runtime/dyncfg-tree.json; then
printf '[netdata-support-bundle] dyncfg marker written but its manifest size could not be corrected\n' >&2
fi
else
printf '[netdata-support-bundle] could not write the dyncfg marker; the original capture error is kept\n' >&2
fi
fi
collect_api 07-runtime/ml-info.json "Machine learning status" /api/v1/ml_info
# netdata's own resource usage, bounded windows (perf triage without screenshots)
collect_api 07-runtime/self-cpu.csv "Netdata CPU last 10min (csv)" "/api/v1/data?chart=netdata.server_cpu&after=-600&points=60&format=csv"
collect_api 07-runtime/self-memory.csv "Netdata memory last 10min (csv)" "/api/v1/data?chart=netdata.memory&after=-600&points=60&format=csv"
collect_api 07-runtime/self-api-clients.csv "Netdata API clients last 10min (csv)" "/api/v1/data?chart=netdata.clients&after=-600&points=60&format=csv"
else
info "agent API unreachable - skipping runtime section"
mkdir -p "$WORK/07-runtime"
echo "Agent API at 127.0.0.1:$NDPORT was unreachable when this bundle was created. See 05-logs and 06-state/status-file.json for why." > "$WORK/07-runtime/AGENT-WAS-DOWN.txt"
manifest_add 07-runtime/AGENT-WAS-DOWN.txt "file" "generated" "Marker: agent API unreachable at collection time"
collect_dyncfg_tree_cli || :
fi
if [ -n "${NETDATA_BIN:-}" ]; then
collect_cmd 07-runtime/buildinfo.txt "netdata -W buildinfo (verbatim - paths section matters; works with daemon down)" "$NETDATA_BIN" -W buildinfo
collect_cmd_raw 07-runtime/buildinfo.json "netdata -W buildinfojson (machine-readable; no header so it parses as JSON)" "$NETDATA_BIN" -W buildinfojson
collect_cmd 07-runtime/cmakecache.txt "netdata -W cmakecache: authoritative build config (compiler flags, enabled plugins, configured paths) - superset of buildinfo" "$NETDATA_BIN" -W cmakecache
fi
if [ -n "${NETDATACLI:-}" ] && [ -n "${NETDATA_PID:-}" ]; then
collect_cmd_raw 07-runtime/aclk-state.json "Cloud connectivity state (netdatacli aclk-state json; no header so it parses as JSON)" "$NETDATACLI" aclk-state json
fi
}
# =============================================================================
# 08-network
# =============================================================================
collect_network() {
info "collecting: network"
collect_cmd 08-network/netdata-sockets.txt "All sockets owned by the Netdata process tree" \
sh -c '
_live_pids=""
if [ -n "${NETDATA_PID:-}" ] && command -v ps >/dev/null 2>&1; then
_live_pids=$(ps -eo pid=,ppid=,comm= 2>/dev/null | awk -v root="$NETDATA_PID" "
{ pid=\$1; parent[pid]=\$2; comm[pid]=\$3 }
END {
if (root !~ /^[0-9]+$/ || comm[root] != \"netdata\") exit
owned[root]=1; changed=1
while (changed) {
changed=0
for (pid in parent) if (!owned[pid] && owned[parent[pid]]) {
owned[pid]=1; changed=1
}
}
for (pid in owned) if (owned[pid]) print pid
}" | sort -n)
fi
if [ -z "$_live_pids" ]; then
echo "(unavailable: Netdata process not found)"
elif command -v lsof >/dev/null 2>&1; then
_valid_pids=$(printf "%s\\n" "$_live_pids" | awk "/^[0-9]+$/")
if [ -z "$_valid_pids" ]; then
echo "(unavailable: Netdata process tree contains no valid PIDs)"
else
_pid_list=$(printf "%s\\n" "$_valid_pids" | awk "/^[0-9]+$/ { printf \"%s%s\", sep, \$1; sep=\",\" }")
_pid_re=$(printf "%s" "$_pid_list" | tr "," "|")
# Keep a second PID check on the text output. Some lsof variants have
# different selection semantics; an ignored -p must never publish a
# host-wide listing.
lsof -nP -a -p "$_pid_list" -i 2>&1 |
awk -v re="^(${_pid_re})$" "\$1 ~ /^lsof:/ || \$1 == \"COMMAND\" || \$2 ~ re"
lsof -nP -a -p "$_pid_list" -U 2>&1 |
awk -v re="^(${_pid_re})$" "\$1 ~ /^lsof:/ || \$1 == \"COMMAND\" || \$2 ~ re"
fi
elif command -v ss >/dev/null 2>&1; then
_pid_re=$(printf "%s\\n" "$_live_pids" | awk "/^[0-9]+$/ { printf \"%s%s\", sep, \$1; sep=\"|\" }")
ss -a -tunxp 2>&1 | awk -v re="pid=(${_pid_re})(,|\\))" "NR == 1 || \$0 ~ re"
elif command -v sockstat >/dev/null 2>&1; then
_pid_re=$(printf "%s\\n" "$_live_pids" | awk "/^[0-9]+$/ { printf \"%s%s\", sep, \$1; sep=\"|\" }")
sockstat -s 2>&1 | awk -v re="^(${_pid_re})$" "NR == 1 || \$3 ~ re"
elif command -v netstat >/dev/null 2>&1; then
echo "(degraded: process ownership unavailable; showing port 19999 listeners only)"
netstat -an 2>&1 | awk "NR == 1 || (\$0 ~ /19999/ && \$0 ~ /LISTEN|listen|LISTENING/)"
else
echo "(unavailable: lsof, ss, sockstat, or netstat is required for socket details)"
fi; true'
if [ "$OBFUSCATE" = "1" ]; then
# search/domain values are often corporate-internal names outside private TLDs
collect_cmd 08-network/resolv-conf.txt "DNS resolver config (search domains withheld)" \
sh -c 'sed -E "s/^((search|domain)[ \t]).*/\1[SEARCH-DOMAINS-WITHHELD]/" /etc/resolv.conf 2>/dev/null; true'
else
collect_file 08-network/resolv-conf.txt "DNS resolver config" /etc/resolv.conf
fi
collect_cmd 08-network/proxy-env.txt "Proxy environment (this shell; see 03-process/process-environ.txt for the agent view)" \
sh -c 'env | grep -iE "^(https?_proxy|no_proxy|all_proxy)=" || echo "(no proxy variables set)"'
collect_cmd 08-network/cloud-connectivity.txt "Reachability of Netdata Cloud (DNS + TLS + response code, no data sent)" sh -c '
if command -v curl >/dev/null; then
curl -sv --max-time 8 -o /dev/null https://app.netdata.cloud/ 2>&1 \
| grep -E "^\*|^< HTTP|^> " \
| grep -viE "TLS handshake|change.?cipher|certificate|subject:|issuer:|ALPN|offering|CAfile|user-agent|accept:" \
| head -40;
elif command -v wget >/dev/null; then
if wget -q -T 8 -O /dev/null https://app.netdata.cloud/ 2>/dev/null;
then echo "wget https://app.netdata.cloud/ : SUCCESS";
else echo "wget https://app.netdata.cloud/ : FAILED (exit $?)"; fi;
else echo "neither curl nor wget available"; fi; true'
}
# =============================================================================
# 09-permissions
# =============================================================================
_add_pluginsd() {
[ -n "${1:-}" ] || return 0
[ -d "$1" ] || return 0
case " $PLUGINSD_DIRS " in *" $1 "*) return 0 ;; *) : ;; esac
PLUGINSD_DIRS="$PLUGINSD_DIRS $1"
}
collect_permissions() {
info "collecting: permissions"
# plugins.d discovery: the binary's own prefix, the known install prefixes, and
# <config>/custom-plugins.d, which the agent also searches by default
# (plugins_d.c). A [directories] plugins override in netdata.conf is not read:
# it is rare, and keeping every path here out of user-controlled input means
# nothing config-derived ever reaches a collector.
PLUGINSD_DIRS=""
if [ -n "${NETDATA_BIN:-}" ]; then
case "$NETDATA_BIN" in
*/usr/sbin/netdata|*/usr/bin/netdata) _add_pluginsd "${NETDATA_BIN%/usr/*}/usr/libexec/netdata/plugins.d" ;;
*) : ;;
esac
_add_pluginsd "$(dirname "$(dirname "$NETDATA_BIN")")/libexec/netdata/plugins.d"
fi
for d in /usr/libexec/netdata/plugins.d /usr/lib/netdata/plugins.d \
/opt/netdata/usr/libexec/netdata/plugins.d \
/usr/local/libexec/netdata/plugins.d \
/opt/homebrew/libexec/netdata/plugins.d; do
_add_pluginsd "$d"
done
[ -n "$CONFDIR" ] && _add_pluginsd "$CONFDIR/custom-plugins.d"
PLUGINSD_DIRS="${PLUGINSD_DIRS# }"
# The directory list reaches the child as positional ARGUMENTS, never as text
# spliced into the program: a binary-derived installation path containing a quote
# would otherwise
# close the quoted program and run as shell code with the bundle's privileges.
# shellcheck disable=SC2086 # deliberate word-split of the discovered dir list
collect_cmd 09-permissions/plugins-d.txt "plugins.d: modes, ownership, setuid/setgid bits and file CAPABILITIES (lost capabilities are a top cause of a plugin collecting nothing)" sh -c '
if [ "$#" -eq 0 ]; then echo "(no plugins.d directory found on this host)"; exit 0; fi
for d in "$@"; do
echo "===== $d ====="
ls -ld "$d" 2>/dev/null
echo "-- contents --"
ls -la "$d" 2>/dev/null | head -200
echo "-- setuid/setgid entries --"
s=$(find "$d" -maxdepth 1 -type f \( -perm -4000 -o -perm -2000 \) 2>/dev/null | head -50)
if [ -n "$s" ]; then printf "%s\n" "$s"; else echo "(none)"; fi
echo "-- file capabilities --"
if command -v getcap >/dev/null 2>&1; then
c=$(getcap -r "$d" 2>/dev/null | head -50)
if [ -n "$c" ]; then printf "%s\n" "$c"; else echo "(no file has capabilities set)"; fi
else
echo "(getcap not available - file capabilities could NOT be checked)"
fi
# NOTE: a trailing "+" in the listing means "alternate access method", which
# on netdata installs is usually the security.capability xattr shown above,
# NOT an ACL. ACLs for the directory itself are in netdata-paths.txt.
done
true' sh $PLUGINSD_DIRS
# every netdata-owned path worth reporting permissions for; the cache dir stays
# TOP-LEVEL only (recursing it would enumerate thousands of dbengine files)
_PERM_PATHS=""
# the journal directories are NOT netdata's, but they decide whether
# systemd-journal.plugin can read anything: on a default host they are
# root:systemd-journal with an ACL, and an ACL is invisible to ls
for _p in "$CONFDIR" "$CONFDIR/netdata.conf" "$CONFDIR/stream.conf" "$CONFDIR/ssl" \
"$LOGDIR" "$LIBDIR" "$LIBDIR/cloud.d" "$LIBDIR/registry" "$CACHEDIR" \
/var/log/journal /run/log/journal \
$PLUGINSD_DIRS "${NETDATA_BIN:-}"; do
[ -n "$_p" ] || continue
[ -e "$_p" ] || continue
case " $_PERM_PATHS " in *" $_p "*) continue ;; *) : ;; esac
_PERM_PATHS="$_PERM_PATHS $_p"
done
_PERM_PATHS="${_PERM_PATHS# }"
# Which user the agent runs as, and what groups it is in. Journal access is
# granted by a file capability, by journal-group membership, or by an ACL; the
# capability sweep above covers the first, this covers the second, and the
# journal directories in the path list cover the third. Without all three a
# host that relies on group membership looks identical to a broken one.
collect_cmd 09-permissions/netdata-user.txt "Which user the agent runs as and its group membership (journal and device access depend on it)" sh -c '
raw=""
[ -n "${1:-}" ] && raw=$(ps -o user= -p "$1" 2>/dev/null | tr -d " ")
echo "agent process user (as ps reports it): ${raw:-(unknown: no running netdata process)}"
# ps prints a NUMERIC uid when the name will not fit or the account does not
# exist here - which is exactly what a containerised agent looks like from
# the host. Resolve it rather than reporting a bare number.
resolved=""
case "$raw" in
"" ) : ;;
*[!0-9]* ) resolved="$raw" ;;
* ) if getent passwd "$raw" >/dev/null 2>&1; then
resolved=$(getent passwd "$raw" | cut -d: -f1)
echo "resolved uid $raw to account: $resolved"
else
echo "uid $raw has NO account on this host - the agent is probably containerised,"
echo "so its groups live in the container, not here. Collect a bundle inside the container."
fi ;;
esac
echo
if [ -n "$resolved" ]; then
echo "--- id $resolved ---"
id "$resolved" 2>/dev/null || echo "(id failed for $resolved)"
fi
if getent passwd netdata >/dev/null 2>&1; then
echo "--- id netdata (the local account, for comparison) ---"
id netdata 2>/dev/null || true
else
echo "(no local netdata account on this host)"
fi
echo
echo "--- groups that grant journal access ---"
for g in systemd-journal adm; do
if getent group "$g" >/dev/null 2>&1; then getent group "$g"; else echo "$g: (group does not exist on this host)"; fi
done
true' sh "${NETDATA_PID:-}"
# OPT-IN ONLY. Every other capture is read-only; this one executes a collector,
# so it runs solely when the operator asked for it. Bounded by the usual
# per-command timeout and cap, sanitized like any other capture. It answers the
# question the permission artifacts cannot: what the plugin itself reports when
# it tries to open the journal.
if [ "$INCLUDE_PLUGIN_DEBUG" = "1" ]; then
# Everything - identity lookup included - happens inside collect_cmd, so it
# is bounded by the per-command timeout. A getent out here would be
# unbounded, and a slow or wedged NSS would stall the whole bundle before
# the capped capture even started.
# shellcheck disable=SC2086 # deliberate word-split of the discovered dir list
collect_cmd 09-permissions/plugin-debug-systemd-journal.txt \
"REQUESTED: systemd-journal.plugin debug STDERR (its stdout is journal content and is discarded); states directly why the journal could not be opened" \
sh -c '
pid="$1"; t="$2"; shift 2
# --- locate the plugin -------------------------------------------------
bin=""; seen=""
for d in "$@"; do
[ -n "$d" ] || continue
[ -f "$d/systemd-journal.plugin" ] || continue
seen="$d/systemd-journal.plugin"
if [ -x "$seen" ]; then bin="$seen"; break; fi
done
if [ -z "$bin" ] && [ -n "$seen" ]; then
echo "systemd-journal.plugin IS installed at: $seen"
echo "but it is not executable by the current user ($(id -un 2>/dev/null))."
ls -l "$seen" 2>/dev/null
echo "Re-run the bundle with sudo to capture the debug output."
exit 0
fi
if [ -z "$bin" ]; then
echo "systemd-journal.plugin is not installed in any discovered plugins.d directory."
exit 0
fi
# --- resolve the agent user to a local NAME ----------------------------
raw=""; u=""
[ -n "$pid" ] && raw=$(ps -o user= -p "$pid" 2>/dev/null | tr -d " ")
case "$raw" in
"") if getent passwd netdata >/dev/null 2>&1; then u=netdata
echo "# note: no running netdata process; using the local netdata account"
else
echo "# note: no running netdata process and no local netdata account"
fi ;;
*[!0-9]*) u="$raw" ;;
*) if getent passwd "$raw" >/dev/null 2>&1; then
u=$(getent passwd "$raw" | cut -d: -f1)
else
echo "# note: the agent runs as uid $raw, which has no local account (containerised)."
echo "# NOT substituting the host netdata account - that would test a different identity."
fi ;;
esac
[ -n "$u" ] || exit 0
# --- refuse a path we cannot pass safely -------------------------------
# runuser takes argv, so any path is safe there; the su fallback has to
# build a command string, and a path with shell metacharacters would be
# executed as code under the agent account.
case "$bin" in
*[!A-Za-z0-9/._-]*) bin_safe=0 ;;
*) bin_safe=1 ;;
esac
run() { if command -v timeout >/dev/null 2>&1; then timeout "$t" "$@"; else echo "# note: no timeout(1) here; only the bundle watchdog bounds this"; "$@"; fi; }
if [ "$(id -u)" = "0" ] && command -v runuser >/dev/null 2>&1; then
echo "# running as: $u (via runuser)"
run runuser -u "$u" -- "$bin" debug 2>&1 >/dev/null
echo "# exit: $?"
elif [ "$(id -u)" = "0" ] && command -v su >/dev/null 2>&1 && [ "$bin_safe" = "1" ]; then
echo "# running as: $u (via su)"
run su -s /bin/sh -c "$bin debug 2>&1 >/dev/null" "$u"
echo "# exit: $?"
elif [ "$(id -u)" = "0" ] && [ "$bin_safe" = "0" ]; then
echo "# NOT running the plugin: its path contains characters that cannot be passed"
echo "# safely to su, and runuser is unavailable. Path: $bin"
elif [ "$(id -un 2>/dev/null)" = "$u" ]; then
echo "# running as: $u (already this user)"
run "$bin" debug 2>&1 >/dev/null
echo "# exit: $?"
else
echo "# NOT running the plugin: cannot become $u (not root, or no runuser/su)."
echo "# An access error from any other user would not reflect the agent."
fi
true' sh "${NETDATA_PID:-}" "${CMD_TIMEOUT:-10}" $PLUGINSD_DIRS
fi
# paths reach the child as positional ARGUMENTS - see the note above
# shellcheck disable=SC2086 # deliberate word-split of the discovered path list
collect_cmd 09-permissions/netdata-paths.txt "Modes, ownership, extended attributes, security contexts (SELinux/AppArmor) and POSIX ACLs for netdata's directories, configs and binary" sh -c '
if [ "$#" -eq 0 ]; then echo "(no netdata paths found on this host)"; exit 0; fi
has_selinux=0; [ -e /sys/fs/selinux ] && has_selinux=1
for p in "$@"; do
echo "===== $p ====="
ls -ld "$p" 2>/dev/null
stat -c "mode=%A(%a) owner=%U(%u) group=%G(%g) type=%F" "$p" 2>/dev/null \
|| stat -f "mode=%Sp(%Lp) owner=%Su group=%Sg" "$p" 2>/dev/null \
|| true
if [ "$has_selinux" = "1" ]; then
ls -ldZ "$p" 2>/dev/null || echo "(ls -Z unsupported)"
fi
if command -v getfattr >/dev/null 2>&1; then
# -m - includes the security.* and trusted.* namespaces, which is where
# SELinux labels and file capabilities actually live
a=$(getfattr -d -m - --absolute-names "$p" 2>/dev/null | grep -v "^#" | grep -v "^$" | head -20)
if [ -n "$a" ]; then echo "xattrs:"; printf "%s\n" "$a"; else echo "xattrs: (none set)"; fi
elif command -v xattr >/dev/null 2>&1; then
a=$(xattr -l "$p" 2>/dev/null | head -20)
if [ -n "$a" ]; then echo "xattrs (macOS):"; printf "%s\n" "$a"; else echo "xattrs: (none set)"; fi
elif command -v lsextattr >/dev/null 2>&1; then
a=$( { lsextattr -q system "$p" 2>/dev/null; lsextattr -q user "$p" 2>/dev/null; } | head -20)
if [ -n "$a" ]; then echo "xattrs (FreeBSD):"; printf "%s\n" "$a"; else echo "xattrs: (none set)"; fi
else
echo "xattrs: (getfattr/xattr/lsextattr absent - the checkable namespaces are reported below)"
fi
# Reported unconditionally: getfattr lives in the "attr" package, which is
# NOT installed by default on Debian/Ubuntu, and these cover the attributes
# that actually break netdata - security.capability (a dropped capability
# stops a plugin dead) and the ext2/3/4 immutable/append-only flags (which
# silently block the agent from writing its own state).
if command -v getcap >/dev/null 2>&1; then
c=$(getcap "$p" 2>/dev/null)
if [ -n "$c" ]; then echo "capabilities: $c"; else echo "capabilities: (none set)"; fi
else
echo "capabilities: (getcap not available)"
fi
if command -v lsattr >/dev/null 2>&1; then
# lsattr only works on ext-family filesystems; elsewhere it is silent.
# "e" (extent) is the ext4 default, so report only NON-default flags -
# an immutable ("i") or append-only ("a") flag is the interesting case.
l=$(lsattr -d "$p" 2>/dev/null | grep -vE "^-*e-* ")
[ -n "$l" ] && echo "file flags (non-default): $l"
fi
if command -v getfacl >/dev/null 2>&1; then
f=$(getfacl -p "$p" 2>/dev/null | grep -v "^#" | grep -v "^$" | head -20)
if [ -n "$f" ]; then echo "acl:"; printf "%s\n" "$f"; else echo "acl: (none)"; fi
else
echo "acl: (getfacl not available)"
fi
done
true' sh $_PERM_PATHS
}
# =============================================================================
# summary + manifest + README
# =============================================================================
write_summary() {
info "writing summary and manifest"
AGENT_VERSION=$(awk -F'"' '/"version"/{print $4; exit}' "$WORK/07-runtime/info-v1.json" 2>/dev/null)
[ -z "$AGENT_VERSION" ] && [ -n "${NETDATA_BIN:-}" ] && AGENT_VERSION=$("$NETDATA_BIN" -v 2>/dev/null | head -1)
CLAIMED="unknown"
for _aclkf in "$WORK/07-runtime/aclk-state.json" "$WORK/07-runtime/aclk.json"; do
[ -f "$_aclkf" ] || continue
if grep -q '"agent-claimed":true' "$_aclkf" 2>/dev/null; then CLAIMED="yes"; break; fi
if grep -q '"agent-claimed":false' "$_aclkf" 2>/dev/null; then CLAIMED="no"; break; fi
done
[ "$CLAIMED" = "unknown" ] && [ -f "$WORK/06-state/cloud-state.txt" ] && \
grep -qE '^[0-9a-f-]{36}' "$WORK/06-state/cloud-state.txt" && CLAIMED="yes"
ERRCOUNT=""
[ -f "$WORK/05-logs/error.log" ] && ERRCOUNT=$(grep -ci error "$WORK/05-logs/error.log" 2>/dev/null)
CRASH_HINT=""
[ -f "$WORK/06-state/status-file.json" ] && CRASH_HINT=$(awk -F'"' '/"exit_reason"|"cause"/{print $4}' "$WORK/06-state/status-file.json" 2>/dev/null | head -1)
{
echo "NETDATA SUPPORT BUNDLE SUMMARY"
echo "generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "tool version: $VERSION"
echo "runtime seconds: $(( $(now_s) - START_TS ))"
echo "ran as root: $([ "$(id -u)" = "0" ] && echo yes || echo no)"
echo "pii obfuscation: $([ "$OBFUSCATE" = "1" ] && echo on || echo OFF)"
[ -f "$WORK/04-config/stream.conf" ] && \
echo "streaming key: PRESENT VERBATIM in 04-config/stream.conf (by design - see README.md)"
echo
echo "agent version: ${AGENT_VERSION:-unknown}"
_agent_note=""
if [ -n "${NETDATA_PID:-}" ] && [ "$IS_CONTAINER" = "0" ] && [ -z "$CONFDIR" ] && \
grep -qE 'docker|containerd|kubepods|lxc' "/proc/$NETDATA_PID/cgroup" 2>/dev/null; then
_agent_note=" (process appears to run INSIDE a container; no local install found on this host)"
fi
echo "agent running: $([ -n "${NETDATA_PID:-}" ] && echo "yes (pid $NETDATA_PID)$_agent_note" || echo NO)"
if [ -z "${NETDATA_PID:-}" ] && grep -q '"status":"running"' "$WORK/06-state/status-file.json" 2>/dev/null; then
echo "WARNING: status file still says 'running' but no netdata process exists -"
echo " unclean termination (SIGKILL / OOM kill / power loss); the agent"
echo " could not update the file at death. Check 01-system/kernel-messages.txt."
fi
echo "agent api: $([ "$api_ok" = "1" ] && echo reachable || echo UNREACHABLE)"
echo "container: $([ "$IS_CONTAINER" = "1" ] && echo yes || echo no)"
echo "config dir: ${CONFDIR:-NOT FOUND}"
echo "claimed to cloud: $CLAIMED"
[ -n "$CRASH_HINT" ] && echo "last exit reason: $CRASH_HINT <-- check 06-state/status-file.json"
[ -n "$ERRCOUNT" ] && echo "error.log 'error' lines: $ERRCOUNT"
[ "${DOCKER_LOGS_NEEDED:-0}" = "1" ] && echo "NOTE: agent log HISTORY is not in this bundle - it lives in 'docker logs' on the host (see 05-logs/LOGS-ARE-IN-DOCKER.txt)"
echo
echo "SNMP diagnostics: $SNMP_STATUS ($SNMP_FILES raw files; UNSANITIZED when included)"
echo "READ ORDER FOR TRIAGE:"
echo " SNMP issues -> 06-state/snmp-diagnostics-status.txt, 06-state/snmp-diagnostics/"
echo " crashes/won't start -> 06-state/status-file.json, 05-logs/, 01-system/kernel-messages.txt"
echo " collector issues -> 07-runtime/dyncfg-tree.json (job states), 05-logs/collector.log, 04-config/go.d*, 09-permissions/plugins-d.txt"
echo " streaming issues -> 04-config/stream.conf, 07-runtime/node-instances.json, 01-system/clock-timesync.txt"
echo " cloud/claiming -> 06-state/cloud-state.txt, 07-runtime/aclk-state.json, 08-network/"
echo " performance -> 03-process/threads-cpu.txt, 06-state/db-disk-usage.txt, 07-runtime/node-instances.json"
echo " permission denied -> 09-permissions/ (modes, capabilities, xattrs, SELinux/AppArmor, ACLs)"
} > "$WORK/summary.txt"
manifest_add summary.txt file generated "Human summary"
}
write_readme() {
cat > "$WORK/README.md" <<'EOF'
# Netdata Support Bundle
Generated by `netdata-support-bundle`. Standard captures are SANITIZED:
secrets (tokens, api keys, passwords) are redacted; by default IPs,
MACs, emails and hostnames are replaced with stable pseudonyms (`ip-1`,
`redacted-host`, `[EMAIL]`, `[MAC]`) - consistent across standard captures, so
cross-referencing still works. The pseudonym map stays on the user's machine,
next to the tarball - it is NOT in this bundle.
**Streaming-key exception:** the **streaming API key** is kept VERBATIM in
`04-config/stream.conf` (both `api key` values and `[<API_KEY>]` section
headers). It is the value support needs to tell whether a child and its parent
agree, so it is not treated as a secret. If your threat model differs, remove or
mask it in `04-config/stream.conf` before sending the bundle. Note the same key
IS still redacted in `05-logs/access.log`, where it also appears.
**Optional raw SNMP evidence:** `06-state/snmp-diagnostics/`, when included,
is UNSANITIZED: no secret redaction or PII obfuscation. It preserves device
responses, inventory and metric values. Share this bundle through a restricted
support channel, never a public issue. See `06-state/snmp-diagnostics-status.txt` for
missing files or copy failures; the directory is not one simultaneous snapshot.
Collected text files keep their original bytes: a byte-order mark, CRLF or CR line
endings and a missing final newline all survive redaction, so an encoding fault
in a config file is still visible here.
## Layout (triage order)
| dir | contents |
|---|---|
| `summary.txt` | one-page overview - start here |
| `MANIFEST.json` | machine-readable index of every file (origin, size, sanitization) |
| `01-system/` | OS, kernel, memory, disks, virtualization, clock sync, OOM/segfault evidence |
| `02-install/` | install method, packages, .environment, container context |
| `03-process/` | netdata process tree, per-thread CPU, limits, fds, environment |
| `04-config/` | effective (running) config + every user-customized config file |
| `05-logs/` | journal + agent log files (window-capped), updater log, coredump metadata |
| `06-state/` | daemon status file (crash record), state/db disk usage, claim state |
| `07-runtime/` | live agent state: API captures (info, node instances, alerts), netdatacli captures (dyncfg tree, aclk state), buildinfo |
| `08-network/` | Netdata process-tree socket inventory, DNS, proxy, Netdata Cloud reachability |
| `09-permissions/` | modes, ownership, setuid bits, file capabilities, extended attributes, SELinux/AppArmor contexts and ACLs - including every `plugins.d` |
## Conventions
- Command captures (`*.txt`) begin with a `# netdata-support-bundle | command: ...`
provenance header and end with `# exit: N`.
- Copied files (configs, logs, json) are pristine (no injected headers);
their origin is recorded in `MANIFEST.json`.
- `07-runtime/AGENT-WAS-DOWN.txt` exists when the local API probe failed (the
agent may be down, or its API bound away from 127.0.0.1 / bearer-protected).
netdatacli captures such as `dyncfg-tree.json` do not need the API and can
still be present next to it.
EOF
manifest_add README.md file generated "Bundle documentation"
}
write_manifest() {
# emit MANIFEST.json LAST so every file (incl. summary.txt and README.md) is indexed
{
echo '{'
echo ' "schema": "netdata-support-bundle/v2",'
echo " \"tool_version\": \"$VERSION\","
echo " \"generated_utc\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
echo " \"runtime_seconds\": $(( $(now_s) - START_TS )),"
echo " \"pii_obfuscated\": $([ "$OBFUSCATE" = "1" ] && [ "$SNMP_FILES" = "0" ] && echo true || echo false),"
echo " \"secrets_redacted\": $([ "$SNMP_FILES" = "0" ] && echo true || echo false),"
echo " \"snmp_diagnostics\": {\"requested\": $([ "$INCLUDE_SNMP" = "1" ] && echo true || echo false), \"status\": \"$SNMP_STATUS\", \"files\": $SNMP_FILES},"
# the standard-capture exception to secrets_redacted (netdata/netdata#23448)
echo " \"streaming_api_key_redacted\": false,"
echo " \"agent_running\": $([ -n "${NETDATA_PID:-}" ] && echo true || echo false),"
echo " \"agent_api_reachable\": $([ "$api_ok" = "1" ] && echo true || echo false),"
echo " \"is_container\": $([ "$IS_CONTAINER" = "1" ] && echo true || echo false),"
echo ' "files": ['
sed '$!s/$/,/' "$MANIFEST_ROWS" | sed 's/^/ /'
echo ' ]'
echo '}'
} > "$WORK/MANIFEST.json"
}
# =============================================================================
# tarball
# =============================================================================
publish_bundle() {
mkdir -p "$OUTDIR"
# zstd compresses faster and smaller than gzip on this kind of text-heavy data;
# use it when the tar build supports --zstd, else a zstd pipe, else gzip
if command -v zstd >/dev/null 2>&1 && tar --zstd -cf /dev/null -T /dev/null 2>/dev/null; then
_ext="tar.zst"; _mode="tar-zstd"
elif command -v zstd >/dev/null 2>&1; then
_ext="tar.zst"; _mode="zstd-pipe"
else
_ext="tar.gz"; _mode="gzip"
fi
TARBALL="$OUTDIR/$BUNDLE.$_ext"
# build inside the 0700 staging dir, then publish with O_EXCL (set -C) so a
# pre-existing file OR symlink planted in a shared tmp dir can never be
# followed or overwritten (no check/open TOCTOU window)
# anonymize archive owner/group so a non-root user's account isn't in tar headers
_towner=""
if tar --owner=0 --group=0 -cf /dev/null -T /dev/null 2>/dev/null; then
_towner="--owner=0 --group=0 --numeric-owner"
fi
_tarok=0
# shellcheck disable=SC2086 # $_towner intentionally splits into separate tar flags
case "$_mode" in
tar-zstd) ( cd "$STAGING" && tar $_towner --zstd -cf "$STAGING/bundle.$_ext" "$BUNDLE" ) && _tarok=1 ;;
zstd-pipe)
# POSIX pipelines hide tar failures behind a successful compressor.
( cd "$STAGING" || exit 1
{ tar $_towner -cf - "$BUNDLE"; echo "$?" > "$STAGING/tar.rc"; } |
zstd -q -o "$STAGING/bundle.$_ext"
) && [ "$(cat "$STAGING/tar.rc" 2>/dev/null)" = 0 ] && _tarok=1
;;
gzip) ( cd "$STAGING" && tar $_towner -czf "$STAGING/bundle.$_ext" "$BUNDLE" ) && _tarok=1 ;;
*) : ;;
esac
if [ "$_tarok" != "1" ]; then
echo "failed to create tarball" >&2; exit 1
fi
if ! ( set -C; cat "$STAGING/bundle.$_ext" > "$TARBALL" ) 2>/dev/null; then
echo "refusing to write $TARBALL (a file or symlink already exists there)" >&2
exit 1
fi
if [ "$OBFUSCATE" = "1" ] && [ -s "$MAP_FILE" ]; then
MAP_OUT="$OUTDIR/$BUNDLE.pseudonym-map.tsv"
if ! ( set -C; cat "$MAP_FILE" > "$MAP_OUT" ) 2>/dev/null; then
MAP_OUT="$OUTDIR/$BUNDLE.pseudonym-map.$$.tsv"
if ! ( set -C; cat "$MAP_FILE" > "$MAP_OUT" ) 2>/dev/null; then
info "WARNING: could not write the pseudonym map next to the bundle - it was DISCARDED; rerun with --keep-staging if you need it"
MAP_OUT=""
fi
fi
fi
TOTAL_S=$(( $(now_s) - START_TS ))
SIZE=$(du -h "$TARBALL" 2>/dev/null | cut -f1)
echo >&2
info "done in ${TOTAL_S}s"
info "bundle: $TARBALL ($SIZE)"
[ "$OBFUSCATE" = "1" ] && [ -n "${MAP_OUT:-}" ] && info "pseudonym map (KEEP PRIVATE, do not send): $MAP_OUT"
if [ "$_ext" = "tar.zst" ]; then
info "review it: tar --zstd -tf $TARBALL (or: zstd -dc $TARBALL | tar -tf -)"
else
info "review it: tar -tzf $TARBALL"
fi
if [ "${DOCKER_LOGS_NEEDED:-0}" = "1" ]; then
info "IMPORTANT: this agent logs to the container's stdout - its log history is NOT in this bundle."
info "follow 05-logs/LOGS-ARE-IN-DOCKER.txt to capture, review and redact host logs before sharing."
fi
info "attach the bundle to your support ticket."
}
# Collector helper scratch variables are function-prefixed. Uppercase variables,
# api_ok and have_timeout are shared run state; CAPTURE_* are capture results.
# Tests source the definitions and call initialization/collectors on private fixtures.
main() {
set -u
demote "$@"
init_defaults
parse_options "$@"
init_staging
detect_timeout
init_sanitizer_context
[ "$SELFTEST" = "1" ] && run_selftest
discover_environment
collect_system
collect_install
collect_process
collect_config
collect_logs
collect_state
collect_runtime
collect_network
collect_permissions
write_summary
write_readme
write_manifest
publish_bundle
}
if [ "${ND_SUPPORT_BUNDLE_SOURCE_ONLY:-0}" != "1" ]; then
main "$@"
fi