#!/bin/bash # ============================================================ # Host Monitor Agent 安裝腳本 # 主機:{{ $host->name }} # 產生時間:{{ now()->format('Y-m-d H:i:s') }} # ============================================================ set -e REPORT_URL="{{ $reportUrl }}" TOKEN="{{ $token }}" AGENT_DIR="/opt/host-agent" AGENT_SCRIPT="$AGENT_DIR/agent.sh" CRON_FILE="/etc/cron.d/host-agent" SERVICE_USER="root" echo "[INFO] 安裝 Host Monitor Agent..." # ── 連線測試:確認本機可 POST 回 Portal ────────────────────────────────────── echo "[INFO] 測試與 Portal 的連線..." _PING_URL="${REPORT_URL/\/api\/agent\/report/\/api\/agent\/ping}" set +e _PING_HTTP=$(curl -s -w "%{http_code}" -X POST "$_PING_URL" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}' \ --max-time 15 --connect-timeout 10 \ -o /dev/null 2>/dev/null) set -e [ -z "$_PING_HTTP" ] && _PING_HTTP="000" if [ "$_PING_HTTP" = "200" ]; then echo "[OK] 連線測試成功,繼續安裝..." elif [ "$_PING_HTTP" = "401" ]; then echo "[ERROR] Token 驗證失敗,請重新從 Portal 下載安裝腳本。" exit 1 elif [ "$_PING_HTTP" = "000" ]; then echo "[ERROR] 無法連線到 Portal(連線逾時或被防火牆封鎖)" echo " 請確認本機對外 TCP 443 POST 請求未被防火牆擋住:" echo " curl -X POST ${_PING_URL} -H 'Authorization: Bearer \$TOKEN' -d '{}'" echo " 若下載腳本可以但 POST 失敗,通常是防火牆只允許 GET 而非 POST。" exit 1 else echo "[ERROR] Portal 回應異常(HTTP $_PING_HTTP),請聯絡管理員。" exit 1 fi # 建立目錄 mkdir -p "$AGENT_DIR" # 寫入 agent script # ===BEGIN AGENT.SH=== cat > "$AGENT_SCRIPT" << 'AGENT_EOF' #!/bin/bash # Host Monitor Agent - 系統報告收集與推送 export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" REPORT_URL="__REPORT_URL__" TOKEN="__TOKEN__" AGENT_DIR="$(dirname "$0")" AGENT_VERSION="202606250000" AGENT_SCRIPT="$(readlink -f "$0" 2>/dev/null || echo "$AGENT_DIR/agent.sh")" _log() { logger -t host-agent "$1" 2>/dev/null || true; } # JSON 字串跳脫(依序嘗試 python3 / python2 / sed fallback) esc() { local _v _v=$(printf '%s' "$1" | python3 -c "import sys,json; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])" 2>/dev/null) && { printf '%s' "$_v"; return; } _v=$(printf '%s' "$1" | python -c "import sys,json; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])" 2>/dev/null) && { printf '%s' "$_v"; return; } printf '%s' "$1" | tr -cd '\040-\176\t' | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' } export -f esc collect() { # 基本系統資訊 HOSTNAME_VAL=$(hostname -f 2>/dev/null || hostname) KERNEL=$(uname -r 2>/dev/null || echo "") IP_VAL=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1) [ -z "$IP_VAL" ] && IP_VAL=$(hostname -I 2>/dev/null | awk '{print $1}') UPTIME_VAL=$(uptime -p 2>/dev/null || uptime 2>/dev/null | awk -F',' '{print $1}' | sed 's/^ *up /up /') BOOT_TIME=$(who -b 2>/dev/null | awk '{print $3, $4}') # OS PRETTY_NAME="" OS_ID="" OS_VERSION_ID="" if [ -f /etc/os-release ]; then . /etc/os-release PRETTY_NAME="${PRETTY_NAME:-}" OS_ID="${ID:-}" OS_VERSION_ID="${VERSION_ID:-}" fi [ -z "$PRETTY_NAME" ] && PRETTY_NAME=$(uname -s 2>/dev/null) # Timezone TZ_VAL=$(timedatectl show --property=Timezone --value 2>/dev/null \ || cat /etc/timezone 2>/dev/null \ || date +%Z 2>/dev/null || echo "") # CPU CPU_MODEL=$(grep "model name" /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2- | xargs) CPU_COUNT=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo 1) # Memory MEM_TOTAL_KB=$(awk '/^MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 0) MEM_AVAIL_KB=$(awk '/^MemAvailable/{print $2}' /proc/meminfo 2>/dev/null || echo 0) MEM_USED_KB=$(( MEM_TOTAL_KB - MEM_AVAIL_KB )) MEM_TOTAL_GB=$(awk "BEGIN{printf \"%.2f\", $MEM_TOTAL_KB/1048576}") MEM_USED_GB=$(awk "BEGIN{printf \"%.2f\", $MEM_USED_KB/1048576}") MEM_FREE_GB=$(awk "BEGIN{printf \"%.2f\", $MEM_AVAIL_KB/1048576}") MEM_PCT=0 [ "$MEM_TOTAL_KB" -gt 0 ] && MEM_PCT=$(( MEM_USED_KB * 100 / MEM_TOTAL_KB )) # Disk (JSON array) DISK_JSON="[" FIRST_DISK=1 while IFS= read -r line; do read -ra F <<< "$line" [ "${#F[@]}" -lt 7 ] && continue PCT="${F[5]//\%/}" [ -z "$PCT" ] && PCT=0 FS=$(echo "${F[0]}" | sed 's/"/\\"/g') MT=$(echo "${F[6]}" | sed 's/"/\\"/g') [ "$FIRST_DISK" -eq 0 ] && DISK_JSON="${DISK_JSON}," DISK_JSON="${DISK_JSON}{\"filesystem\":\"$FS\",\"mounted_on\":\"$MT\",\"size\":\"${F[2]}\",\"used\":\"${F[3]}\",\"avail\":\"${F[4]}\",\"use\":\"${F[5]}\",\"percent\":$PCT}" FIRST_DISK=0 done < <(df -h --output=source,fstype,size,used,avail,pcent,target 2>/dev/null | tail -n +2 | grep -E '^/dev/') DISK_JSON="${DISK_JSON}]" # NTP NTP_STATUS="inactive" for svc in chronyd chrony ntp systemd-timesyncd; do if systemctl is-active "$svc" >/dev/null 2>&1; then NTP_STATUS="active"; break fi done # Firewall FW_STATUS="inactive"; FW_TYPE="" if systemctl is-active firewalld >/dev/null 2>&1; then FW_STATUS="active"; FW_TYPE="firewalld" elif ufw status 2>/dev/null | grep -q "Status: active"; then FW_STATUS="active"; FW_TYPE="ufw" elif iptables -L -n 2>/dev/null | grep -qv "^Chain\|^target\|^$"; then FW_STATUS="active"; FW_TYPE="iptables" fi # SSH SSH_PORT=$(sshd -T 2>/dev/null | awk '/^port /{print $2}' | head -1) [ -z "$SSH_PORT" ] && SSH_PORT=$(awk '/^Port /{print $2}' /etc/ssh/sshd_config 2>/dev/null | head -1) [ -z "$SSH_PORT" ] && SSH_PORT="22" PERMIT_ROOT=$(sshd -T 2>/dev/null | awk '/^permitrootlogin /{print $2}') [ -z "$PERMIT_ROOT" ] && PERMIT_ROOT=$(awk '/^PermitRootLogin /{print $2}' /etc/ssh/sshd_config 2>/dev/null | head -1) [ -z "$PERMIT_ROOT" ] && PERMIT_ROOT="yes" PUBKEY_AUTH=$(sshd -T 2>/dev/null | awk '/^pubkeyauthentication /{print $2}') [ -z "$PUBKEY_AUTH" ] && PUBKEY_AUTH=$(awk '/^PubkeyAuthentication /{print $2}' /etc/ssh/sshd_config 2>/dev/null | head -1) [ -z "$PUBKEY_AUTH" ] && PUBKEY_AUTH="yes" PASSWORD_AUTH=$(sshd -T 2>/dev/null | awk '/^passwordauthentication /{print $2}') [ -z "$PASSWORD_AUTH" ] && PASSWORD_AUTH=$(awk '/^PasswordAuthentication /{print $2}' /etc/ssh/sshd_config 2>/dev/null | head -1) [ -z "$PASSWORD_AUTH" ] && PASSWORD_AUTH="yes" MAX_AUTH_TRIES=$(sshd -T 2>/dev/null | awk '/^maxauthtries /{print $2}') [ -z "$MAX_AUTH_TRIES" ] && MAX_AUTH_TRIES=$(awk '/^MaxAuthTries /{print $2}' /etc/ssh/sshd_config 2>/dev/null | head -1) [ -z "$MAX_AUTH_TRIES" ] && MAX_AUTH_TRIES="6" # fail2ban F2B_STATUS=$(systemctl is-active fail2ban 2>/dev/null | head -1) [ -z "$F2B_STATUS" ] && F2B_STATUS="inactive" # Antivirus AV_INSTALLED="false"; AV_VER="" if command -v clamscan >/dev/null 2>&1; then AV_INSTALLED="true" AV_VER=$(clamscan --version 2>/dev/null | head -1 | sed 's/"/\\"/g') fi # Security updates SEC_UPDATES=0 if command -v apt-get >/dev/null 2>&1; then SEC_UPDATES=$(timeout 60 apt-get --just-print upgrade 2>/dev/null | grep -ci security || true) elif command -v yum >/dev/null 2>&1; then SEC_UPDATES=$(timeout 60 yum check-update --security 2>/dev/null | grep -c "^\S" || true) fi [ -z "$SEC_UPDATES" ] && SEC_UPDATES=0 # MAC MAC_TYPE=""; MAC_STATUS="" if command -v getenforce >/dev/null 2>&1; then MAC_TYPE="selinux" MAC_STATUS=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]') elif command -v aa-status >/dev/null 2>&1; then MAC_TYPE="apparmor" if systemctl is-active apparmor >/dev/null 2>&1; then MAC_STATUS="enabled"; else MAC_STATUS="disabled"; fi fi # Software versions APACHE_VER=$(apache2 -v 2>/dev/null | head -1 | grep -oE 'Apache/[0-9.]+' | grep -oE '[0-9.]+') [ -z "$APACHE_VER" ] && APACHE_VER=$(httpd -v 2>/dev/null | head -1 | grep -oE 'Apache/[0-9.]+' | grep -oE '[0-9.]+') NGINX_VER=$(nginx -v 2>&1 | grep -oE 'nginx/[0-9.]+' | grep -oE '[0-9.]+') PHP_VER=$(php --version 2>/dev/null | head -1 | awk '{print $2}') DOTNET_VER=$(dotnet --version 2>/dev/null || true) MYSQL_VER=$(mysql --version 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i~/^[0-9]+\.[0-9]+\.[0-9]+/) {print $i; exit}}') OPENSSL_VER=$(openssl version 2>/dev/null | awk '{print $2}') OPENSSH_VER=$(ssh -V 2>&1 | awk '{print $1}' | sed 's/OpenSSH_//') PYTHON_VER=$(python3 --version 2>/dev/null | awk '{print $2}') GCC_VER=$(gcc --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) # Password policy PAM_PWQUALITY="false" [ -f /etc/security/pwquality.conf ] && PAM_PWQUALITY="true" _LD="/etc/login.defs" LOGIN_DEFS_MAX_DAYS=""; LOGIN_DEFS_MIN_DAYS=""; LOGIN_DEFS_MIN_LEN=""; LOGIN_DEFS_WARN_AGE="" if [ -f "$_LD" ]; then LOGIN_DEFS_MAX_DAYS=$(awk '/^PASS_MAX_DAYS[[:space:]]/{print $2; exit}' "$_LD" 2>/dev/null) LOGIN_DEFS_MIN_DAYS=$(awk '/^PASS_MIN_DAYS[[:space:]]/{print $2; exit}' "$_LD" 2>/dev/null) LOGIN_DEFS_MIN_LEN=$(awk '/^PASS_MIN_LEN[[:space:]]/{print $2; exit}' "$_LD" 2>/dev/null) LOGIN_DEFS_WARN_AGE=$(awk '/^PASS_WARN_AGE[[:space:]]/{print $2; exit}' "$_LD" 2>/dev/null) fi PAM_MINLEN="" if [ -f /etc/security/pwquality.conf ]; then PAM_MINLEN=$(awk -F= '/^[[:space:]]*minlen[[:space:]]*=/{gsub(/ /,"",$2); print $2; exit}' /etc/security/pwquality.conf 2>/dev/null) fi # Privileged accounts (sudo / wheel group members + sudoers direct entries) PRIV_ACCOUNTS_JSON="[]" _PRIV_LIST=$( { getent group sudo 2>/dev/null | cut -d: -f4 | tr ',' '\n' getent group wheel 2>/dev/null | cut -d: -f4 | tr ',' '\n' cat /etc/sudoers /etc/sudoers.d/* 2>/dev/null \ | awk '/^[^#%[:space:]]/ && /ALL=/{print $1}' \ | grep -Ev '^(Defaults|root)'; } \ | sort -u | grep -v '^$' ) if [ -n "$_PRIV_LIST" ]; then PRIV_ACCOUNTS_JSON="[" _PA_FIRST=1 while IFS= read -r u; do [ -z "$u" ] && continue [ "$_PA_FIRST" -eq 0 ] && PRIV_ACCOUNTS_JSON="${PRIV_ACCOUNTS_JSON}," PRIV_ACCOUNTS_JSON="${PRIV_ACCOUNTS_JSON}\"$(esc "$u")\"" _PA_FIRST=0 done <<< "$_PRIV_LIST" PRIV_ACCOUNTS_JSON="${PRIV_ACCOUNTS_JSON}]" fi # Logrotate 180-day check (syslog / global) # 使用 [[:space:]] 取代 \s,相容 mawk(Ubuntu 預設 awk) LOG_ROTATE_180="false" LOG_ROTATE_DETAIL="" if [ -f /etc/logrotate.conf ]; then _LR_FILES=$(cat /etc/logrotate.conf /etc/logrotate.d/* 2>/dev/null || true) _LR_MAX=$(printf '%s\n' "$_LR_FILES" \ | awk '/^[[:space:]]*rotate[[:space:]]+[0-9]+/{v=$2+0; if(v>m)m=v} END{print m+0}') _LR_WEEKLY=$(printf '%s\n' "$_LR_FILES" | grep -c '^[[:space:]]*weekly' 2>/dev/null) _LR_MONTHLY=$(printf '%s\n' "$_LR_FILES" | grep -c '^[[:space:]]*monthly' 2>/dev/null) _LR_MAX="${_LR_MAX:-0}" _LR_WEEKLY="${_LR_WEEKLY:-0}" _LR_MONTHLY="${_LR_MONTHLY:-0}" if [ "$_LR_MAX" -ge 180 ] 2>/dev/null; then LOG_ROTATE_180="true"; LOG_ROTATE_DETAIL="daily/rotate ${_LR_MAX}(≥180天)" elif [ "$_LR_WEEKLY" -gt 0 ] && [ "$_LR_MAX" -ge 26 ] 2>/dev/null; then LOG_ROTATE_180="true"; LOG_ROTATE_DETAIL="weekly/rotate ${_LR_MAX}(≥26週)" elif [ "$_LR_MONTHLY" -gt 0 ] && [ "$_LR_MAX" -ge 6 ] 2>/dev/null; then LOG_ROTATE_180="true"; LOG_ROTATE_DETAIL="monthly/rotate ${_LR_MAX}(≥6月)" else _LR_PERIOD="daily" [ "$_LR_WEEKLY" -gt 0 ] && _LR_PERIOD="weekly" [ "$_LR_MONTHLY" -gt 0 ] && _LR_PERIOD="monthly" LOG_ROTATE_DETAIL="${_LR_PERIOD}/rotate ${_LR_MAX}(未達 180 天)" fi fi # Apache logrotate 180-day check APACHE_LOGROTATE_180="false" APACHE_LOGROTATE_DETAIL="未安裝 Apache" for _AC in /etc/logrotate.d/apache2 /etc/logrotate.d/httpd /etc/logrotate.d/nginx; do [ -f "$_AC" ] || continue _AC_PERIOD=$(awk '/^[[:space:]]*(daily|weekly|monthly)[[:space:]]*$/{print $1; exit}' "$_AC" 2>/dev/null) _AC_COUNT=$(awk '/^[[:space:]]*rotate[[:space:]]+[0-9]+/{print $2+0; exit}' "$_AC" 2>/dev/null) _AC_COUNT="${_AC_COUNT:-0}" _AC_PERIOD="${_AC_PERIOD:-daily}" APACHE_LOGROTATE_DETAIL="${_AC_PERIOD}/rotate ${_AC_COUNT}" if [ "$_AC_PERIOD" = "daily" ] && [ "$_AC_COUNT" -ge 180 ] 2>/dev/null; then APACHE_LOGROTATE_180="true" elif [ "$_AC_PERIOD" = "weekly" ] && [ "$_AC_COUNT" -ge 26 ] 2>/dev/null; then APACHE_LOGROTATE_180="true" elif [ "$_AC_PERIOD" = "monthly" ] && [ "$_AC_COUNT" -ge 6 ] 2>/dev/null; then APACHE_LOGROTATE_180="true" fi break done # Accounts detail (UID ≥ 1000 + root,排除 nologin/false shell) ACCOUNTS_DETAIL_JSON="[]" _USERS_LIST=$(awk -F: '($3==0||$3>=1000)&&$1!="nobody"&&$7!~/nologin|false/{print $1}' /etc/passwd 2>/dev/null) if [ -n "$_USERS_LIST" ]; then _SUDO_MEMBERS=$( { getent group sudo 2>/dev/null | cut -d: -f4 | tr ',' '\n' getent group wheel 2>/dev/null | cut -d: -f4 | tr ',' '\n'; } \ | sort -u | grep -v '^$' ) ACCOUNTS_DETAIL_JSON="[" _AD_FIRST=1 while IFS= read -r u; do [ -z "$u" ] && continue IS_SUDO="false" echo "$_SUDO_MEMBERS" | grep -q "^${u}$" 2>/dev/null && IS_SUDO="true" [ "$u" = "root" ] && IS_SUDO="true" USER_GROUPS=$(id -Gn "$u" 2>/dev/null | tr ' ' ',') USER_SHELL=$(awk -F: -v u="$u" '$1==u{print $7}' /etc/passwd 2>/dev/null) if command -v chage >/dev/null 2>&1; then # LC_ALL=C 強制英文輸出,避免 locale 造成 pattern 比對失敗 # gsub(/^[[:space:]]*/,...) 同時去除 tab 與空白 _CHAGE=$(LC_ALL=C chage -l "$u" 2>/dev/null) LAST_CHG=$(echo "$_CHAGE" | awk -F: '/Last password change/{gsub(/^[[:space:]]*/,"",$2);print $2}') PW_EXP=$(echo "$_CHAGE" | awk -F: '/Password expires/{gsub(/^[[:space:]]*/,"",$2);print $2}') ACCT_EXP=$(echo "$_CHAGE" | awk -F: '/Account expires/{gsub(/^[[:space:]]*/,"",$2);print $2}') MAX_D=$(echo "$_CHAGE" | awk -F: '/Maximum number/{gsub(/^[[:space:]]*/,"",$2);print $2}') MIN_D=$(echo "$_CHAGE" | awk -F: '/Minimum number/{gsub(/^[[:space:]]*/,"",$2);print $2}') WARN_D=$(echo "$_CHAGE" | awk -F: '/Number of days of warning/{gsub(/^[[:space:]]*/,"",$2);print $2}') else LAST_CHG="N/A"; PW_EXP="N/A"; ACCT_EXP="N/A"; MAX_D="N/A"; MIN_D="N/A"; WARN_D="N/A" fi [ -z "$PW_EXP" ] && PW_EXP="never" [ -z "$ACCT_EXP" ] && ACCT_EXP="never" [ -z "$MAX_D" ] && MAX_D="99999" [ -z "$MIN_D" ] && MIN_D="0" [ -z "$WARN_D" ] && WARN_D="7" [ "$_AD_FIRST" -eq 0 ] && ACCOUNTS_DETAIL_JSON="${ACCOUNTS_DETAIL_JSON}," ACCOUNTS_DETAIL_JSON="${ACCOUNTS_DETAIL_JSON}{\"user\":\"$(esc "$u")\",\"is_sudo\":${IS_SUDO},\"groups\":\"$(esc "$USER_GROUPS")\",\"chage\":{\"shell\":\"$(esc "$USER_SHELL")\",\"last_change\":\"$(esc "$LAST_CHG")\",\"pw_expires\":\"$(esc "$PW_EXP")\",\"acct_expires\":\"$(esc "$ACCT_EXP")\",\"max_days\":\"$(esc "$MAX_D")\",\"min_days\":\"$(esc "$MIN_D")\",\"warn_days\":\"$(esc "$WARN_D")\"}}" _AD_FIRST=0 done <<< "$_USERS_LIST" ACCOUNTS_DETAIL_JSON="${ACCOUNTS_DETAIL_JSON}]" fi # Failed SSH logins (24h) — journalctl 優先,fallback auth.log/secure FAILED_LOGINS_24H=0 _JFL=$(journalctl _SYSTEMD_UNIT=sshd.service --since "24 hours ago" --no-pager -q 2>/dev/null \ | grep -c "Failed password\|Invalid user" 2>/dev/null) _JFL="${_JFL:-0}" [ "${_JFL:-0}" -gt 0 ] 2>/dev/null && FAILED_LOGINS_24H=$_JFL if [ "${FAILED_LOGINS_24H:-0}" -eq 0 ] 2>/dev/null; then for _LOG in /var/log/auth.log /var/log/secure; do [ -f "$_LOG" ] || continue _FL=$(grep -c "Failed password\|Invalid user" "$_LOG" 2>/dev/null) _FL="${_FL:-0}" [ "${_FL:-0}" -gt "${FAILED_LOGINS_24H:-0}" ] 2>/dev/null && FAILED_LOGINS_24H=$_FL break done fi FAILED_LOGINS_24H="${FAILED_LOGINS_24H:-0}" # Web service status — 優先找正在執行的服務,避免停用的 nginx 單元搶先 WEB_SERVICE_NAME="" WEB_SERVICE_STATUS="not_installed" for _WS in nginx apache2 httpd; do if systemctl is-active "$_WS" >/dev/null 2>&1; then WEB_SERVICE_NAME="$_WS" WEB_SERVICE_STATUS="active" break fi done if [ -z "$WEB_SERVICE_NAME" ]; then for _WS in nginx apache2 httpd; do if systemctl cat "$_WS" >/dev/null 2>&1; then WEB_SERVICE_NAME="$_WS" WEB_SERVICE_STATUS="inactive" break fi done fi # HTTP localhost check + security headers + SSL cert HTTP_CHECK_CODE=0 HTTP_CHECK_MS=0 HTTP_HEADER_CSP="" HTTP_HEADER_HSTS="" HTTP_HEADER_XFO="" HTTP_HEADER_XCTO="" HTTP_HEADER_XXP="" HTTP_HEADER_RP="" HTTP_HEADER_PP="" HTTP_HEADER_SERVER="" SSL_CERT_EXPIRY_DATE="" SSL_CERT_DAYS_LEFT=-1 if [ "$WEB_SERVICE_STATUS" = "active" ]; then # HTTP response code + time _HR=$(curl -s -o /dev/null -w "%{http_code}:%{time_total}" \ http://localhost/ --max-time 5 --connect-timeout 3 2>/dev/null || echo "0:0") HTTP_CHECK_CODE=$(echo "$_HR" | cut -d: -f1) _HT=$(echo "$_HR" | cut -d: -f2) HTTP_CHECK_MS=$(awk "BEGIN{printf \"%d\", ${_HT:-0} * 1000}" 2>/dev/null || echo 0) # Security headers: use FQDN as Host header so the correct vhost is matched _FQDN=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost") _HEADER_URL="http://localhost/" _HEADER_OPTS=(-sI --max-time 5 --connect-timeout 3 -H "Host: $_FQDN") if curl -sk --max-time 5 --connect-timeout 3 -o /dev/null "https://localhost/" -H "Host: $_FQDN" 2>/dev/null; then _HEADER_URL="https://localhost/" _HEADER_OPTS=(-skI --max-time 5 --connect-timeout 3 -H "Host: $_FQDN") fi _HEADERS=$(curl "${_HEADER_OPTS[@]}" "$_HEADER_URL" 2>/dev/null || true) HTTP_HEADER_CSP=$(printf '%s' "$_HEADERS" | grep -i "^content-security-policy:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_HSTS=$(printf '%s' "$_HEADERS" | grep -i "^strict-transport-security:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_XFO=$(printf '%s' "$_HEADERS" | grep -i "^x-frame-options:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_XCTO=$(printf '%s' "$_HEADERS" | grep -i "^x-content-type-options:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_XXP=$(printf '%s' "$_HEADERS" | grep -i "^x-xss-protection:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_RP=$(printf '%s' "$_HEADERS" | grep -i "^referrer-policy:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_PP=$(printf '%s' "$_HEADERS" | grep -i "^permissions-policy:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') HTTP_HEADER_SERVER=$(printf '%s' "$_HEADERS" | grep -i "^server:" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//' | tr -d '\r\n') # SSL/TLS comprehensive security check SSL_TLS_SSLV3="false"; SSL_TLS_TLS10="false"; SSL_TLS_TLS11="false" SSL_TLS_TLS12="false"; SSL_TLS_TLS13="false" SSL_TLS_FORWARD_SECRECY="false"; SSL_TLS_WEAK_CIPHER="false" SSL_TLS_CERT_BITS=""; SSL_TLS_CERT_SIG=""; SSL_TLS_CERT_ISSUER="" if command -v openssl >/dev/null 2>&1; then _DOMAIN=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost") # Cert expiry + details (single connection) _CERT_RAW=$(echo Q | timeout 8 openssl s_client \ -connect "localhost:443" -servername "$_DOMAIN" 2>/dev/null || true) _CERT_DATES=$(printf '%s' "$_CERT_RAW" | openssl x509 -noout -dates 2>/dev/null || true) if [ -n "$_CERT_DATES" ]; then SSL_CERT_EXPIRY_DATE=$(echo "$_CERT_DATES" | grep "^notAfter=" | cut -d= -f2 | tr -d '\r\n' || true) if [ -n "$SSL_CERT_EXPIRY_DATE" ]; then _EXP_EPOCH=$(date -d "$SSL_CERT_EXPIRY_DATE" +%s 2>/dev/null || echo 0) _NOW_EPOCH=$(date +%s) SSL_CERT_DAYS_LEFT=$(( (_EXP_EPOCH - _NOW_EPOCH) / 86400 )) fi fi _CERT_TEXT=$(printf '%s' "$_CERT_RAW" | openssl x509 -noout -text 2>/dev/null || true) SSL_TLS_CERT_BITS=$(printf '%s' "$_CERT_TEXT" | grep -oE "Public-Key: \([0-9]+ bit\)" | grep -oE "[0-9]+" | head -1 || true) SSL_TLS_CERT_SIG=$(printf '%s' "$_CERT_TEXT" | grep "Signature Algorithm" | head -1 | awk '{print $NF}' | tr -d '\r\n' || true) SSL_TLS_CERT_ISSUER=$(printf '%s' "$_CERT_RAW" | openssl x509 -noout -issuer 2>/dev/null \ | sed 's/^issuer=//' | tr -d '\r\n' || true) # Helper: test if a TLS protocol handshake succeeds _ssl_proto_ok() { echo Q | timeout 5 openssl s_client -connect "localhost:443" \ -servername "$_DOMAIN" "$1" 2>&1 \ | grep -qE "Cipher[[:space:]]+:[[:space:]]+[A-Z0-9_]" && echo "true" || echo "false" } SSL_TLS_SSLV3=$(_ssl_proto_ok "-ssl3" 2>/dev/null || echo "false") SSL_TLS_TLS10=$(_ssl_proto_ok "-tls1" 2>/dev/null || echo "false") SSL_TLS_TLS11=$(_ssl_proto_ok "-tls1_1" 2>/dev/null || echo "false") SSL_TLS_TLS12=$(_ssl_proto_ok "-tls1_2" 2>/dev/null || echo "false") SSL_TLS_TLS13=$(_ssl_proto_ok "-tls1_3" 2>/dev/null || echo "false") # Forward secrecy: check negotiated cipher suite _CURR_CIPHER=$(printf '%s' "$_CERT_RAW" | grep -E "Cipher[[:space:]]+" | tail -1 | awk '{print $NF}' || true) echo "$_CURR_CIPHER" | grep -qE "^(ECDHE|DHE|TLS_AES|TLS_CHACHA)" \ && SSL_TLS_FORWARD_SECRECY="true" || SSL_TLS_FORWARD_SECRECY="false" # Weak ciphers: RC4, DES, NULL, EXPORT, anonymous echo Q | timeout 5 openssl s_client -connect "localhost:443" -servername "$_DOMAIN" \ -cipher "RC4:DES:3DES:EXP:NULL:aNULL" 2>&1 \ | grep -qE "Cipher[[:space:]]+:[[:space:]]+[A-Z0-9_]" \ && SSL_TLS_WEAK_CIPHER="true" || SSL_TLS_WEAK_CIPHER="false" fi fi HTTP_CHECK_CODE="${HTTP_CHECK_CODE:-0}" HTTP_CHECK_MS="${HTTP_CHECK_MS:-0}" SSL_CERT_DAYS_LEFT="${SSL_CERT_DAYS_LEFT:--1}" # Web error log check (last 200 lines) WEB_ERROR_COUNT=0 WEB_ERROR_SAMPLE="" if [ "$WEB_SERVICE_STATUS" = "active" ] && [ -n "$WEB_SERVICE_NAME" ]; then _ERR_LOG="" for _EL in \ "/var/log/${WEB_SERVICE_NAME}/error.log" \ "/var/log/apache2/error.log" \ "/var/log/nginx/error.log" \ "/var/log/httpd/error_log"; do if [ -f "$_EL" ] && [ -r "$_EL" ]; then _ERR_LOG="$_EL" break fi done if [ -n "$_ERR_LOG" ]; then WEB_ERROR_COUNT=$(tail -200 "$_ERR_LOG" 2>/dev/null \ | grep -c -iE '\[(error|crit|emerg|alert)\]' 2>/dev/null || true) WEB_ERROR_SAMPLE=$(tail -200 "$_ERR_LOG" 2>/dev/null \ | grep -iE '\[(error|crit|emerg|alert)\]' | tail -3 \ | awk 'NR>1{printf "§"}{printf "%s",$0}' || true) fi fi WEB_ERROR_COUNT="${WEB_ERROR_COUNT:-0}" # Database service status DB_SERVICE_NAME="" DB_SERVICE_STATUS="not_installed" for _DS in mysql mysqld mariadb postgresql postgresql-15 postgresql-14 postgresql-16; do if systemctl cat "$_DS" >/dev/null 2>&1; then DB_SERVICE_NAME="$_DS" if systemctl is-active "$_DS" >/dev/null 2>&1; then DB_SERVICE_STATUS="active" else DB_SERVICE_STATUS="inactive" fi break fi done # Backup check BACKUP_JSON="[]" _BK_BUF="" _NOW_TS=$(date +%s) for _BK_TYPE in data system; do _BK_SCRIPT_FOUND="false"; _BK_SCRIPT_PATH="" _BK_IN_CRON="false" _BK_DIR_FOUND="false"; _BK_DIR_PATH="" _BK_LAST_TIME=""; _BK_LAST_HOURS=-1 _BK_FILES_24H=0; _BK_SIZE="" # 找腳本 for _SP in \ "/backup/${_BK_TYPE}/${_BK_TYPE}.sh" \ "/home/backup/${_BK_TYPE}/${_BK_TYPE}.sh"; do if [ -f "$_SP" ]; then _BK_SCRIPT_FOUND="true"; _BK_SCRIPT_PATH="$_SP"; break fi done # 找備份目錄(腳本同目錄 or 標準路徑) for _DP in \ "/backup/${_BK_TYPE}" \ "/home/backup/${_BK_TYPE}"; do if [ -d "$_DP" ]; then _BK_DIR_FOUND="true"; _BK_DIR_PATH="$_DP"; break fi done # 檢查 cron 是否有執行此腳本 if [ "$_BK_SCRIPT_FOUND" = "true" ]; then grep -rh "$_BK_SCRIPT_PATH" /etc/cron.d /etc/cron.daily /etc/cron.hourly \ /var/spool/cron /var/spool/cron/crontabs 2>/dev/null \ | grep -qv "^[[:space:]]*#" \ && _BK_IN_CRON="true" || true # 也搜尋 root crontab crontab -l 2>/dev/null | grep -q "$_BK_SCRIPT_PATH" && _BK_IN_CRON="true" || true fi # 分析備份目錄 if [ "$_BK_DIR_FOUND" = "true" ]; then # 最新檔案時間 _LATEST_TS=$(find "$_BK_DIR_PATH" -type f -printf '%T@\n' 2>/dev/null \ | sort -rn | head -1 | cut -d. -f1) if [ -n "$_LATEST_TS" ] && [ "$_LATEST_TS" -gt 0 ] 2>/dev/null; then _BK_LAST_TIME=$(date -d "@${_LATEST_TS}" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || true) _BK_LAST_HOURS=$(( (_NOW_TS - _LATEST_TS) / 3600 )) fi # 最近 24 小時的檔案數 _BK_FILES_24H=$(find "$_BK_DIR_PATH" -type f -mtime -1 2>/dev/null | wc -l) _BK_FILES_24H=${_BK_FILES_24H:-0} # 目錄大小 _BK_SIZE=$(du -sh "$_BK_DIR_PATH" 2>/dev/null | cut -f1 || true) fi [ -n "$_BK_BUF" ] && _BK_BUF="${_BK_BUF}," _BK_BUF="${_BK_BUF}{\"type\":\"${_BK_TYPE}\",\"script_found\":${_BK_SCRIPT_FOUND},\"script_path\":\"$(esc "$_BK_SCRIPT_PATH")\",\"in_cron\":${_BK_IN_CRON},\"dir_found\":${_BK_DIR_FOUND},\"dir_path\":\"$(esc "$_BK_DIR_PATH")\",\"last_backup\":\"$(esc "$_BK_LAST_TIME")\",\"last_backup_hours\":${_BK_LAST_HOURS},\"files_24h\":${_BK_FILES_24H},\"size\":\"$(esc "$_BK_SIZE")\"}" done [ -n "$_BK_BUF" ] && BACKUP_JSON="[$_BK_BUF]" # ── Log integrity check ────────────────────────────────────────── LOG_HASH_FILE="$AGENT_DIR/.log_hashes" _LI_TODAY_MIDNIGHT=$(date -d "$(date +%Y-%m-%d) 00:00:00" +%s 2>/dev/null || echo 0) _LI_CHECKED=0; _LI_CLEAN=0; _LI_NEW=0; _LI_TAMPERED=0 _LI_TAMP_BUF=""; _LI_WRITE_BUF=""; _LI_FILES_BUF="" # Per-category counters (use simple vars for portability) _li_cats="" # space-separated list of seen categories _li_cat_data="" # "CAT:checked:clean:new:tampered|..." pipe-delimited _li_baseline_hash() { grep -F " $1" "$LOG_HASH_FILE" 2>/dev/null | head -1 | awk '{print $1}'; } # Classify a log file path into a display category _li_categorize() { case "$1" in */apache2/*|*/httpd/*) echo "Apache" ;; */nginx/*) echo "Nginx" ;; */mysql/*|*/mariadb/*) echo "MySQL" ;; */postgresql/*) echo "PostgreSQL" ;; */auth.log*|*/secure*) echo "Auth" ;; */syslog*|*/messages*) echo "Syslog" ;; */kern.log*) echo "Kernel" ;; */daemon.log*) echo "Daemon" ;; */mail.log*|*/mail.err*) echo "Mail" ;; */cron.log*|*/cron*) echo "Cron" ;; *) echo "Other" ;; esac } # Get/set category counter: _li_get CAT field, _li_inc CAT field _li_get() { echo "$_li_cat_data" | tr '|' '\n' | grep "^$1:" | head -1 | cut -d: -f$(case $2 in checked)echo 2;;clean)echo 3;;new)echo 4;;tampered)echo 5;;esac) } _li_inc() { local _c="$1" _f="$2" local _old; _old=$(echo "$_li_cat_data" | tr '|' '\n' | grep "^${_c}:" | head -1) local _ch=0 _cl=0 _nw=0 _tm=0 if [ -n "$_old" ]; then _ch=$(echo "$_old" | cut -d: -f2) _cl=$(echo "$_old" | cut -d: -f3) _nw=$(echo "$_old" | cut -d: -f4) _tm=$(echo "$_old" | cut -d: -f5) _li_cat_data=$(echo "$_li_cat_data" | tr '|' '\n' | grep -v "^${_c}:" | tr '\n' '|') fi case "$_f" in checked) _ch=$((_ch+1));; clean) _cl=$((_cl+1));; new) _nw=$((_nw+1));; tampered) _tm=$((_tm+1));; esac _li_cat_data="${_li_cat_data}${_c}:${_ch}:${_cl}:${_nw}:${_tm}|" } while IFS= read -r _lf; do [ -f "$_lf" ] || continue _lmtime=$(stat -c '%Y' "$_lf" 2>/dev/null || echo 0) [ "$_LI_TODAY_MIDNIGHT" -gt 0 ] && [ "$_lmtime" -ge "$_LI_TODAY_MIDNIGHT" ] && continue _lsize=$(stat -c '%s' "$_lf" 2>/dev/null || echo 0) [ "$_lsize" -gt 209715200 ] && continue _lh=$(md5sum "$_lf" 2>/dev/null | awk '{print $1}') [ -z "$_lh" ] && continue _LI_CHECKED=$((_LI_CHECKED + 1)) _lcat=$(_li_categorize "$_lf") _li_inc "$_lcat" checked _lold=$(_li_baseline_hash "$_lf") if [ -z "$_lold" ]; then _LI_NEW=$((_LI_NEW + 1)) _li_inc "$_lcat" new _LI_WRITE_BUF="${_LI_WRITE_BUF}${_lh} ${_lf}\n" _lstatus="new" elif [ "$_lh" = "$_lold" ]; then _LI_CLEAN=$((_LI_CLEAN + 1)) _li_inc "$_lcat" clean _LI_WRITE_BUF="${_LI_WRITE_BUF}${_lh} ${_lf}\n" _lstatus="clean" else _LI_TAMPERED=$((_LI_TAMPERED + 1)) _li_inc "$_lcat" tampered [ -n "$_LI_TAMP_BUF" ] && _LI_TAMP_BUF="${_LI_TAMP_BUF}," _LI_TAMP_BUF="${_LI_TAMP_BUF}\"$(esc "$_lf")\"" _LI_WRITE_BUF="${_LI_WRITE_BUF}${_lold} ${_lf}\n" _lstatus="tampered" fi [ -n "$_LI_FILES_BUF" ] && _LI_FILES_BUF="${_LI_FILES_BUF}," _LI_FILES_BUF="${_LI_FILES_BUF}{\"path\":\"$(esc "$_lf")\",\"cat\":\"${_lcat}\",\"status\":\"${_lstatus}\"}" done < <(find /var/log -maxdepth 4 -type f \( \ -name "*.[0-9]" -o -name "*.[0-9][0-9]" -o \ -name "*.[0-9].gz" -o -name "*.[0-9][0-9].gz" -o \ -name "*.log.gz" -o -name "syslog-[0-9]*" -o \ -name "*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]" -o \ -name "*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].gz" \ \) 2>/dev/null | sort) [ -n "$_LI_WRITE_BUF" ] && printf '%b' "$_LI_WRITE_BUF" > "$LOG_HASH_FILE" 2>/dev/null || true # Build categories JSON array (awk avoids subshell variable-loss) _LI_CAT_TMP=$(mktemp) printf '%s' "$_li_cat_data" | tr '|' '\n' > "$_LI_CAT_TMP" _LI_CAT_BUF=$(awk -F: 'NF>=5 && $1!="" { if (nr>0) printf "," printf "{\"name\":\"%s\",\"checked\":%s,\"clean\":%s,\"new_files\":%s,\"tampered\":%s}", $1,$2,$3,$4,$5 nr++ }' "$_LI_CAT_TMP") rm -f "$_LI_CAT_TMP" LOG_INTEGRITY_JSON="{\"checked\":${_LI_CHECKED},\"clean\":${_LI_CLEAN},\"new_files\":${_LI_NEW},\"tampered\":${_LI_TAMPERED},\"tampered_files\":[${_LI_TAMP_BUF}],\"categories\":[${_LI_CAT_BUF}],\"files\":[${_LI_FILES_BUF}],\"checked_at\":\"$(date '+%Y-%m-%d %H:%M:%S')\"}" # 最近登入紀錄 + 失敗次數(用 \n 接行,避免 JSON 多行問題) LAST_LOGINS=$(last -n 20 2>/dev/null | head -21 | grep -v "^$" | tr '"' "'" | tr '\\' '/' | awk 'NR>1{printf "§"}{printf "%s",$0}' || true) FAILED_LOGIN_COUNT="" for _FLC_LOG in /var/log/auth.log /var/log/secure; do [ -f "$_FLC_LOG" ] || continue FAILED_LOGIN_COUNT=$(grep -c "Failed password\|authentication failure\|Invalid user" "$_FLC_LOG" 2>/dev/null) break done FAILED_LOGIN_COUNT="${FAILED_LOGIN_COUNT:-0}" # NTP servers NTP_SERVERS_JSON="[]" _NTP_BUF="" if command -v chronyc >/dev/null 2>&1; then while IFS= read -r _s; do [ -z "$_s" ] && continue [ -n "$_NTP_BUF" ] && _NTP_BUF="${_NTP_BUF}," _NTP_BUF="${_NTP_BUF}\"$(esc "$_s")\"" done < <(timeout 5 chronyc sources 2>/dev/null | awk '/^\^/ {print $2}' | head -5) elif [ -f /etc/systemd/timesyncd.conf ]; then for _s in $(grep "^NTP=" /etc/systemd/timesyncd.conf 2>/dev/null | cut -d= -f2); do [ -n "$_NTP_BUF" ] && _NTP_BUF="${_NTP_BUF}," _NTP_BUF="${_NTP_BUF}\"$(esc "$_s")\"" done fi [ -n "$_NTP_BUF" ] && NTP_SERVERS_JSON="[${_NTP_BUF}]" # 對外開放 Port(TCP + UDP) _PORTS_BUF="" while IFS=$'\t' read -r _proto _port _addr _proc; do [ -z "$_port" ] && continue [ -n "$_PORTS_BUF" ] && _PORTS_BUF="${_PORTS_BUF}," _PORTS_BUF="${_PORTS_BUF}{\"protocol\":\"$(esc "$_proto")\",\"port\":\"$(esc "$_port")\",\"service\":\"$(esc "$_proc")\",\"address\":\"$(esc "$_addr")\"}" done < <( ss -tlnp 2>/dev/null | awk 'NR>1 && $1=="LISTEN" { addr=$4; n=split(addr,p,":"); port=p[n]; sub(/:[^:]*$/,"",addr) proc="unknown"; s=$0 if (sub(/.*\(\("/, "", s)) { sub(/".*/, "", s); proc=s } gsub(/\t/, " ", proc) printf "TCP\t%s\t%s\t%s\n", port, addr, proc }' ss -ulnp 2>/dev/null | awk 'NR>1 { addr=$4; n=split(addr,p,":"); port=p[n]; sub(/:[^:]*$/,"",addr) proc="unknown"; s=$0 if (sub(/.*\(\("/, "", s)) { sub(/".*/, "", s); proc=s } gsub(/\t/, " ", proc) printf "UDP\t%s\t%s\t%s\n", port, addr, proc }' ) PORTS_JSON="[${_PORTS_BUF}]" # 系統排程(root、www-data) _CRON_BUF="" _add_cron() { local _src="$1" _line="$2" echo "$_line" | grep -qE '^[[:space:]]*(#|$)' && return [ -n "$_CRON_BUF" ] && _CRON_BUF="${_CRON_BUF}," _CRON_BUF="${_CRON_BUF}{\"source\":\"$(esc "$_src")\",\"job\":\"$(esc "$_line")\"}" } if [ -f /etc/crontab ]; then while IFS= read -r _l; do _add_cron "/etc/crontab" "$_l"; done < /etc/crontab fi for _f in /etc/cron.d/*; do [ -f "$_f" ] || continue while IFS= read -r _l; do _add_cron "cron.d/$(basename "$_f")" "$_l"; done < "$_f" done for _u in root www-data; do _uc=$(crontab -l -u "$_u" 2>/dev/null || true) [ -z "$_uc" ] && continue while IFS= read -r _l; do _add_cron "${_u} crontab" "$_l"; done <<< "$_uc" done CRON_JOBS_JSON="[${_CRON_BUF}]" # Apache 安全設定 — 只有確認 Apache 存在才收集(避免目錄存在但未執行的誤判) APACHE_SECURITY_JSON="" if [ -n "$APACHE_VER" ]; then for _adir in /etc/apache2 /etc/httpd; do [ -d "$_adir" ] || continue _ST=$(grep -rh "ServerTokens" "$_adir" 2>/dev/null | grep -v "^[[:space:]]*#" | awk '{print $2}' | tail -1) _SS=$(grep -rh "ServerSignature" "$_adir" 2>/dev/null | grep -v "^[[:space:]]*#" | awk '{print $2}' | tail -1) _TE=$(grep -rh "TraceEnable" "$_adir" 2>/dev/null | grep -v "^[[:space:]]*#" | awk '{print $2}' | tail -1) APACHE_SECURITY_JSON="{\"server_tokens\":\"$(esc "${_ST:-unknown}")\",\"server_signature\":\"$(esc "${_SS:-unknown}")\",\"trace_enable\":\"$(esc "${_TE:-unknown}")\"}" break done fi # Nginx 安全設定 — 只有確認 Nginx 存在才收集 NGINX_SECURITY_JSON="" if [ -n "$NGINX_VER" ] && [ -d /etc/nginx ]; then _NST=$(grep -rh "server_tokens" /etc/nginx 2>/dev/null | grep -v "^[[:space:]]*#" | awk '{gsub(/;/,"",$2); print $2}' | tail -1) _NGZ=$(grep -rh "gzip" /etc/nginx 2>/dev/null | grep -v "^[[:space:]]*#" | awk '/^[[:space:]]*gzip[[:space:]]/{gsub(/;/,"",$2); print $2; exit}') _NAI=$(grep -rh "autoindex" /etc/nginx 2>/dev/null | grep -v "^[[:space:]]*#" | awk '{gsub(/;/,"",$2); print $2}' | tail -1) NGINX_SECURITY_JSON="{\"server_tokens\":\"$(esc "${_NST:-unknown}")\",\"gzip\":\"$(esc "${_NGZ:-unknown}")\",\"autoindex\":\"$(esc "${_NAI:-unknown}")\"}" fi # Apache vhost SSL 憑證 APACHE_CERTS_EXISTS=false _CERTS_BUF="" for _vhost in /etc/apache2/sites-enabled/* /etc/httpd/conf.d/*.conf; do [ -f "$_vhost" ] || continue _certfile=$(grep -i "^[[:space:]]*SSLCertificateFile" "$_vhost" 2>/dev/null | grep -v "^[[:space:]]*#" | awk '{print $2}' | head -1 || true) [ -z "$_certfile" ] && continue if [ -f "$_certfile" ] && command -v openssl >/dev/null 2>&1; then APACHE_CERTS_EXISTS=true _vname=$(basename "$_vhost" .conf) _expiry=$(openssl x509 -noout -enddate -in "$_certfile" 2>/dev/null | cut -d= -f2 || echo "N/A") [ -n "$_CERTS_BUF" ] && _CERTS_BUF="${_CERTS_BUF}," _CERTS_BUF="${_CERTS_BUF}{\"name\":\"$(esc "$_vname")\",\"certificate_file\":\"$(esc "$_certfile")\",\"certificate_expiry\":\"$(esc "$_expiry")\"}" fi done # 螢幕保護設定(GNOME / server 通常無此項) SCREENSAVER_CONFIGURED=false SCREENSAVER_TIMEOUT="" if command -v gsettings >/dev/null 2>&1; then _SS_LOCK=$(gsettings get org.gnome.desktop.screensaver lock-enabled 2>/dev/null || echo "false") _SS_IDLE=$(gsettings get org.gnome.session idle-delay 2>/dev/null || echo "") if [ "$_SS_LOCK" = "true" ]; then SCREENSAVER_CONFIGURED=true SCREENSAVER_TIMEOUT="$(esc "$_SS_IDLE")" fi fi _b64() { printf '%s' "$1" | base64 -w0 2>/dev/null || printf '%s' "$1" | base64; } python3 << PYJSON import json, base64, sys def b(s): try: return base64.b64decode(s + "==").decode('utf-8', errors='replace') except: return "" def jl(s, fb=None): try: return json.loads(s) if str(s).strip() else fb except: return fb def ni(s, d=0): try: return int(s) except: return d def nf(s, d=0.0): try: return float(s) except: return d data = { "hostname": b("$(_b64 "$HOSTNAME_VAL")"), "os_release": { "PRETTY_NAME": b("$(_b64 "$PRETTY_NAME")"), "ID": b("$(_b64 "$OS_ID")"), "VERSION_ID": b("$(_b64 "$OS_VERSION_ID")"), }, "kernel_version": b("$(_b64 "$KERNEL")"), "timezone": b("$(_b64 "$TZ_VAL")"), "ip": b("$(_b64 "$IP_VAL")"), "uptime": b("$(_b64 "$UPTIME_VAL")"), "boot_time": b("$(_b64 "$BOOT_TIME")"), "cpu_model": b("$(_b64 "$CPU_MODEL")"), "cpu_count": ni("$CPU_COUNT"), "memory": { "total_gb": nf("$MEM_TOTAL_GB"), "used_gb": nf("$MEM_USED_GB"), "free_gb": nf("$MEM_FREE_GB"), "percent": ni("$MEM_PCT"), }, "disk_info": jl(b("$(_b64 "$DISK_JSON")"), []), "ntp_status": b("$(_b64 "$NTP_STATUS")"), "ntp_servers": jl(b("$(_b64 "$NTP_SERVERS_JSON")"), []), "firewall_status": b("$(_b64 "$FW_STATUS")"), "firewall_type": b("$(_b64 "$FW_TYPE")"), "fail2ban_status": b("$(_b64 "$F2B_STATUS")"), "antivirus_installed": "$AV_INSTALLED" == "true", "antivirus_version": b("$(_b64 "$AV_VER")"), "security_updates": ni("$SEC_UPDATES"), "mac_type": b("$(_b64 "$MAC_TYPE")"), "mac_status": b("$(_b64 "$MAC_STATUS")"), "apache_version": b("$(_b64 "$APACHE_VER")"), "nginx_version": b("$(_b64 "$NGINX_VER")"), "php_version": b("$(_b64 "$PHP_VER")"), "dotnet_version": b("$(_b64 "$DOTNET_VER")"), "mysql_version": b("$(_b64 "$MYSQL_VER")"), "openssl_version": b("$(_b64 "$OPENSSL_VER")"), "openssh_version": b("$(_b64 "$OPENSSH_VER")"), "python_version": b("$(_b64 "$PYTHON_VER")"), "gcc_version": b("$(_b64 "$GCC_VER")"), "ssh_config": { "port": b("$(_b64 "$SSH_PORT")"), "permit_root_login": b("$(_b64 "$PERMIT_ROOT")"), "pubkey_auth": b("$(_b64 "$PUBKEY_AUTH")"), "password_auth": b("$(_b64 "$PASSWORD_AUTH")"), "max_auth_tries": b("$(_b64 "$MAX_AUTH_TRIES")"), }, "password_policy": { "pam_pwquality": "$PAM_PWQUALITY" == "true", "pam_minlen": b("$(_b64 "$PAM_MINLEN")") or None, "max_days": b("$(_b64 "$LOGIN_DEFS_MAX_DAYS")") or None, "min_days": b("$(_b64 "$LOGIN_DEFS_MIN_DAYS")") or None, "min_length": b("$(_b64 "$LOGIN_DEFS_MIN_LEN")") or None, "warn_age": b("$(_b64 "$LOGIN_DEFS_WARN_AGE")") or None, }, "privileged_accounts": jl(b("$(_b64 "$PRIV_ACCOUNTS_JSON")"), []), "log_rotate_180days": "$LOG_ROTATE_180" == "true", "log_rotate_detail": b("$(_b64 "$LOG_ROTATE_DETAIL")"), "apache_logrotate_180days": "$APACHE_LOGROTATE_180" == "true", "apache_logrotate_detail": b("$(_b64 "$APACHE_LOGROTATE_DETAIL")"), "accounts_detail": jl(b("$(_b64 "$ACCOUNTS_DETAIL_JSON")"), []), "failed_logins_24h": ni("$FAILED_LOGINS_24H"), "last_logins": b("$(_b64 "$LAST_LOGINS")"), "failed_login_count": ni("$FAILED_LOGIN_COUNT"), "listening_ports_parsed": jl(b("$(_b64 "$PORTS_JSON")"), []), "cron_jobs": jl(b("$(_b64 "$CRON_JOBS_JSON")"), []), "apache_security": jl(b("$(_b64 "${APACHE_SECURITY_JSON:-}")"), None), "nginx_security": jl(b("$(_b64 "${NGINX_SECURITY_JSON:-}")"), None), "screensaver_configured": "$SCREENSAVER_CONFIGURED" == "true", "screensaver_timeout": b("$(_b64 "$SCREENSAVER_TIMEOUT")"), "apache_sites_certificates": { "exists": "$APACHE_CERTS_EXISTS" == "true", "details": jl(b("$(_b64 "[${_CERTS_BUF}]")"), []), }, "web_service": { "name": b("$(_b64 "$WEB_SERVICE_NAME")"), "status": b("$(_b64 "$WEB_SERVICE_STATUS")"), "http_code": ni("$HTTP_CHECK_CODE"), "response_ms": ni("$HTTP_CHECK_MS"), "ssl_cert_expiry": b("$(_b64 "$SSL_CERT_EXPIRY_DATE")"), "ssl_cert_days_left": ni("$SSL_CERT_DAYS_LEFT"), "ssl_tls": { "sslv3": "$SSL_TLS_SSLV3" == "true", "tls10": "$SSL_TLS_TLS10" == "true", "tls11": "$SSL_TLS_TLS11" == "true", "tls12": "$SSL_TLS_TLS12" == "true", "tls13": "$SSL_TLS_TLS13" == "true", "forward_secrecy": "$SSL_TLS_FORWARD_SECRECY" == "true", "weak_cipher": "$SSL_TLS_WEAK_CIPHER" == "true", "cert_bits": b("$(_b64 "$SSL_TLS_CERT_BITS")"), "cert_sig": b("$(_b64 "$SSL_TLS_CERT_SIG")"), "cert_issuer": b("$(_b64 "$SSL_TLS_CERT_ISSUER")"), }, "http_headers": { "csp": b("$(_b64 "$HTTP_HEADER_CSP")"), "hsts": b("$(_b64 "$HTTP_HEADER_HSTS")"), "x_frame_options": b("$(_b64 "$HTTP_HEADER_XFO")"), "x_content_type_options": b("$(_b64 "$HTTP_HEADER_XCTO")"), "x_xss_protection": b("$(_b64 "$HTTP_HEADER_XXP")"), "referrer_policy": b("$(_b64 "$HTTP_HEADER_RP")"), "permissions_policy": b("$(_b64 "$HTTP_HEADER_PP")"), "server": b("$(_b64 "$HTTP_HEADER_SERVER")"), }, "error_count": ni("$WEB_ERROR_COUNT"), "error_sample": b("$(_b64 "$WEB_ERROR_SAMPLE")"), }, "db_service": { "name": b("$(_b64 "$DB_SERVICE_NAME")"), "status": b("$(_b64 "$DB_SERVICE_STATUS")"), }, "backup": jl(b("$(_b64 "$BACKUP_JSON")"), []), "log_integrity": jl(b("$(_b64 "$LOG_INTEGRITY_JSON")"), {}), "agent_version": b("$(_b64 "$AGENT_VERSION")"), } # ── backup_check:掃描備份目錄,記錄每日備份情況(近 30 天)──────── import os, subprocess from datetime import datetime, timedelta def _du(path, timeout=15): try: r = subprocess.run(['du', '-sh', path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) return r.stdout.decode('utf-8', errors='replace').split('\t')[0].strip() if r.returncode == 0 else '?' except Exception: return '?' _now_dt = datetime.now() _days_n = 30 _bk_check = [] try: for _root in ['/backup', '/home/backup']: if not os.path.isdir(_root): continue _root_size = _du(_root) _subdirs_out = [] try: _entries = sorted( [e for e in os.scandir(_root) if e.is_dir()], key=lambda e: e.name ) except Exception: _entries = [] for _entry in _entries: _sd_path = _entry.path _sd_name = _entry.name # 收集所有檔案及 mtime _all_files = [] try: for _dp, _dn, _fns in os.walk(_sd_path): for _fn in _fns: _fp = os.path.join(_dp, _fn) try: _st = os.stat(_fp) _all_files.append({'name': _fn, 'mtime': _st.st_mtime, 'size': _st.st_size}) except Exception: pass except Exception: pass # 最新檔案 _newest_file = '' _newest_time = '' _days_ago = 999 if _all_files: _nw = max(_all_files, key=lambda f: f['mtime']) _newest_file = _nw['name'] _nw_dt = datetime.fromtimestamp(_nw['mtime']) _newest_time = _nw_dt.strftime('%Y-%m-%d %H:%M:%S') _days_ago = (_now_dt - _nw_dt).days _sd_size = _du(_sd_path) # 每日備份統計(近 30 天) _daily = {} _dp_count = 0 for _d in range(_days_n - 1, -1, -1): _ds = (_now_dt - timedelta(days=_d)).replace(hour=0, minute=0, second=0, microsecond=0) _de = _ds + timedelta(days=1) _dstr = _ds.strftime('%Y-%m-%d') _df = [f for f in _all_files if _ds.timestamp() <= f['mtime'] < _de.timestamp()] _cnt = len(_df) _sz = sum(f['size'] for f in _df) _ok = _cnt > 0 if _ok: _dp_count += 1 _daily[_dstr] = {'count': _cnt, 'size': _sz, 'ok': _ok, 'files': sorted([f['name'] for f in _df])} _subdirs_out.append({ 'name': _sd_name, 'size': _sd_size, 'newest_file': _newest_file, 'newest_time': _newest_time, 'days_ago': _days_ago, 'daily_check': _daily, 'daily_pass': _dp_count, 'daily_total': _days_n, }) _bk_check.append({'path': _root, 'size': _root_size, 'subdirs': _subdirs_out}) except Exception as _bk_e: sys.stderr.write('backup_check error: ' + str(_bk_e) + '\n') data['backup_check'] = _bk_check # 移除孤立 surrogate(非 UTF-8 檔名等來源),確保輸出對 PHP json_decode 相容 def _sanitize_json(o): if isinstance(o, str): return o.encode('utf-8', errors='replace').decode('utf-8') if isinstance(o, dict): return {_sanitize_json(k): _sanitize_json(v) for k, v in o.items()} if isinstance(o, list): return [_sanitize_json(i) for i in o] return o try: print(json.dumps(_sanitize_json(data))) except Exception as _e: sys.stderr.write('json.dumps error: ' + str(_e) + '\n') PYJSON } collect_sbom() { python3 << 'PYSBOM' import json, subprocess, os, sys from datetime import date today = date.today().isoformat() def run(cmd, timeout=120): try: r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) return r.stdout.decode('utf-8', errors='replace') if r.returncode == 0 else '' except Exception: return '' # APT packages apt_pkgs = [] out = run(['dpkg-query', '-W', '--showformat=${Package}\t${Version}\t${Description}\n']) for line in out.splitlines(): parts = line.split('\t', 2) if len(parts) < 2 or not parts[0].strip(): continue name, ver = parts[0].strip(), parts[1].strip() desc = parts[2].strip() if len(parts) > 2 else '' lic = '' cp = '/usr/share/doc/' + name + '/copyright' if os.path.isfile(cp): try: with open(cp, errors='replace') as f: for l in f: if l.startswith('License:'): w = l[8:].strip().split() lic = w[0] if w else '' break except Exception: pass apt_pkgs.append({'name': name, 'version': ver, 'license': lic, 'description': desc}) # pip packages pip_pkgs = [] for pip_cmd in ['pip3', 'pip']: out = run([pip_cmd, 'list', '--format=json'], timeout=60) if out: try: pkgs_list = json.loads(out) pkg_descs = {} if pkgs_list: names = [p.get('name', '') for p in pkgs_list if p.get('name')] show_out = run([pip_cmd, 'show'] + names, timeout=180) cur = {} for l in show_out.splitlines(): if l.startswith('---'): if cur.get('Name'): pkg_descs[cur['Name'].lower()] = cur.get('Summary', '') cur = {} elif ':' in l: k, v = l.split(':', 1) cur[k.strip()] = v.strip() if cur.get('Name'): pkg_descs[cur['Name'].lower()] = cur.get('Summary', '') for p in pkgs_list: pname = p.get('name', '') pip_pkgs.append({'name': pname, 'version': p.get('version', ''), 'license': 'unknown', 'description': pkg_descs.get(pname.lower(), '')}) break except Exception: continue # npm global packages npm_pkgs = [] out = run(['npm', 'list', '-g', '--depth=0', '--json', '--long'], timeout=60) if out: try: for pkg_name, info in json.loads(out).get('dependencies', {}).items(): npm_pkgs.append({'name': pkg_name, 'version': info.get('version', ''), 'license': '', 'description': info.get('description', '')}) except Exception: pass # composer global packages composer_pkgs = [] out = run(['composer', 'global', 'show', '--format=json'], timeout=60) if out: try: for p in json.loads(out).get('installed', []): composer_pkgs.append({'name': p.get('name', ''), 'version': p.get('version', ''), 'license': '', 'description': p.get('description', '')}) except Exception: # fallback: plain text output for line in run(['composer', 'global', 'show'], timeout=60).splitlines(): parts = line.split() if len(parts) >= 2: composer_pkgs.append({'name': parts[0], 'version': parts[1], 'license': '', 'description': ''}) print(json.dumps({ 'collected_at': today, 'apt': apt_pkgs, 'pip': pip_pkgs, 'npm': npm_pkgs, 'composer': composer_pkgs })) PYSBOM } # SBOM:每天只收集一次,結果存成 cache 檔(避免 ARG_MAX 限制) SBOM_STAMP="$AGENT_DIR/.sbom_date" SBOM_CACHE="$AGENT_DIR/.sbom_cache.json" TODAY=$(date +%Y-%m-%d) if [ "$(cat "$SBOM_STAMP" 2>/dev/null)" != "$TODAY" ]; then _SBOM_TMP=$(mktemp "$AGENT_DIR/.sbom.XXXXXX") collect_sbom > "$_SBOM_TMP" 2>/dev/null # 驗證 JSON 有效才存入 cache if python3 -c "import sys,json; json.load(open(sys.argv[1]))" "$_SBOM_TMP" 2>/dev/null; then mv "$_SBOM_TMP" "$SBOM_CACHE" echo "$TODAY" > "$SBOM_STAMP" else rm -f "$_SBOM_TMP" fi fi _TMP=$(mktemp "$AGENT_DIR/.report.XXXXXX") _COLLECT_ERR=$(mktemp) collect > "$_TMP.raw" 2>"$_COLLECT_ERR" if [ -s "$_COLLECT_ERR" ]; then _log "collect() stderr: $(head -c 800 "$_COLLECT_ERR" | tr '\n' ' ')" fi rm -f "$_COLLECT_ERR" # 將 sbom 與 av_result 插入 JSON payload AV_RESULT_FILE="$AGENT_DIR/.av_result.json" python3 - "$_TMP.raw" "$SBOM_CACHE" "$AV_RESULT_FILE" > "$_TMP" 2>/dev/null << 'PYEOF' import sys, json, os raw = open(sys.argv[1]).read() sbom_cache = sys.argv[2] av_result_file = sys.argv[3] try: d = json.loads(raw) if os.path.exists(sbom_cache): with open(sbom_cache) as f: d['sbom'] = json.load(f) if os.path.exists(av_result_file): with open(av_result_file) as f: d['av_result'] = json.load(f) print(json.dumps(d)) except: sys.stdout.write(raw) PYEOF rm -f "$_TMP.raw" # 驗證 JSON 格式是否正確,失敗時記錄並跳過本次回報 if ! python3 -c "import json,sys; json.load(sys.stdin)" < "$_TMP" 2>/dev/null; then _log "JSON validation failed (collect error). Python: $(python3 --version 2>&1 | tr -d '\n')" rm -f "$_TMP" exit 1 fi RESPONSE=$(curl -s -o "$AGENT_DIR/.resp" -w "%{http_code}" -X POST "$REPORT_URL" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ --data-binary "@$_TMP" \ --max-time 30 \ --connect-timeout 10 2>/dev/null) rm -f "$_TMP" if [ "$RESPONSE" = "200" ]; then _log "Report sent successfully" # ── 自我更新檢查 ──────────────────────────────────── _SRV_VER=$(python3 -c " import sys,json try: d=json.load(open('$AGENT_DIR/.resp')) print(d.get('agent_version','')) except: print('') " 2>/dev/null || true) if [ -n "$_SRV_VER" ] && [ "$_SRV_VER" != "$AGENT_VERSION" ]; then _log "Agent update: $AGENT_VERSION -> $_SRV_VER" _UPD_URL="${REPORT_URL/\/api\/agent\/report/\/api\/agent\/installer\/$TOKEN}" _INST_TMP=$(mktemp) if curl -sf --max-time 60 --connect-timeout 15 "$_UPD_URL" -o "$_INST_TMP" 2>/dev/null; then _NEW_TMP=$(mktemp) awk '/^# ===BEGIN AGENT\.SH===$/{found=1; next} /^# ===END AGENT\.SH===$/{found=0} found' "$_INST_TMP" | \ grep -v '^cat > \|^AGENT_EOF$' | \ sed "s|__REPORT_URL__|$REPORT_URL|g; s|__TOKEN__|$TOKEN|g" > "$_NEW_TMP" if [ -s "$_NEW_TMP" ] && grep -q "AGENT_VERSION" "$_NEW_TMP"; then chmod 700 "$_NEW_TMP" chown root:root "$_NEW_TMP" 2>/dev/null mv "$_NEW_TMP" "$AGENT_SCRIPT" _log "Agent updated to $_SRV_VER, takes effect next run" else rm -f "$_NEW_TMP" _log "Agent update failed: invalid script" fi else _log "Agent update download failed" fi rm -f "$_INST_TMP" fi else _log "Report failed: HTTP $RESPONSE $(cat "$AGENT_DIR/.resp" 2>/dev/null | head -c 200)" fi exit 0 AGENT_EOF # ===END AGENT.SH=== # 替換佔位符 sed -i "s|__REPORT_URL__|$REPORT_URL|g" "$AGENT_SCRIPT" sed -i "s|__TOKEN__|$TOKEN|g" "$AGENT_SCRIPT" chmod 700 "$AGENT_SCRIPT" chown root:root "$AGENT_SCRIPT" # ── 掃毒腳本 ────────────────────────────────────────────────────────────────── AV_SCRIPT="$AGENT_DIR/av_scan.sh" AV_PATHS="{{ implode(' ', $host->av_scan_paths ?? ['/var/www', '/home', '/tmp']) }}" AV_RESULT="$AGENT_DIR/.av_result.json" cat > "$AV_SCRIPT" << 'AV_EOF' #!/bin/bash export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" AV_RESULT="__AV_RESULT__" AV_PATHS="__AV_PATHS__" STARTED_AT=$(date '+%Y-%m-%d %H:%M:%S') START_TS=$(date +%s) # 確認 ClamAV 已安裝 if ! command -v clamscan >/dev/null 2>&1 && ! command -v clamdscan >/dev/null 2>&1; then printf '{"started_at":"%s","finished_at":"%s","duration_seconds":0,"status":"error","error":"ClamAV not installed","files_scanned":0,"dirs_scanned":0,"infected":0,"threats":[],"paths":[]}\n' \ "$STARTED_AT" "$STARTED_AT" > "$AV_RESULT" exit 0 fi # 更新病毒碼 freshclam --quiet 2>/dev/null || true # 選擇掃描命令(優先用 clamdscan daemon 模式,較快) if command -v clamdscan >/dev/null 2>&1 && clamdscan --ping 1 >/dev/null 2>&1; then SCAN_CMD="clamdscan --fdpass --no-summary" else SCAN_CMD="clamscan --no-summary" fi # 取得版本 ENGINE=$( (clamscan --version 2>/dev/null || clamdscan --version 2>/dev/null) | head -1) # 執行掃描 TMPOUT=$(mktemp) $SCAN_CMD -r $AV_PATHS > "$TMPOUT" 2>&1 || true FINISHED_AT=$(date '+%Y-%m-%d %H:%M:%S') DURATION=$(( $(date +%s) - START_TS )) FILES_SCANNED=$(grep -c "^/" "$TMPOUT" 2>/dev/null || true) FILES_SCANNED=${FILES_SCANNED:-0} DIRS_SCANNED=$(grep "^/" "$TMPOUT" | sed 's|/[^/]*$||' | sort -u | awk 'END{print NR}') DIRS_SCANNED=${DIRS_SCANNED:-0} INFECTED_LINES=$(grep " FOUND$" "$TMPOUT" 2>/dev/null || true) INFECTED_COUNT=$(echo "$INFECTED_LINES" | grep -c " FOUND$" 2>/dev/null || true) INFECTED_COUNT=${INFECTED_COUNT:-0} # 建構 threats JSON THREATS_BUF="" while IFS= read -r _line; do [ -z "$_line" ] && continue _file=$(echo "$_line" | sed 's/: .* FOUND$//') _virus=$(echo "$_line" | sed 's/^.*: //' | sed 's/ FOUND$//') [ -n "$THREATS_BUF" ] && THREATS_BUF="${THREATS_BUF}," THREATS_BUF="${THREATS_BUF}{\"file\":\"${_file//\"/\\\"}\",\"virus\":\"${_virus//\"/\\\"}\"}" done <<< "$INFECTED_LINES" rm -f "$TMPOUT" # 建構 paths JSON PATHS_BUF="" for _p in $AV_PATHS; do [ -n "$PATHS_BUF" ] && PATHS_BUF="${PATHS_BUF}," PATHS_BUF="${PATHS_BUF}\"${_p//\"/\\\"}\"" done STATUS="clean" [ "$INFECTED_COUNT" -gt 0 ] && STATUS="infected" printf '{"started_at":"%s","finished_at":"%s","duration_seconds":%s,"status":"%s","engine":"%s","files_scanned":%s,"dirs_scanned":%s,"infected":%s,"threats":[%s],"paths":[%s]}\n' \ "$STARTED_AT" "$FINISHED_AT" "$DURATION" "$STATUS" "${ENGINE//\"/\\\"}" \ "$FILES_SCANNED" "$DIRS_SCANNED" "$INFECTED_COUNT" "$THREATS_BUF" "$PATHS_BUF" \ > "$AV_RESULT" AV_EOF sed -i "s|__AV_RESULT__|$AV_RESULT|g" "$AV_SCRIPT" sed -i "s|__AV_PATHS__|$AV_PATHS|g" "$AV_SCRIPT" chmod 700 "$AV_SCRIPT" chown root:root "$AV_SCRIPT" # ── 設定 cron ───────────────────────────────────────────────────────────────── cat > "$CRON_FILE" << CRON_EOF # Host Monitor Agent - auto generated */5 * * * * root flock -n $AGENT_DIR/agent.lock timeout 270 $AGENT_SCRIPT >/dev/null 2>&1 # 掃毒:每週六 02:00 0 2 * * 6 root $AV_SCRIPT >> /var/log/av_scan.log 2>&1 CRON_EOF chmod 644 "$CRON_FILE" # 立即執行一次確認 echo "[INFO] 正在送出第一次報告..." set +e timeout 300 bash "$AGENT_SCRIPT" _FIRST_EXIT=$? set -e if [ "$_FIRST_EXIT" -eq 0 ] && [ -f "$AGENT_DIR/.resp" ]; then _HTTP_OK=$(python3 -c "import json,sys; d=json.load(open('$AGENT_DIR/.resp')); print('ok' if d.get('ok') else 'fail')" 2>/dev/null || echo "fail") if [ "$_HTTP_OK" = "ok" ]; then echo "[OK] 安裝完成!Agent 每 5 分鐘自動回報,掃毒每週六 02:00 執行。" else _RESP=$(cat "$AGENT_DIR/.resp" 2>/dev/null | head -c 300) _PY3V=$(python3 --version 2>&1 || echo "未安裝") echo "[WARN] Agent 已安裝,但第一次回報異常。Server 回應:$_RESP" echo " Python 版本:$_PY3V(需 3.7+)" echo " 手動診斷:journalctl -t host-agent --since '5 minutes ago'" echo " 重新執行:bash $AGENT_SCRIPT" fi elif [ "$_FIRST_EXIT" -eq 124 ]; then echo "[WARN] 第一次回報逾時(資料收集超過 5 分鐘),Agent 已安裝並會在下次 cron 自動重試。" elif [ "$_FIRST_EXIT" -eq 1 ]; then _PY3V=$(python3 --version 2>&1 || echo "未安裝") echo "[WARN] 資料收集失敗(JSON 格式錯誤)。Python 版本:$_PY3V(需 3.7+)" echo " 若 Python < 3.7 請升級:sudo apt install python3.8 -y" echo " Agent 已安裝,下次 cron 將自動重試。" else echo "[WARN] 第一次回報失敗(exit $_FIRST_EXIT)。請確認 python3 已安裝,並執行:" echo " bash $AGENT_SCRIPT" fi