#!/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
#   --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
  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 ;;
      --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;
    } }'
}
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
}

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

  # 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"
    for gjs in "$LIBDIR"/god-jobs-statuses.json "$LIBDIR"/*jobs-statuses*.json; do
      [ -f "$gjs" ] && { collect_file 06-state/go.d-job-statuses.json "go.d collector job states (which jobs run/fail)" "$gjs"; break; }
    done
    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 (only when the agent responds)
# =============================================================================
collect_runtime() {
  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_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"
  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 command -v netdatacli >/dev/null 2>&1 && [ -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=""
  for _p in "$CONFDIR" "$CONFDIR/netdata.conf" "$CONFDIR/stream.conf" "$CONFDIR/ssl" \
            "$LOGDIR" "$LIBDIR" "$LIBDIR/cloud.d" "$LIBDIR/registry" "$CACHEDIR" \
            $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# }"

  # 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    -> 04-config/go.d*, 05-logs/collector.log, 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 API captures: info, node instances, alerts, 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).
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
