Skip to content

Backup Container Information & Change Log

Just like the NGINX reverse proxy backup script, I decided to take a snapshot of each container's DNA and get some metadata from it, then upload it here. I often create/destroy containers trying out new things, and sometimes I implement changes to containers, but if I don't log the changes somewhere, I'll completely forget about them. So, it's also got a change log upload.

Features

  • Creates a hash of each container and stores it to help track changes
  • Puts the DNA map in one chapter, and the change log in another
  • Pulls metadata:
    • RAM Assigned
    • CPU Cores
    • Bridge
    • IP Addresses
    • OS
    • Disk Usage
    • Users with Shells
    • SSH Status
    • Lists Available Updates
  • Prints a list of services installed (I picked the most commonly used in my containers):
    • NGINX, APACHE, PHP
    • MariaDB/MySQL, PostgreSQL
    • Redis
    • Node.js
    • Python
    • Java
    • Fail2Ban
    • UFW
    • ZFS
    • Podman
    • Snap
    • Flatpack
    • Mosquitto
    • Rsync
    • Rclond
    • Certbot

Script

#!/bin/bash

# === CONFIG ===
BOOKSTACK_URL="https://YOUR SITE HERE/api"
BOOK_ID=31                 # UPDATE THIS!
CHAPTER_ID_MAPS=157        # UPDATE THIS!
CHAPTER_ID_CHANGES=156     # UPDATE THIS!
TOKEN_ID="YOUR TOKEN HERE"
TOKEN_SECRET="YOUR SECRET HERE"
HASH_DIR="$HOME/.lxc-dna-tracker"

mkdir -p "$HASH_DIR"

dtg=$(date "+%d%b%y %H%M" | tr 'a-z' 'A-Z')
CHANGE_LOG_HTML="<h2>🧬 Container Change Log - $dtg</h2><ul>"
any_changes=false

echo "🧾 Starting LXC DNA Audit..."

for CTID in $(pct list | awk 'NR>1 {print $1}'); do
    HOSTNAME=$(pct exec $CTID -- hostname 2>/dev/null | xargs)
    CT_BASENAME=$(echo "$HOSTNAME" | tr ' ' '_' | tr '[:upper:]' '[:lower:]')
    HASH_FILE="$HASH_DIR/$CT_BASENAME.sha256"
    REPORT_FILE=$(mktemp)

    echo "📦 Auditing CTID $CTID ($HOSTNAME)..."

    # === GENERATE DNA REPORT HTML ===
    {
        echo "<h2>📦 $HOSTNAME (CTID $CTID)</h2><p><strong>Audit Timestamp:</strong> $dtg</p>"

        echo "<h3>📊 Configuration</h3><table style=\"border: none; border-collapse: collapse; width: 100%;\">"
        echo "<tr><td style=\"border: none; padding: 4px;\">RAM Assigned</td><td style=\"border: none; padding: 4px;\">$(pct config $CTID | grep memory | awk '{print $2}') MB</td></tr>"
        echo "<tr><td style=\"border: none; padding: 4px;\">CPU Cores</td><td style=\"border: none; padding: 4px;\">$(pct config $CTID | grep cores | awk '{print $2}')</td></tr>"
        echo "<tr><td style=\"border: none; padding: 4px;\">Bridge</td><td style=\"border: none; padding: 4px;\">$(pct config $CTID | grep net0 | grep -oP 'bridge=\K[^, ]+')</td></tr>"
        echo "<tr><td style=\"border: none; padding: 4px;\">IP Address</td><td style=\"border: none; padding: 4px;\">$(pct exec $CTID -- hostname -I 2>/dev/null)</td></tr>"
        echo "<tr><td style=\"border: none; padding: 4px;\">OS</td><td style=\"border: none; padding: 4px;\">$(pct exec $CTID -- grep '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '\"')</td></tr>"
        echo "</table>"

        echo "<h3>💾 Disk Usage</h3>$(pct exec $CTID -- df -h /)"

        echo "<h3>🔒 Users with Shells</h3>$(pct exec $CTID -- getent passwd | grep '/bin/bash')"

        echo "<h3>🔐 SSH Status</h3>$(pct exec $CTID -- systemctl is-active ssh 2>/dev/null || echo 'Not Running')"

        echo "<h3>📥 Available Package Updates</h3>$(pct exec $CTID -- bash -c 'apt list --upgradable 2>/dev/null | grep -v \"Listing...\"' || echo 'N/A')"

        echo "<h3>🔍 Services</h3><table style=\"border: none; border-collapse: collapse; width: 100%;\">"

        check() {
            local label=$1
            local cmd=$2
            local extract=$3
            local value
            if pct exec $CTID -- which $cmd > /dev/null 2>&1; then
                value=$(pct exec $CTID -- bash -c "$cmd $extract" 2>&1 | head -n1)
                echo "<tr><td style=\"border: none; padding: 4px;\">$label</td><td style=\"border: none; padding: 4px;\">$value</td></tr>"
            else
                echo "<tr><td style=\"border: none; padding: 4px;\">$label</td><td style=\"border: none; padding: 4px;\">❌ Not installed</td></tr>"
            fi
        }

        check "🌍 Nginx" "nginx" "-v"
        check "🌍 Apache" "apache2" "-v"
        check "🐘 PHP" "php" "-v"
        check "🐬 MariaDB/MySQL" "mysql" "--version"
        check "🐘 PostgreSQL" "psql" "--version"
        check "📦 Redis" "redis-server" "--version"
        check "🟢 Node.js" "node" "-v"
        check "🐍 Python3" "python3" "--version"
        check "☕ Java" "java" "-version"
        check "🔒 Fail2Ban" "fail2ban-server" "--version"
        check "🔥 UFW" "ufw" "--version"
        check "🧰 ZFS" "zpool" "--version"
        check "🚢 Podman" "podman" "--version"
        check "📦 Snap" "snap" "--version"
        check "📦 Flatpak" "flatpak" "--version"
        check "🧵 Mosquitto" "mosquitto" "-h"
        check "🗄️ Rsync" "rsync" "--version"
        check "🌎 Rclone" "rclone" "--version"
        check "🛡️ Certbot" "certbot" "--version"

        echo "</table>"
    } > "$REPORT_FILE"

    # === HASH + VERSION ===
    REPORT_HASH=$(sha256sum "$REPORT_FILE" | awk '{print $1}')
    version=$(ls "$HASH_DIR/${CT_BASENAME}_v"*.sha256 2>/dev/null | wc -l)
    version=$((version + 1))
    cp "$REPORT_FILE" "$HASH_DIR/${CT_BASENAME}_v${version}.sha256"
    PAGE_NAME="$CTID $HOSTNAME Ver $version $dtg"

    # === NEW CONTAINER ===
    if [[ ! -f "$HASH_FILE" ]]; then
        echo "🆕 New container: $HOSTNAME → Adding to Container Maps"
        json_payload=$(jq -n \
          --arg name "$PAGE_NAME" \
          --arg html "$(cat "$REPORT_FILE")" \
          --argjson book_id "$BOOK_ID" \
          --argjson chapter_id "$CHAPTER_ID_MAPS" \
          '{name: $name, book_id: $book_id, chapter_id: $chapter_id, html: $html}')

        curl -s -X POST "$BOOKSTACK_URL/pages" \
          -H "Authorization: Token $TOKEN_ID:$TOKEN_SECRET" \
          -H "Content-Type: application/json" \
          -d "$json_payload" >/dev/null

        echo "$REPORT_HASH" > "$HASH_FILE"

    # === CONTAINER CHANGED ===
    elif [[ "$REPORT_HASH" != "$(cat "$HASH_FILE")" ]]; then
        echo "🔄 Change: $HOSTNAME"
        any_changes=true
        echo "$REPORT_HASH" > "$HASH_FILE"
        CHANGE_LOG_HTML+="<li><strong>$CTID $HOSTNAME Ver $version</strong>$(cat "$REPORT_FILE")</li>"
    else
        echo "✅ No change for $HOSTNAME"
    fi

    rm "$REPORT_FILE"
done

CHANGE_LOG_HTML+="</ul>"

# === UPLOAD CHANGE LOG IF NEEDED ===
if $any_changes; then
    echo "📤 Uploading change log..."
    json_payload=$(jq -n \
      --arg name "DNA Change Log $dtg" \
      --arg html "$CHANGE_LOG_HTML" \
      --argjson book_id "$BOOK_ID" \
      --argjson chapter_id "$CHAPTER_ID_CHANGES" \
      '{name: $name, book_id: $book_id, chapter_id: $chapter_id, html: $html}')

    result=$(curl -s -X POST "$BOOKSTACK_URL/pages" \
      -H "Authorization: Token $TOKEN_ID:$TOKEN_SECRET" \
      -H "Content-Type: application/json" \
      -d "$json_payload")

    echo "📬 BookStack response:"
    echo "$result"
else
    echo "🛑 No container changes. Nothing uploaded."
fi