#!/usr/bin/env bash
#
# cpanel-ftp-audit.sh
#
# FTP login history, failed logins and file transfers for EVERY FTP account
# under a cPanel user: the main login, the <user>_logs account and all FTP
# sub-accounts (name@domain). Works with Pure-FTPd and ProFTPD.
#
# Usage (as root):
#   bash cpanel-ftp-audit.sh [-v] [cpanel_username]
#   curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh | bash -s -- [-v] cpanel_username
#   bash <(curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh)     # prompts for username
#
#   -v   verbose: list every failed attempt and every transfer (not just the
#        top/latest 50), plus every raw FTP log line for the account
#        (uploads, deletes, logouts, timeouts...)
#
# Read-only: changes nothing on the server. A copy of the report is saved to
# /root/ftp-audit-<user>-<timestamp>.txt
#
# Everything is inside functions and only runs on the last line, so it is safe
# to pipe from curl straight into bash.

usage() {
  cat <<'EOF'
Usage: cpanel-ftp-audit.sh [-v] <cpanel_username>

  -v   verbose: show all failed attempts and transfers, plus raw FTP log lines

Examples:
  curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh | bash -s -- dafaeeaa
  bash <(curl -fsSL https://YOUR-HOST/cpanel-ftp-audit.sh) -v dafaeeaa
EOF
}

# Escape a literal string for use inside grep -E / sed -E patterns
re_escape() { printf '%s' "$1" | sed -E 's#[][\.^$*+?(){}|]#\\&#g'; }

# Build "(a|b|c)" from the arguments, regex-escaped
alt_re() {
  local out="" x
  for x in "$@"; do out+="${out:+|}$(re_escape "$x")"; done
  printf '(%s)' "${out:-__no_match__}"
}

section() { printf '\n=== %s ===\n' "$*"; }

limit() { if (( VERBOSE )); then cat; else head -n "$1"; fi; }

report() {
  local n

  echo "cPanel FTP audit for:  $CPUSER"
  echo "Generated:             $(date '+%F %T %Z') on $(hostname)"
  echo "FTP server (config):   ${FTPSERVER:-unknown}"
  echo "Login log source:      ${LOGDESC:-none found}"
  echo "Domains:               ${DOMAINS[*]:-none found}"
  echo "FTP accounts (${#FTP_ACCTS[@]}):"
  printf '  %s\n' "${FTP_ACCTS[@]}"

  # ---------------------------------------------------------------- overview
  section "Overview per FTP account"
  awk -F'\t' -v accts="${FTP_ACCTS[*]}" '
    FILENAME == ARGV[1] { ok[$2]++; last[$2] = $1; lastip[$2] = $3; next }
                        { bad[$2]++ }
    END {
      n = split(accts, a, " ")
      printf "%-40s %7s %7s  %-19s  %s\n", "ACCOUNT", "LOGINS", "FAILED", "LAST LOGIN", "LAST IP"
      for (i = 1; i <= n; i++) {
        u = a[i]
        printf "%-40s %7d %7d  %-19s  %s\n", u, ok[u], bad[u],
               (u in last ? last[u] : "-"), (u in lastip ? lastip[u] : "-")
      }
    }' "$TMP/logins.tsv" "$TMP/failed.tsv"

  # -------------------------------------------------------- successful logins
  n=$(wc -l < "$TMP/logins.tsv")
  section "Successful logins, oldest first ($n total)"
  if (( n )); then
    awk -F'\t' '{ printf "%-19s  %-40s  %s\n", $1, $2, $3 }' "$TMP/logins.tsv"

    section "Successful logins by account and IP"
    cut -f2,3 "$TMP/logins.tsv" | sort | uniq -c | sort -k1,1nr \
      | awk '{ printf "%7d  %-40s  %s\n", $1, $2, $3 }'
  else
    echo "None found in the available logs."
  fi

  # ------------------------------------------------------------ failed logins
  n=$(wc -l < "$TMP/failed.tsv")
  section "Failed logins by account and IP ($n total)"
  echo "(includes attempts on non-existent users @ this account's domains)"
  if (( n )); then
    cut -f2,3 "$TMP/failed.tsv" | sort | uniq -c | sort -k1,1nr \
      | awk '{ printf "%7d  %-40s  %s\n", $1, $2, $3 }' | limit 50
    if (( VERBOSE )); then
      section "All failed logins, oldest first"
      awk -F'\t' '{ printf "%-19s  %-40s  %s\n", $1, $2, $3 }' "$TMP/failed.tsv"
    elif (( $(cut -f2,3 "$TMP/failed.tsv" | sort -u | wc -l) > 50 )); then
      echo "(top 50 shown - run with -v for everything)"
    fi
  else
    echo "None found in the available logs."
  fi

  # ---------------------------------------------------------- file transfers
  section "File transfers"
  if (( ${#XFER_FILES[@]} == 0 )); then
    echo "No FTP transfer logs found (ftpxferlog / ftp.<domain>-ftp_log)."
  else
    echo "Transfer logs searched:"
    printf '  %s\n' "${XFER_FILES[@]}"
    n=$(wc -l < "$TMP/xfer.tsv")
    echo
    if (( n )); then
      echo "Totals:"
      awk -F'\t' '{ k = $2 "\t" $4; c[k]++; b[k] += $5 }
        END { for (k in c) { split(k, p, "\t")
              printf "  %-40s %-8s %7d files %16.0f bytes\n", p[1], p[2], c[k], b[k] } }' \
        "$TMP/xfer.tsv" | sort

      if (( VERBOSE )); then
        section "All transfers, oldest first ($n)"
      else
        section "Latest 50 transfers ($n total, run with -v for all)"
      fi
      tail -n "$( (( VERBOSE )) && echo "+1" || echo 50 )" "$TMP/xfer.tsv" \
        | awk -F'\t' '{ printf "%-20s  %-32s  %-15s  %-8s  %12s  %s\n", $1, $2, $3, $4, $5, $6 }'
    else
      echo "No transfers found for these accounts."
    fi
  fi

  # ------------------------------------------------------------ raw log lines
  n=$(wc -l < "$TMP/ftp.log")
  if (( VERBOSE )); then
    section "Raw FTP daemon log lines for this account ($n)"
    cat "$TMP/ftp.log"
  else
    echo
    echo "($n raw FTP log lines matched - run with -v to see them all,"
    echo " including uploads, deletes, logouts and timeouts)"
  fi

  echo
  echo "Note: history only goes back as far as log rotation keeps it (${OLDEST:-unknown})."
}

main() {
  export LC_ALL=C
  VERBOSE=0
  CPUSER=""

  while [[ $# -gt 0 ]]; do
    case $1 in
      -v|--verbose) VERBOSE=1 ;;
      -h|--help)    usage; return 0 ;;
      -*)           echo "Unknown option: $1" >&2; usage >&2; return 1 ;;
      *)            CPUSER=$1 ;;
    esac
    shift
  done

  if [[ $EUID -ne 0 ]]; then
    echo "Please run this as root." >&2
    return 1
  fi

  if [[ -z $CPUSER ]]; then
    read -rp "cPanel username: " CPUSER </dev/tty || true
  fi
  CPUSER=${CPUSER//[[:space:]]/}
  CPUSER=${CPUSER,,}

  if [[ ! $CPUSER =~ ^[a-z0-9][a-z0-9_.-]*$ ]]; then
    usage >&2
    return 1
  fi
  if [[ ! -f /var/cpanel/users/$CPUSER ]]; then
    echo "No cPanel account called '$CPUSER' on this server." >&2
    return 1
  fi

  HOMEDIR=$(getent passwd "$CPUSER" | cut -d: -f6)
  HOMEDIR=${HOMEDIR:-/home/$CPUSER}

  # All FTP logins for the account: main, _logs, plus sub-accounts
  mapfile -t FTP_ACCTS < <(
    { echo "$CPUSER"; echo "${CPUSER}_logs"; cut -d: -f1 "/etc/proftpd/$CPUSER" 2>/dev/null; } \
      | sed 's/[[:space:]]//g' | awk 'NF && !seen[$0]++'
  )

  # Domains owned by the account (used to catch attempts on unknown user@domain)
  mapfile -t DOMAINS < <(
    awk -F': *' -v u="$CPUSER" '{ sub(/[[:space:]]+$/, "", $2) } $2 == u { print $1 }' \
      /etc/userdomains 2>/dev/null | sort -u
  )

  FTPSERVER=$(awk -F= '$1 == "ftpserver" { print $2 }' /var/cpanel/cpanel.config 2>/dev/null)

  # Syslog files, oldest first (RHEL-family: messages, Ubuntu: syslog)
  LOGFILES=()
  LOGDESC=""
  local base f d
  for base in /var/log/messages /var/log/syslog; do
    if [[ -e $base ]]; then
      mapfile -t LOGFILES < <(ls -1tr -- "$base" "$base"[-.]* 2>/dev/null)
      LOGDESC="$base (+$(( ${#LOGFILES[@]} - 1 )) rotated)"
      break
    fi
  done

  TMP=$(mktemp -d)
  trap 'rm -rf "$TMP"' EXIT

  ACCT_RE=$(alt_re "${FTP_ACCTS[@]}")
  DOM_RE=$(alt_re "${DOMAINS[@]}")
  local B='[^[:alnum:]_.-]'
  local TS='^([A-Z][a-z]{2} +[0-9]{1,2} [0-9:]{8}|[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8})[^ ]*'
  local FAILU="(${ACCT_RE}|[^][ :]*@${DOM_RE})"
  local FILTER_RE="(pure-ftpd|proftpd).*(${B}${ACCT_RE}${B}|@${DOM_RE}${B})"

  echo "Reading logs for $CPUSER (${#FTP_ACCTS[@]} FTP accounts)..." >&2

  # 1. Pull every FTP daemon line that mentions one of the accounts/domains
  if (( ${#LOGFILES[@]} )); then
    OLDEST=$(zcat -f -- "${LOGFILES[0]}" 2>/dev/null | head -n1 | sed -nE "s/${TS}.*/oldest entry: \1/p")
    for f in "${LOGFILES[@]}"; do zcat -f -- "$f" 2>/dev/null; done \
      | grep -aE "$FILTER_RE" > "$TMP/ftp.log"
  elif command -v journalctl >/dev/null 2>&1; then
    LOGDESC="systemd journal"
    journalctl --no-pager -o short -t pure-ftpd -t proftpd 2>/dev/null \
      | grep -aE "$FILTER_RE" > "$TMP/ftp.log"
    OLDEST=$(head -n1 "$TMP/ftp.log" | sed -nE "s/${TS}.*/oldest match: \1/p")
  else
    : > "$TMP/ftp.log"
  fi

  # 2. Successful logins -> date<TAB>account<TAB>ip
  grep -aE "(pure-ftpd.*\] ${ACCT_RE} is now logged in|proftpd.* - USER ${ACCT_RE}: Login successful)" "$TMP/ftp.log" \
    | sed -nE \
        -e "s/${TS}.*\(\?@([^)]*)\) \[[A-Z]+\] ([^ ]+) is now logged in.*/\1\t\3\t\2/p" \
        -e "s/${TS}.*\([^[]*\[([^]]*)\]\) - USER ([^ :]+): Login successful.*/\1\t\3\t\2/p" \
    > "$TMP/logins.tsv"

  # 3. Failed logins -> date<TAB>account<TAB>ip
  grep -aE "(pure-ftpd.*Authentication failed for user \[${FAILU}\]|proftpd.* - USER ${FAILU}( \(Login failed\)|: no such user))" "$TMP/ftp.log" \
    | sed -nE \
        -e "s/${TS}.*\(\?@([^)]*)\) \[[A-Z]+\] Authentication failed for user \[([^]]*)\].*/\1\t\3\t\2/p" \
        -e "s/${TS}.*\([^[]*\[([^]]*)\]\) - USER ([^ :]+)( \(Login failed\)|: no such user).*/\1\t\3\t\2/p" \
    > "$TMP/failed.tsv"

  # 4. Transfer logs (xferlog format) for the account's domains
  mapfile -t XFER_FILES < <(
    {
      ls -1 /usr/local/apache/domlogs/ftpxferlog* 2>/dev/null
      for d in "${DOMAINS[@]}"; do
        ls -1 /usr/local/apache/domlogs/ftp."$d"-ftp_log* \
              /usr/local/apache/domlogs/*/ftp."$d"-ftp_log* \
              "$HOMEDIR/logs/ftp.$d"-ftp_log* 2>/dev/null
      done
    } | awk '!seen[$0]++'
  )

  # xferlog: 5 date fields, time, host, bytes, filename..., then 9 fixed fields
  for f in "${XFER_FILES[@]}"; do zcat -f -- "$f" 2>/dev/null; done \
    | awk -v accts="${FTP_ACCTS[*]}" '
        BEGIN {
          n = split(accts, a, " "); for (i = 1; i <= n; i++) want[a[i]] = 1
          split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " ")
          for (i = 1; i <= 12; i++) mon[m[i]] = sprintf("%02d", i)
        }
        NF >= 18 && ($(NF-4) in want) {
          fn = $9; for (i = 10; i <= NF - 9; i++) fn = fn " " $i
          dir = $(NF-6)
          dir = (dir == "i" ? "UPLOAD" : dir == "o" ? "DOWNLOAD" : dir == "d" ? "DELETE" : dir)
          printf "%s%s%02d%s\t%s %s %02d %s\t%s\t%s\t%s\t%s\t%s\n",
                 $5, mon[$2], $3, $4, $5, $2, $3, $4, $(NF-4), $7, dir, $8, fn
        }' \
    | sort -u | cut -f2- > "$TMP/xfer.tsv"

  umask 077
  REPORT="/root/ftp-audit-${CPUSER}-$(date +%Y%m%d-%H%M%S).txt"
  report | tee "$REPORT"
  echo
  echo "Report saved to $REPORT" >&2
}

main "$@"