File: //bigscoots/includes/common.sh
#!/bin/bash
#
# Common Variables
#
PATH=/usr/lib64/ccache:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin:/root/bin:/root/.local/bin
# Grab the server IP
serverip=$(ip route get 1 | grep -oP 'src \K[0-9.]+')
# Set default SSH options
SSH_OPTIONS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o BatchMode=yes -q)
#
# Common Functions
#
# Function name: wpcli
# Purpose: This function wraps the 'wp' command, which is used for managing WordPress installations from the command line.
# The function adds a series of flags to the 'wp' command to modify its behavior in specific ways.
function wpcli {
# WPCLIFLAGS is a variable that contains several flags that will be used with every 'wp' command:
# --allow-root: This allows 'wp' commands to be run as the root user.
# --skip-plugins: This prevents plugins from being loaded when executing 'wp' commands.
# --skip-themes: This prevents themes from being loaded when executing 'wp' commands.
# --require=/bigscoots/includes/err_report.php: This causes 'wp' to load the err_report.php file before running any 'wp' commands.
# The err_report.php file can contain any PHP code, which is typically used to set up environment variables or define helper functions.
WPCLIFLAGS=(--allow-root --skip-plugins --skip-themes --require=/bigscoots/includes/err_report.php)
# This line runs the 'wp' command with the flags defined in WPCLIFLAGS, followed by any arguments passed to the wpcli function.
# For example, if you run 'wpcli plugin activate my-plugin', this line will run 'wp --allow-root --skip-plugins --skip-themes --require=/bigscoots/includes/err_report.php plugin activate my-plugin'.
wp "${WPCLIFLAGS[@]}" "$@"
}
n_wpcli() {
[ -f /bin/wp ] && chmod 755 /bin/wp
[ -f /usr/bin/wp ] && chmod 755 /usr/bin/wp
su -s /bin/bash -l nginx -c "source /bigscoots/includes/common.sh && wpcli $*"
}
n_wp() {
[ -f /bin/wp ] && chmod 755 /bin/wp
[ -f /usr/bin/wp ] && chmod 755 /usr/bin/wp
su -s /bin/bash -l nginx -c "source /bigscoots/includes/common.sh && wp $*"
}
# Function name: validate_domain
# Purpose: This function is used to validate the format of a domain name.
validate_domain() {
# We store the first argument passed to the function in a local variable 'domain'.
local domain="$1"
local script="$2"
# Here, we check whether the 'domain' variable is empty or does not match the regular expression.
# The regular expression checks if the domain name contains only alphanumeric characters, hyphens, periods, and at least one dot (.) character.
# The domain name should also end with a sequence of alphabetic characters (a-z or A-Z), signifying a valid top-level domain (e.g., .com, .net, .org).
if [[ -z "$domain" || ! "$domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]+$ ]]
then
# If the domain name is either empty or doesn't match the regular expression, we echo a JSON response with a "status" of "fail" and an appropriate error message.
echo "{\"status\":\"fail\",\"msg\":\"Invalid domain name format. A valid domain name should contain a dot (.) and consist of alphanumeric characters, hyphens, and periods.\"}"
# After displaying the error message, we exit the function with a non-zero status code (1), indicating that an error occurred.
exit 1
fi
# Check if the domain starts with "www."
if [[ "$domain" == www.* ]]
then
send_slack_alert "#wpo-fail" ":x:" "Domain Validation" "$domain" "Domain starts with www. which means this should not exist in /home/nginx/domains/ \n *Script:* $script"
exit 1
fi
}
remove_ocsp_directives() {
local conf_dir="/usr/local/nginx/conf/conf.d"
local dry_run="${1:-false}"
local files_modified=0
local files_skipped=0
echo "=== OCSP/Resolver Directive Removal ==="
echo "Directory: $conf_dir"
echo ""
for conf_file in "$conf_dir"/*.conf; do
# Skip if no files match
[[ -e "$conf_file" ]] || { echo "No .conf files found in $conf_dir"; return 1; }
# Check if file actually contains any of the directives
if ! grep -qE '^\s*(resolver|resolver_timeout|ssl_stapling|ssl_stapling_verify)\s' "$conf_file"; then
echo "[SKIP] $conf_file (no matching directives found)"
((files_skipped++))
continue
fi
echo "[FOUND] $conf_file"
# Show what will be removed
grep -nE '^\s*(resolver|resolver_timeout|ssl_stapling|ssl_stapling_verify)\s' "$conf_file" | \
while IFS= read -r line; do
echo " Line $line"
done
if [[ "$dry_run" == "true" ]]; then
echo " [DRY-RUN] No changes made"
else
# Backup original
cp "$conf_file" "${conf_file}.bak"
# Remove the directives
sed -i '/^\s*resolver\s/d;
/^\s*resolver_timeout\s/d;
/^\s*ssl_stapling\s/d;
/^\s*ssl_stapling_verify\s/d' "$conf_file"
echo " [DONE] Backup saved to ${conf_file}.bak"
((files_modified++))
fi
echo ""
done
echo "=== Summary ==="
if [[ "$dry_run" == "true" ]]; then
echo "Mode: DRY-RUN (no files were modified)"
else
echo "Modified: $files_modified file(s)"
fi
echo "Skipped: $files_skipped file(s) (no matching directives)"
echo ""
# Validate nginx config if changes were made
if [[ "$dry_run" != "true" && $files_modified -gt 0 ]]; then
echo "=== Nginx Config Test ==="
if nginx -t 2>&1; then
echo ""
echo "Config OK β run 'systemctl reload nginx' to apply changes."
else
echo ""
echo "WARNING: nginx -t failed! Review the errors above."
echo "Backups are available at each modified file path with .bak extension."
fi
fi
}
validate_domain_in_path() {
local domain="$1"
local script="$2"
local error_msg=""
# 1. Check if the domain is empty or does not match regex
if [[ -z "$domain" || ! "$domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]+$ ]]; then
error_msg="Domain is empty or invalid format"
# 2. Check if the domain starts with "www."
elif [[ "$domain" == www.* ]]; then
error_msg="Domain starts with www (should be root domain)"
# 3. Check if the directory exists
elif [ ! -d "/home/nginx/domains/$domain/public" ]; then
error_msg="Public directory not found on server"
fi
# --- FAILURE HANDLER ---
if [[ -n "$error_msg" ]]; then
# 1. Send Slack Alert (Preserved your existing logic)
send_slack_alert "#wpo-fail" ":x:" "Domain Validation" "$domain" "${error_msg}. \n *Script:* $script"
# 2. Output JSON Failure
# We escape quotes in $domain and $error_msg to ensure valid JSON
printf '{"status": "failure", "domain": "%s", "error": "%s"}\n' "$domain" "$error_msg"
return 1
fi
# Optional: If you need a success JSON, uncomment the line below
# printf '{"status": "success", "domain": "%s", "error": null}\n' "$domain"
return 0
}
# ==============================================================================
# SLACK NOTIFICATIONS (via n8n/Saumya)
# ==============================================================================
# Usage:
# 1. Initial Message: THREAD_ID=$(send_slack_initial "Main Alert Text" "#channel")
# 2. Thread Reply: send_slack_thread "$THREAD_ID" "Reply Text" "#channel"
#
# Note: #channel is optional (defaults to #alerts).
# send_slack_initial returns the parent_msg_id needed for threading.
# ==============================================================================
source /bigscoots/includes/slack_functions.sh
# Function name: send_slack_alert
# Purpose: This function is used to send a Slack alert with customizable parameters
send_slack_alert() {
# Declare local variables for the arguments
# channel: The Slack channel to send the alert to
local channel="$1"
# emoji: The emoji to include in the alert
local emoji="$2"
# tag: A tag to include in the alert, typically to signify the source or type of alert
local tag="$3"
# Domain: Domain related to the error, may not always be included.
local domain="$4"
# message: The main content of the alert
local message=$(echo "$5" | sed ':a;N;$!ba;s/\n/\\n/g')
# Call the slack.sh script with the constructed message.
# This script is assumed to handle the sending of the message to Slack.
# It is passed the channel and a constructed message that includes
# the emoji, tag, hostname of the local machine, server IP, and the main message content.
bash /bigscoots/general/slack.sh "${channel}" "${emoji} *${tag}*\n*Hostname:* $(hostname)\n *Server IP:* ${serverip}\n *Domain:* ${domain}\n *Message:* ${message}"
# Example usage:
# send_slack_alert "#team-chat" ":smile:" "Error(Appears next to Emoji)" "bigscoots.com" "server on fire"
}
check_virt() {
# 1. Is it LXD?
# (Check for the LXD device folder or the agent mount)
if [ -d /dev/lxd ] || [ -d /dev/.lxd-mounts ] || [ -d /run/lxd_agent ]; then
# Now distinguish between VM and Container
if [ -d /run/lxd_agent ] || grep -q "/dev/sd" /proc/mounts; then
echo "lxd-virtual-machine"
else
echo "lxd-container"
fi
return
fi
# 2. Is it OpenVZ?
if [ -f /proc/user_beancounters ] || [ -d /proc/vz ] || grep -q "ploop" /proc/mounts; then
echo "openvz"
return
fi
# 3. Fallback to Systemd (Bare Metal will return 'none')
if command -v systemd-detect-virt >/dev/null 2>&1; then
local res=$(systemd-detect-virt)
if [ "$res" = "none" ]; then
echo "baremetal"
else
echo "$res"
fi
else
echo "unknown"
fi
}
# Function name: correct_permissions_ownership
# Purpose: This function is used to correct the ownership and permissions of files and directories
correct_permissions_ownership() {
(
set +m
local domain="${1:-*}"
# Remove the quotes from the search_path definition so the shell expands the wildcard
local search_path=/home/nginx/domains/$domain/public
# Check if the path actually exists before running
if ls $search_path >/dev/null 2>&1; then
# Use the expanded path directly
nohup setsid ionice -c 3 find $search_path \
\( \! -user nginx -exec chown nginx: {} + \) , \
\( -type f \! -perm 644 -exec chmod 644 {} + \) , \
\( -type d \! -perm 755 -exec chmod 755 {} + \) > /dev/null 2>&1 &
fi
)
}
skip_all_plugins_except() {
local noskip_plugin="$1"
local skipped_plugins=""
local formatted_skipped_plugins=""
# Build base command
local cmd=(wpcli plugin list --field=name)
# Only add --path if $domain is set
if [ -n "$domain" ]; then
cmd+=(--path="/home/nginx/domains/${domain}/public")
fi
# Run the command, filter out the plugin to keep, strip "/plugin-file.php" if present
skipped_plugins=$("${cmd[@]}" 2>/dev/null | grep -v "${noskip_plugin}" | sed 's:/.*::')
# Format as comma-separated
formatted_skipped_plugins=$(echo "$skipped_plugins" | paste -sd,)
echo "${formatted_skipped_plugins}"
}
# Function to start the timer
start_timer() {
START_TIME=$(date +%s)
START_TIME_READABLE=$(date)
}
# Function to end the timer and display the elapsed time
end_timer() {
END_TIME=$(date +%s)
END_TIME_READABLE=$(date)
echo "Task started at: $START_TIME_READABLE"
echo "Task ended at: $END_TIME_READABLE"
ELAPSED_TIME=$((END_TIME - START_TIME))
ELAPSED_TIME_READABLE=$(date -u -d @"$ELAPSED_TIME" +'%T')
echo "Total elapsed time: $ELAPSED_TIME seconds ($ELAPSED_TIME_READABLE)"
}
ngxreload_t() {
local message="" noexit=false
# parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--noexit) noexit=true ;;
*) message="$1" ;;
esac
shift
done
local lineno="${BASH_LINENO[0]}"
local caller_file="${BASH_SOURCE[1]}"
if nginx -t > /dev/null 2>&1; then
ngxreload > /dev/null 2>&1
else
send_slack_alert "#team-chat" ":warning:" "nginx config fail" "N/A" \
"$message\n *Script throwing error:* \`${caller_file}\` *Line:* \`${lineno}\`"
if $noexit; then
return 1 # fail, but donβt exit the whole script
else
exit 1
fi
fi
}
centmin_upgrade_124() {
if ! grep -q ^132. /etc/centminmod-release > /dev/null 2>&1
then
(
cmupdate > /dev/null 2>&1 &&
cmupdate update-stable > /dev/null 2>&1 &&
pushd /usr/local/src/centminmod > /dev/null 2>&1 &&
expect /bigscoots/wpo/manage/expect/centmin > /dev/null 2>&1
) || send_slack_alert "#wpo-fail" ":x:" "Centmin Upgrade" "NA" "Failed at step $?"
fi
}
fix_mariadb103_repo() {
# Handle MariaDB 10.1 β alert and exit early
if grep -q "10\.1\." /etc/yum.repos.d/mariadb.repo; then
yum-config-manager --save --setopt=mariadb.skip_if_unavailable=true > /dev/null 2>&1
send_slack_alert "#wpo-fail" ":warning:" "mariadb-version" "NA" "I am running MariaDB10.1 please upgrade."
return
fi
# Convert CentminMod-style MariaDB 10.3 CentOS 7 repo to archive
if grep -q "http://yum.mariadb.org/10.3/centos7-amd64" /etc/yum.repos.d/mariadb.repo; then
sed -i 's|http://yum.mariadb.org/10.3/centos7-amd64|https://archive.mariadb.org/mariadb-10.3/yum/centos7-amd64|' /etc/yum.repos.d/mariadb.repo
sed -i 's|https://yum.mariadb.org/RPM-GPG-KEY-MariaDB|https://archive.mariadb.org/PublicKey|' /etc/yum.repos.d/mariadb.repo
yum -q clean all
fi
# Convert CentminMod-style MariaDB 10.3 CentOS 8 repo to archive
if grep -q "http://yum.mariadb.org/10.3/centos8-amd64" /etc/yum.repos.d/mariadb.repo; then
sed -i 's|http://yum.mariadb.org/10.3/centos8-amd64|https://archive.mariadb.org/mariadb-10.3/yum/centos8-amd64|' /etc/yum.repos.d/mariadb.repo
sed -i 's|https://yum.mariadb.org/RPM-GPG-KEY-MariaDB|https://archive.mariadb.org/PublicKey|' /etc/yum.repos.d/mariadb.repo
yum -q clean all
fi
# Fix legacy Rackspace mirror usage
if grep -q "mirror.rackspace.com/mariadb/yum/10.3" /etc/yum.repos.d/mariadb.repo; then
cat > /etc/yum.repos.d/mariadb.repo <<EOF
[mariadb]
name = MariaDB
baseurl = https://archive.mariadb.org/mariadb-10.3/yum/centos7-amd64
gpgkey = https://supplychain.mariadb.com/MariaDB-Server-GPG-KEY
gpgcheck = 1
exclude = MariaDB-Galera-server
EOF
yum -q clean all
fi
# Source OS info
. /etc/os-release
# Replace deprecated dlm.mariadb.com repo for CentOS 7
if [[ "$ID" == "centos" && "$VERSION_ID" == "7" ]] && \
grep -q "dlm.mariadb.com/repo/mariadb-server/10.6" /etc/yum.repos.d/mariadb.repo; then
cat > /etc/yum.repos.d/mariadb.repo <<EOF
[mariadb]
name = MariaDB
baseurl = https://archive.mariadb.org/mariadb-10.6/yum/centos7-amd64
gpgkey=https://supplychain.mariadb.com/MariaDB-Server-GPG-KEY
gpgcheck=1
exclude=MariaDB-Galera-server
skip_if_unavailable = 1
keepcache = 0
EOF
fi
}
wpcli_update() {
# --- Pre-check: Nuke and reinstall if wp binary is missing or empty ---
local wp_bin
wp_bin=$(type -P wp 2>/dev/null)
if [ -z "$wp_bin" ] || [ ! -s "$wp_bin" ] || [ ! -s /usr/bin/wp ]; then
rm -f /usr/local/bin/wp /usr/bin/wp /bin/wp
if bash /usr/local/src/centminmod/addons/wpcli.sh install > /dev/null 2>&1; then
ln -fs /bin/wp /usr/local/bin/wp
chmod 775 /bin/wp /usr/local/bin/wp
else
send_slack_alert "#wpo-fail" ":warning:" "wp cli reinstall failed (empty binary)" "NA" "Binary was empty or missing. Reinstall via \`wpcli.sh install\` also failed."
return 1
fi
fi
current_wpcli_version=$(wp cli version --allow-root 2>/dev/null | awk '{print $2}')
mariadb_version=$(mysql -V | grep -oP 'Distrib\s+\K10\.3')
# --- Part 1: Handle Main WP-CLI Binary (Update/Downgrade logic) ---
if [ "$mariadb_version" == "10.3" ]; then
if [ "$current_wpcli_version" != "2.11.0" ]; then
rm -f /usr/local/bin/wp /bin/wp
curl -sSL -o /usr/bin/wp https://github.com/wp-cli/wp-cli/releases/download/v2.11.0/wp-cli-2.11.0.phar
chmod +x /usr/bin/wp
ln -fs /usr/bin/wp /usr/local/bin/wp
wp cli cache clear --allow-root --quiet
fi
else
if ! err_output=$(wp cli update --allow-root --yes --quiet 2>&1); then
[ -d /root/.wp-cli ] && rm -rf /root/.wp-cli /usr/bin/wp /usr/local/bin/wp
if bash /usr/local/src/centminmod/addons/wpcli.sh install > /dev/null 2>&1; then
ln -fs /bin/wp /usr/local/bin/wp
chmod 775 /bin/wp /usr/local/bin/wp
else
send_slack_alert "#wpo-fail" ":warning:" "wp cli update failed" "NA" "Attempt via \`wp cli update\` \`\`\` $err_output \`\`\` \n attempted \`bash /usr/local/src/centminmod/addons/wpcli.sh install\` as well."
fi
else
if [ ! -L /usr/local/bin/wp ]; then
rm -f /usr/local/bin/wp /bin/wp
bash /usr/local/src/centminmod/addons/wpcli.sh install > /dev/null 2>&1
ln -fs /bin/wp /usr/local/bin/wp
chmod 775 /bin/wp /usr/local/bin/wp
fi
fi
fi
# --- Part 2: Generate PHP Version Wrappers (wp74, wp81, etc.) ---
for php_bin in /opt/remi/php*/root/usr/bin/php; do
[ -e "$php_bin" ] || continue
if [[ $php_bin =~ /opt/remi/php([0-9]+)/root/usr/bin/php ]]; then
version="${BASH_REMATCH[1]}"
wrapper="/usr/local/bin/wp${version}"
cat <<EOF > "$wrapper"
#!/bin/bash
# Auto-generated wrapper: Run WP-CLI using PHP ${version}
exec "$php_bin" /usr/local/bin/wp --allow-root "\$@"
EOF
chmod +x "$wrapper"
fi
done
}
get_wpo_api_hash() {
local wpo_api_hash
if ! wpo_api_hash=$(curl -s https://www.bigscoots.com/downloads/uniquevisithash 2>/dev/null)
then
send_slack_alert "#wpo-errors" ":warning:" "wpo api hash fail" "Failed to pull the hash using CURL."
exit 1
fi
echo "$wpo_api_hash"
}
yum_check() {
local lock_file="/var/lock/yum_check.lock"
local expiration_time=86400 # 24 hours in seconds
# Check if lock file exists and exit if it does
if [[ -f "$lock_file" ]]; then
local current_time=$(date +%s)
local file_modification_time=$(stat -c %Y "$lock_file")
# Check if lock file is older than 24 hours
if (( current_time - file_modification_time < expiration_time )); then
return 0
fi
fi
# Clean yum cache
yum clean all > /dev/null 2>&1
# Check for available updates
local output=$(yum check-update 2>&1)
# Check the exit code
local exit_code=$?
if [[ $exit_code -ne 0 && $exit_code -ne 100 ]]; then
local logfile="/root/.bigscoots/logs/yum_checkupdate-fail-$(date +%s).log"
echo "$output" > "$logfile"
send_slack_alert "#wpo-fail" ":x:" "YUM" "N/A" "A yum check failed, please check: $logfile"
exit 1
fi
# Create lock file
touch "$lock_file"
}
check_process_runtime() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--kill)
KILL_PROCESS=true
shift
;;
*)
PROCESS_NAMES+=("$1")
shift
;;
esac
done
# Loop through each process name provided as arguments
for process_name in "${PROCESS_NAMES[@]}"
do
# Get the PID and CPU time of the specified process, excluding awk itself
ps_output=$(ps -eo pid,etime,args | awk -v process_name="$process_name" '$0 ~ process_name && !/awk/ {print}')
# Loop through each line of the output
while read -r line
do
# Split the line into PID, elapsed time, and command
pid=$(echo "$line" | awk '{print $1}')
etime=$(echo "$line" | awk '{print $2}')
command=$(echo "$line" | awk '{$1=""; $2=""; print substr($0,3)}')
# Remove leading zeros from the elapsed time
etime=$(echo "$etime" | sed 's/^0*//')
# Extract days, hours, minutes, and seconds from elapsed time
IFS='-:' read -r days hours minutes seconds <<< "$etime"
# Calculate total minutes
total_minutes=$(( (10#$days * 24 * 60) + (10#$hours * 60) + (10#$minutes) ))
# Check if the process has been running for more than 30 minutes
if [ "$total_minutes" -gt 30 ]
then
send_slack_alert "#wpo-alerts" ":hourglass_flowing_sand:" "Long Running Process" "NA" "Process with PID $pid ($process_name) has been running for more than 30 minutes."
# Kill the process if the --kill option is provided
if [ "$KILL_PROCESS" = true ]; then
kill -9 "$pid"
send_slack_alert "#wpo-alerts" ":skull_and_crossbones:" "Process Killed" "NA" "Process with PID $pid ($process_name) has been terminated."
fi
fi
done <<< "$ps_output"
done
}
find_wp_installs() {
local root_dir="/home/nginx/domains"
local wp_includes="wp-includes/version.php"
# Function to check if a directory contains a WordPress installation
check_wp_install() {
local dir=$1
if [[ -f "$dir/$wp_includes" ]]; then
echo "$dir"
fi
}
# Use find to locate wp-includes directories, excluding wp-content and wp-admin
find "$root_dir"/*/public -mindepth 1 -maxdepth 2 \( -path "*/wp-content" -o -path "*/wp-admin" \) -prune -o -type d -name "wp-includes" -print | while read -r wp_includes_dir; do
check_wp_install "$(dirname "$wp_includes_dir")"
done
}
install_package() {
if ! rpm -q $1 >/dev/null 2>&1
then
yum install -y -q $1
fi
}
generate_json_response() {
local success="$1"
local message="$2"
local result="$3"
local ip="${4:-""}"
local domain="${5:-""}"
jq -n \
--argjson success "$success" \
--arg message "$message" \
--argjson result "$result" \
--arg ip "$ip" \
--arg domain "$domain" \
'{
"success": $success,
"messages": [$message],
"ip": $ip,
"domain": $domain,
"result": $result
}'
}
send_webhook() {
local webhook_url="$1"
local auth_header="$2"
local json_payload="$3"
#echo "curl -s -w \"\\n%{http_code}\" -X POST -H \"Authorization: ${auth_header}\" -H \"Content-Type: application/json\" -d '${json_payload}' \"${webhook_url}\""
# Send the webhook and capture the response
response=$(curl -s -w "\n%{http_code}" -X POST -H "Authorization: ${auth_header}" -H "Content-Type: application/json" -d "${json_payload}" "${webhook_url}")
http_status=$(echo "$response" | tail -n1)
body=$(echo "$response" | head -n1)
# Determine success based on HTTP status code
if [ "$http_status" -eq 200 ]; then
generate_json_response true "Webhook has been sent successfully" "$body"
else
generate_json_response false "Webhook failed with status code $http_status" "$body"
fi
}
# Initialize a default JSON response
function init_json_response() {
json='{"errors": [], "messages": [], "success": false, "result": {}}'
}
# Function to add an error to the JSON
function add_json_error() {
local error_message="$1"
json=$(jq --arg error "$error_message" '.errors += [$error]' <<< "$json")
}
# Function to add a message to the JSON
function add_json_message() {
local message="$1"
json=$(jq --arg message "$message" '.messages += [$message]' <<< "$json")
}
# Function to set success flag to true/false
function set_json_success() {
local value="${1:-true}"
if [ "$value" = "false" ]; then
json=$(jq '.success = false' <<< "$json")
else
json=$(jq '.success = true' <<< "$json")
fi
}
# Function to add a raw JSON object as a message
function add_actual_json_message() {
local raw_json="$1"
# Use --argjson so that the passed parameter is treated as actual JSON
json=$(jq --argjson message "$raw_json" '.messages += [$message]' <<< "$json")
}
# Function to add a raw JSON object as a error
function add_actual_json_error() {
local raw_json="$1"
json=$(jq --argjson error "$raw_json" '.errors += [$error]' <<< "$json")
}
# Function to set result data using a temporary file to avoid argument list too long
function set_json_result() {
local result_data="$1"
local tmp_json
tmp_json=$(mktemp)
local tmp_result
tmp_result=$(mktemp)
# Write current json and result_data to temporary files
echo "$json" > "$tmp_json"
echo "$result_data" > "$tmp_result"
# Use --argfile to pass the large JSON data from file
json=$(jq --argfile result "$tmp_result" '.result = $result' "$tmp_json")
rm -f "$tmp_json" "$tmp_result"
}
# Function to print the JSON response
function print_json_response() {
echo "$json" | jq .
}
# Function to compare nginx version
compare_nginx_version() {
# Split the version into major, minor, and patch
local current_version="$1"
local minimum_version="1.25.1"
if [[ "$(echo -e "$current_version\n$minimum_version" | sort -V | head -n1)" == "$minimum_version" ]]
then
return 0
else
return 1
fi
}
# Function to check the Nginx version and process config files
check_nginx_and_update_http2() {
# Get Nginx version
local nginx_version=$(nginx -v 2>&1 | grep -oP "(?<=nginx/)[0-9.]+")
if [ -z "$nginx_version" ]; then
return 1
fi
# Compare versions
if compare_nginx_version "$nginx_version"; then
# Initialize variable to track if changes were made
local nginxt=false
# Files to check
local files=(
"/usr/local/nginx/conf/conf.d/*.conf"
"/usr/local/nginx/conf/conf.d/phpmyadmin_ssl.conf"
)
for file in ${files[@]}; do
if [ -f "$file" ]; then
# Step 2: Check for 'listen' directive with http2 and modify
if grep -q 'listen .*http2' "$file"; then
# Remove all instances of 'http2 on;' first
sed -i '/http2[[:space:]]\+on;/d' "$file"
# Remove 'http2' from 'listen' directive and add 'http2 on;' after
sed -i '/listen .*http2/s/http2//g' "$file"
sed -i '/listen .*;/s/ ;/;/g' "$file"
sed -i '/listen .*;/a \ http2 on;' "$file"
# Set flag to true indicating a change was made
nginxt=true
fi
fi
done
# After the loop, check if any changes were made and reload Nginx if necessary
fi
}
convert_wpsecure() {
find /usr/local/nginx/conf/wpincludes/ -type f -name "wpsecure_*.conf" ! -name "*_blacklist.conf" ! -name "*_whitelist.conf" | while read -r file; do
# Extract the domain name
domain=$(basename "$file" | sed 's/wpsecure_//; s/\.conf//')
# Define paths for blacklist and whitelist files
blacklist_file="/usr/local/nginx/conf/wpincludes/${domain}/wpsecure_${domain}_blacklist.conf"
whitelist_file="/usr/local/nginx/conf/wpincludes/${domain}/wpsecure_${domain}_whitelist.conf"
# Find the SSL conf file
ssl_conf_file="/usr/local/nginx/conf/conf.d/${domain}.ssl.conf"
# Check if SSL conf file exists
if [[ -f "$ssl_conf_file" ]]; then
# Extract PHP includes, ignoring commented lines and capturing only the path after "include"
php_includes=$(grep -h "^\s*include /usr/local/nginx/conf/php" "$ssl_conf_file" | awk '{print $2}' | sed 's/;$//' | sort | uniq -c | sort -rn | head -n 1 | awk '{print $2}')
# If we didn't find any PHP includes, skip this domain
if [[ -z "$php_includes" ]]; then
echo "No PHP include found for $domain, skipping..."
continue
fi
else
echo "SSL conf file for $domain not found, skipping..."
continue
fi
# Replace ${vhostname} and ${phpconf} in the template and write to the original file
sed -e "s/\${vhostname}/$domain/g" -e "s|\${phpconf}|$php_includes|g" /bigscoots/wpo/nginx/includes/bs_wp_whitelist_v2.conf > "$file"
# Create empty blacklist and whitelist files with the replaced domain name
touch "$blacklist_file"
touch "$whitelist_file"
done
}
gimme_the_goods_les ()
{
HISTCONTROL=ignorespace;
# Load check: exit early if load > 2x CPU cores
CPU_COUNT=$(nproc)
LOAD_AVG=$(awk '{print $1}' /proc/loadavg)
LOAD_LIMIT=$(echo "$CPU_COUNT * 2" | bc)
COMPARE=$(echo "$LOAD_AVG > $LOAD_LIMIT" | bc)
if [[ "$COMPARE" -gt 1 ]]; then
echo -e "\033[1;31mSystem load ($LOAD_AVG) is too high to proceed (limit: $LOAD_LIMIT).\033[0m"
return 1
fi
sleep 1;
history -d $((HISTCMD-1));
# Display Recent Commands
echo -e "\n\033[1;36m=== Recent Commands ===\033[0m";
history | tail -n 10 | awk '{$1=""; print " "$0}';
# Display Current Users
echo -e "\n\033[1;36m=== Current Users ===\033[0m";
if grep --color=auto -q "AlmaLinux release 9" /etc/redhat-release 2> /dev/null; then
W_CMD="w -i";
else
W_CMD="w";
fi;
UNKNOWN_USER_DETECTED=0
$W_CMD | awk 'BEGIN {
split("38.58.227.48:Zubair 67.202.70.147:Nexus 208.117.38.38:Justin 38.65.255.4:Justin 50.31.114.76:Jay 208.117.38.157:Shibin 208.117.38.23:Dean 198.175.25.80:Zach 208.117.38.24:Prasul 50.31.30.56:Zack 50.31.119.9:Julian 208.100.53.125:Sadiq 208.117.4.65:Michael 208.100.53.146:Khan 208.100.53.138:Gibu 74.121.204.16:JK 50.31.116.25:Ahmad 50.31.99.57:Les 38.65.224.44:Lijith 38.65.227.5:Chris 38.65.224.198:Abdal 38.65.227.37:Asher 38.65.227.35:David 38.65.227.38:Muhammad 38.58.224.253:Pell 38.58.225.37:Aleks 38.58.225.38:Roscoe 38.58.226.2:Pavle", pairs, " ")
for (i in pairs) {
split(pairs[i], p, ":")
ips[p[1]] = p[2]
}
}
$1 == "USER" {
printf "\033[1m%-10s %-10s %-16s %-20s %s\033[0m\n", "USER", "TTY", "FROM", "NAME", "LOGIN@ IDLE JCPU PCPU WHAT"
next
}
NF > 3 {
if ($0 ~ /^[[:alnum:]]/) {
cmd = substr($0, index($0, $4))
# Extract a valid IPv4 address only (four octets, 0-255)
match($3, /([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/, ip_arr)
ip = (ip_arr[1] != "") ? ip_arr[1] : "Unknown"
name = (ip in ips) ? ips[ip] : "Unknown"
# Check if an unknown user is detected
if (name == "Unknown") {
system("echo -e \"\033[1;31m\nπ¨ ALERT: Unauthorized Access Detected! π¨\n\nAn unknown user has connected from " $3 ".\nIf this is unexpected, please investigate immediately.\nBigScoots staff has been notified.\nEnsure you are connecting via a proper tunnel IP.\n\033[0m\"");
system("UNKNOWN_USER_DETECTED=1")
}
printf "%-10s %-10s %-16s %-20s %s\n", $1, $2, $3, name, cmd
} else {
print
}
}' 2> /dev/null
# Display Actively Running Tasks
echo -e "\n\033[1;36m=== Actively Running Tasks ===\033[0m";
RUNNING_TASKS=$(ps -eo pid,cmd --sort=start_time | grep '[b]ash\|[s]h' | grep '/bigscoots/')
if [[ -z "$RUNNING_TASKS" ]]; then
echo "None"
else
echo -e "PID CMD\n$RUNNING_TASKS"
fi
# Display final alert if an unknown user was detected
if [[ "$UNKNOWN_USER_DETECTED" -eq 1 ]]; then
echo -e "\033[1;31m\nπ¨ ALERT: Unauthorized Access Detected! π¨\nPlease check the logs and take action immediately.\033[0m"
fi
# Check if remote database info file exists
if [[ -f /root/.bigscoots/db/info ]]; then
echo -e "\n\033[1;36m=== Remote Database Detected ===\033[0m"
# Extract database information
DBHOST=$(grep '^dbhost:' /root/.bigscoots/db/info | awk '{print $2}')
DBHOSTPUBLIC=$(grep '^dbhostpublic:' /root/.bigscoots/db/info | awk '{print $2}')
echo -e "Private Network IP: \033[1;32m$DBHOST\033[0m"
echo -e "Public Network IP: \033[1;32m$DBHOSTPUBLIC\033[0m"
fi
}
# nsgrep: A custom grep function that excludes common static file types (images, fonts, videos, audio, compressed files, and documents).
# Usage: nsgrep <pattern> <file>
# This function helps filter out static assets like .jpg, .png, .css, .js, fonts, videos, etc., from your search results.
nsgrep() {
local search="$1"
shift
grep -vE "/[^ ]+\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif|css|scss|js|json|woff|woff2|ttf|eot|otf|svgz|webm|mp4|mp3|wav|ogg|flac|aac|zip|tar|gz|rar|7z|pdf|txt|md|xml)([? ].*)?$" "$@" | grep "$search"
}
clean_nginx_config() {
local config_file="$1"
if [[ ! -f "$config_file" ]]; then
echo "Error: Config file $config_file not found!"
return 1
fi
# Create a timestamped backup
epoch=$(date +%s)
config_file_bk="${config_file}.bk.${epoch}"
cp -rf "$config_file" "$config_file_bk"
# Remove unwanted cache includes (wpcacheenabler, wpsupercache, rediscache)
sed -i '/^\s*#\?include \/usr\/local\/nginx\/conf\/wpincludes\/.*\/\(wpcacheenabler\|wpsupercache\|rediscache\)_.*\.conf;/d' "$config_file"
# Remove ngx_pagespeed includes
sed -i '/^\s*#\?include \/usr\/local\/nginx\/conf\/pagespeed\(handler\|statslog\)\?\.conf;/d ; /ngx_pagespeed/d' "$config_file"
# Remove all try_files and comments inside location / block
awk '
BEGIN { inside_location = 0 }
/^ location \/ \{/ { inside_location = 1 }
inside_location && /^\s*(#|try_files|\s*$)/ { next }
inside_location && /^\s*}/ { inside_location = 0 }
{ print }
' "$config_file" > "${config_file}.tmp"
mv -f "${config_file}.tmp" "$config_file"
# Ensure the correct try_files line is added
sed -i '/^ location \/ {$/a\ \ \ \ try_files $uri $uri/ /index.php?$args;' "$config_file"
# Remove consecutive blank lines **only inside the location block**
awk '
BEGIN { inside_location = 0 }
/^ location \/ \{/ { inside_location = 1 }
inside_location && /^\s*$/ { blank++ }
inside_location && /^\s*}/ { inside_location = 0; blank=0 }
blank > 1 { next }
{ print }
' "$config_file" > "${config_file}.tmp"
mv -f "${config_file}.tmp" "$config_file"
# Reload Nginx with validation
ngxreload_t "\`clean_nginx_config\` nginx conf failed. Backup available: $config_file_bk" --noexit
}
# Function: Count unique visitors per user agent
ua_unique_visitors() {
local log_files=("$@")
[[ ${#log_files[@]} -eq 0 ]] && log_files=("access.log")
{
for f in "${log_files[@]}"; do
[[ "$f" == *.gz ]] && zcat --force "$f" || cat "$f"
done
} 2>/dev/null | awk '
{
ip = $1
n = split($0, q, /\"/)
ua = q[n - 1]
if (ua != "") {
key = ua "|" ip
if (!seen[key]++) {
count[ua]++
}
}
}
END {
for (ua in count) {
printf "%7d %s\n", count[ua], ua
}
}' | sort -rn
}
# Function: Fetch verified bot names from Cloudflare Radar API
fetch_verified_bots() {
local BOT_LIST_URL="https://www.bigscoots.com/verified-bots.txt"
local BOT_LIST_FILE="/tmp/verified.bots.raw"
local BOT_REGEX_LIST_FILE="/tmp/verified.bots"
curl -s "$BOT_LIST_URL" -o "$BOT_LIST_FILE"
sed 's/.*/".*&.*"/' "$BOT_LIST_FILE" | tr '[:upper:]' '[:lower:]' > "$BOT_REGEX_LIST_FILE"
echo "$BOT_REGEX_LIST_FILE"
}
match_verified_bots_to_logs() {
local token="$1"
shift
local log_files=("$@")
[[ ${#log_files[@]} -eq 0 ]] && log_files=("access.log")
# Fetch and load bot patterns
bot_file=$(fetch_verified_bots "$token")
mapfile -t bots < "$bot_file"
tmp_ua_visitors="/tmp/ua_visitors.tmp"
{
for f in "${log_files[@]}"; do
[[ "$f" == *.gz ]] && zcat --force "$f" || cat "$f"
done
} 2>/dev/null | awk '
{
ip = $1
n = split($0, q, "\"")
ua = tolower(q[n - 1])
if (ua != "") {
key = ua "|" ip
if (!seen[key]++) {
count[ua]++
}
total[ua]++
}
}
END {
for (ua in count) {
gsub(/"/, "", ua)
print count[ua] "|" total[ua] "|" ua
}
}' > "$tmp_ua_visitors"
# Output headers
printf "%-10s %-15s %s\n" "UNIQUE" "TOTAL_REQS" "BOT_NAME"
printf "%-10s %-15s %s\n" "------" "-----------" "---------"
total_unique=0
total_requests=0
output_lines=()
for bot in "${bots[@]}"; do
matches=$(grep -i "|.*${bot//\"/}" "$tmp_ua_visitors")
unique=0
total=0
while IFS="|" read -r u t ua; do
unique=$((unique + u))
total=$((total + t))
done <<< "$matches"
if [ "$total" -gt 0 ]; then
output_lines+=("$(printf "%-10d %-15d %s" "$unique" "$total" "$bot")")
total_unique=$((total_unique + unique))
total_requests=$((total_requests + total))
fi
done
# Print sorted output
printf "%s\n" "${output_lines[@]}" | sort -k1 -rn
# Print grand totals
echo
echo "========== GRAND TOTAL =========="
printf "%-10s %-15s\n" "UNIQUE" "TOTAL_REQS"
printf "%-10s %-15s\n" "------" "-----------"
printf "%-10d %-15d\n" "$total_unique" "$total_requests"
}
# Function: Detect IPs with a high number of unique UAs (likely spoofers)
detect_ip_useragent_spammers() {
local log_files=("$@")
[[ ${#log_files[@]} -eq 0 ]] && log_files=("access.log")
zcat -f "${log_files[@]}" 2>/dev/null | awk -F\" '
{
split($1, parts, " ")
ip = parts[1]
ua = $6
ip_ua[ip][ua] = 1
}
END {
total_spoofed_ua_count = 0
total_bot_ips = 0
for (ip in ip_ua) {
n = asorti(ip_ua[ip], tmp)
if (n > 10) {
print "BOT_IP", ip, "had", n, "unique user agents β treat as 1 visit or exclude"
total_spoofed_ua_count += n
total_bot_ips++
}
}
print "\nTotal spoofed UA count: ", total_spoofed_ua_count
print "Total BOT_IPs detected: ", total_bot_ips
}'
}
function deploy_opcache_gui {
# Generate a UUID for filename
UUID=$(uuidgen)
FILE_NAME="${UUID}.php"
# Download the file
wget -q -O "${FILE_NAME}" https://raw.githubusercontent.com/amnuts/opcache-gui/refs/heads/master/index.php
if [[ ! -f "${FILE_NAME}" ]]; then
echo "Failed to download opcache-gui."
return 1
fi
# Get site URL
SITE_URL=$(wp option get siteurl 2>/dev/null)
if [[ -z "${SITE_URL}" ]]; then
echo "Could not determine site URL via wp-cli."
return 1
fi
# Print access URL
echo "Opcache GUI available at: ${SITE_URL}/${FILE_NAME}"
# Background removal task using screen
screen -dmS "remove_opcache_${UUID}" bash -c "sleep 86400 && rm -f \"$(pwd)/${FILE_NAME}\""
}
function reinstall_all_plugins {
if [[ "$1" == "--all-domains" ]]; then
echo "Reinstalling all plugins across all domains (10 at a time)..."
find_wp_installs | xargs -P10 -I{} bash -c '
WP="$0"
source /bigscoots/includes/common.sh
echo "wpcli core is-installed --path=$WP"
if wpcli core is-installed --path="$WP" >/dev/null 2>&1; then
echo "[INFO] Processing $WP"
wpcli plugin list --field=name --path="$WP" | while read -r P; do
echo "[INFO] Reinstalling $P in $WP"
wpcli plugin install "$P" --force --path="$WP"
done
else
echo "[WARN] Skipping $WP (not a valid WP install)"
fi
' {}
else
source /bigscoots/includes/common.sh
if ! wpcli core is-installed >/dev/null 2>&1; then
echo "[ERROR] This is not a valid WordPress install (wp core is-installed failed)."
return 1
fi
echo "Reinstalling plugins in current WordPress install..."
wpcli plugin list --field=name | while read -r P; do
echo "[INFO] Reinstalling $P"
wpcli plugin install "$P" --force
done
fi
}
check_domains_lxd() {
local DOMAINS_ROOT="/home/nginx/domains"
local OUTPUT_JSON=0
if [[ "${1:-}" == "--json" ]]; then
OUTPUT_JSON=1
shift
fi
export OUTPUT_JSON
# Get current server's last octet once
local CURRENT
CURRENT=$(curl -s --connect-timeout 5 --max-time 10 ipinfo.io/ip 2>/dev/null | awk -F'.' '{print $4}')
[[ -z "$CURRENT" ]] && CURRENT="unknown"
export CURRENT
_check_one_domain() {
local d="$1"
local DOMAIN PAGE EXPECTED FINAL_URL REDIRECT_DOMAIN
local REDIRECT_CHECK ROOT_STATUS ROOT_CHECK WPADMIN_STATUS WPADMIN_CHECK
local CACHE_BUSTER
local -a CURL_OPTS=(-sL --connect-timeout 5 --max-time 10)
local -a CURL_OPTS_HEAD=(-sLI --connect-timeout 5 --max-time 10)
DOMAIN=$(basename "$d")
# Generate random string locally
CACHE_BUSTER=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c4)
# Use it in the URL
PAGE=$(curl "${CURL_OPTS[@]}" "https://$DOMAIN/bigscoots.php?nocache=$CACHE_BUSTER" 2>/dev/null)
EXPECTED=$(echo "$PAGE" | grep -oP '(?<=hostname-footer">)[0-9]+-\K[0-9]+(?=</div>)' 2>/dev/null)
FINAL_URL=$(curl "${CURL_OPTS_HEAD[@]}" -o /dev/null -w "%{url_effective}" "https://$DOMAIN" 2>/dev/null)
REDIRECT_DOMAIN=$(echo "$FINAL_URL" | awk -F/ '{print $3}')
local is_redirect=0
if [[ "$REDIRECT_DOMAIN" != "$DOMAIN" && "$REDIRECT_DOMAIN" != "www.$DOMAIN" && "$REDIRECT_DOMAIN" != "" ]]; then
is_redirect=1
REDIRECT_CHECK="Redirect: $REDIRECT_DOMAIN"
else
ROOT_STATUS=$(curl "${CURL_OPTS[@]}" -o /dev/null -w "%{http_code}" "https://$DOMAIN" 2>/dev/null)
[[ "$ROOT_STATUS" =~ ^(200|301)$ ]] && ROOT_CHECK="β" || ROOT_CHECK="β($ROOT_STATUS)"
WPADMIN_STATUS=$(curl "${CURL_OPTS[@]}" -o /dev/null -w "%{http_code}" "https://$DOMAIN/wp-admin" 2>/dev/null)
[[ "$WPADMIN_STATUS" =~ ^(200|301|302)$ ]] && WPADMIN_CHECK="β" || WPADMIN_CHECK="β($WPADMIN_STATUS)"
REDIRECT_CHECK="Frontend: $ROOT_CHECK WP-Admin: $WPADMIN_CHECK"
fi
if [[ "$OUTPUT_JSON" -eq 1 ]]; then
# Booleans / status
local status matched frontend_ok wpadmin_ok
matched=false
frontend_ok=false
wpadmin_ok=false
if [[ -z "$EXPECTED" ]]; then
status="no_bigscoots"
elif [[ "$CURRENT" == "unknown" ]]; then
status="current_unknown"
elif [[ "$EXPECTED" == "$CURRENT" ]]; then
status="ok"
matched=true
else
status="mismatch"
fi
if [[ "$is_redirect" -eq 0 ]]; then
[[ "${ROOT_STATUS:-}" =~ ^(200|301)$ ]] && frontend_ok=true
[[ "${WPADMIN_STATUS:-}" =~ ^(200|301|302)$ ]] && wpadmin_ok=true
fi
# JSON-safe values (domains are hostnames, so safe to embed)
local expected_json current_json redirect_json root_json wpadmin_json
[[ -n "$EXPECTED" ]] && expected_json="\"$EXPECTED\"" || expected_json="null"
[[ "$CURRENT" != "unknown" ]] && current_json="\"$CURRENT\"" || current_json="null"
[[ "$is_redirect" -eq 1 ]] && redirect_json="\"$REDIRECT_DOMAIN\"" || redirect_json="null"
[[ -n "${ROOT_STATUS:-}" ]] && root_json="\"$ROOT_STATUS\"" || root_json="null"
[[ -n "${WPADMIN_STATUS:-}" ]] && wpadmin_json="\"$WPADMIN_STATUS\"" || wpadmin_json="null"
printf '{"domain":"%s","status":"%s","current_octet":%s,"expected_octet":%s,"redirect_domain":%s,"root_status":%s,"wpadmin_status":%s,"matched":%s,"frontend_ok":%s,"wpadmin_ok":%s}\n' \
"$DOMAIN" "$status" \
"$current_json" "$expected_json" "$redirect_json" \
"$root_json" "$wpadmin_json" \
"$matched" "$frontend_ok" "$wpadmin_ok"
return 0
fi
# Human output (original style)
if [[ -n "$EXPECTED" && "$CURRENT" != "unknown" ]]; then
if [[ "$EXPECTED" = "$CURRENT" ]]; then
echo "β $DOMAIN (.$CURRENT) | $REDIRECT_CHECK"
else
echo "β $DOMAIN (on .$EXPECTED, this is .$CURRENT) | $REDIRECT_CHECK"
fi
elif [[ -n "$EXPECTED" && "$CURRENT" == "unknown" ]]; then
echo "? $DOMAIN (expected .$EXPECTED, current IP unknown) | $REDIRECT_CHECK"
else
echo "? $DOMAIN (no bigscoots.php) | $REDIRECT_CHECK"
fi
}
export -f _check_one_domain
if [[ "$OUTPUT_JSON" -eq 1 ]]; then
# Collect one-JSON-object-per-line from workers, then wrap into an array.
local objs
objs=$(
printf '%s\n' "$DOMAINS_ROOT"/*/ 2>/dev/null \
| xargs -P3 -n1 bash -c '_check_one_domain "$1"' _
)
# Join lines with commas into a valid JSON array
echo "$objs" | awk '
BEGIN { print "[" }
NF {
if (n++ > 0) printf(",")
printf("%s", $0)
}
END { print "]" }
'
else
printf '%s\n' "$DOMAINS_ROOT"/*/ 2>/dev/null \
| xargs -P3 -n1 bash -c '_check_one_domain "$1"' _
fi
}
update_nginx_drop_conf_robotstxt() {
local target="/usr/local/nginx/conf/drop.conf"
# Search for the simple one-line version
if grep -q "location = /robots.txt.*access_log off" "$target"; then
# Replace matches with the multi-line block
# We use single quotes around the sed command to prevent $uri and $args from breaking
sed -i 's|.*location = /robots.txt.*{.*access_log off.*}|location = /robots.txt {\n allow all;\n try_files $uri /index.php?$args;\n log_not_found off;\n access_log off;\n}|' "$target"
echo "Success: Replaced robots.txt rule."
else
echo "Skipping: Target robots.txt line not found (or already updated)."
fi
}
# In common.sh
assert_lxd_plan() {
local api_test
api_test=$(curl -sf --unix-socket /dev/lxd/sock http://lxd/1.0 2>/dev/null || echo "")
if [[ -z "$api_test" ]]; then
echo "[${serverip:-UNKNOWN}] ERROR: LXD guest API not accessible - lxcfs may be broken. Aborting." >&2
exit 1
fi
LXD_PLAN=$(curl -sf --unix-socket /dev/lxd/sock http://lxd/1.0/config/user.plan 2>/dev/null | tr -d '"' || echo "")
if [[ -z "$LXD_PLAN" ]]; then
echo "[${serverip:-UNKNOWN}] ERROR: user.plan not set on this container. Run --backfill-plans first." >&2
exit 1
fi
export LXD_PLAN
}