#!/bin/bash
# Afrihost
# ssh-client-access.sh
#
# Generates /etc/ssh/sshd_config.d/99-<cpanel_user>_clients.conf Match blocks
# per cPanel user, optionally restricted to a single IP (/32) or a range/CIDR.
#
# Usage:
#   ssh-client-access.sh <cpanel_user> [ip_or_range] [--dryrun]
#   ssh-client-access.sh --global <ip_or_range> [--dryrun]
#   ssh-client-access.sh --report
#
# Examples:
#   ssh-client-access.sh someuser
#   ssh-client-access.sh someuser 52.138.218.9
#   ssh-client-access.sh someuser 52.138.218.0/24
#   ssh-client-access.sh someuser --dryrun
#   ssh-client-access.sh someuser 52.138.218.9 --dryrun
#   ssh-client-access.sh --global 169.1.254.167
#   ssh-client-access.sh --global 41.76.110.32
#   ssh-client-access.sh --report
#
# Re-running for the same user/target with a new IP does NOT overwrite the
# old one: the new IP is merged into the existing Address list (see
# merge_address_list / extract_existing_address below).

([ -f /usr/bin/id ] && [ "$(/usr/bin/id -u)" -eq "0" ]) || { echo notroot ; exit ; }

# cpanel check
if ! [ -f "/usr/local/cpanel/cpanel" ]; then
       exit
fi

set -euo pipefail

SSHD_DIR="/etc/ssh/sshd_config.d"
CPUSER_DB="/var/cpanel/users"
PREFIX="99-"
SUFFIX="_clients.conf"

# Global admin allowlist (not tied to a cpanel user) - covers the "allow SSH/SFTP
# on port 24 from these IPs" requirement.
GLOBAL_SSH_PORT=24
GLOBAL_FILE="${SSHD_DIR}/10-global_admins_clients.conf"

usage() {
    cat <<EOF
Usage:
  $(basename "$0") <cpanel_user> [ip_or_range] [--dryrun]
  $(basename "$0") --global <ip_or_range> [--dryrun]
  $(basename "$0") --report

  <cpanel_user>     Required. Must be a valid cPanel user (checked against ${CPUSER_DB}).
  [ip_or_range]     Optional. A single IPv4 (becomes /32) or a CIDR range (e.g. 52.138.218.0/24).
                    If the target file already has IP(s) recorded, a newly supplied IP is
                    merged in alongside the old one(s) rather than replacing them.
  --global          Manage the global admin allowlist for SSH/SFTP on port ${GLOBAL_SSH_PORT}
                    (${GLOBAL_FILE}), independent of any cpanel user.
  --dryrun          Optional, may appear as the 2nd or 3rd argument. Prints what
                    would be created/updated without writing anything.
  --report          Prints a summary of existing per-user client-access files.
EOF
}

err() { echo "Error: $*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# --report mode
# ---------------------------------------------------------------------------
run_report() {
    shopt -s nullglob
    local files=("${SSHD_DIR}/${PREFIX}"*"${SUFFIX}")
    shopt -u nullglob

    echo "total: ${#files[@]}"

    for f in "${files[@]}"; do
        local base user recorded_date
        base=$(basename "$f")
        # strip prefix "99-" and suffix "_clients.conf" to recover the cpanel user
        user="${base#${PREFIX}}"
        user="${user%${SUFFIX}}"

        # Prefer the "# date modified:" line recorded inside the file;
        # fall back to filesystem mtime if that line is missing.
        recorded_date=$(grep -m1 '^# date modified:' "$f" 2>/dev/null | sed 's/^# date modified: *//')
        if [[ -z "$recorded_date" ]]; then
            recorded_date=$(date -r "$f" '+%Y-%m-%d %H:%M:%S %Z')
        fi

        echo "${user} ${recorded_date}"
    done
}

# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
validate_cpanel_user() {
    local user="$1"
    if [[ ! -f "${CPUSER_DB}/${user}" ]]; then
        err "'${user}' is not a valid cPanel user (no ${CPUSER_DB}/${user})"
    fi
}

# Returns the normalized ip/CIDR on stdout, or errors out.
normalize_ip() {
    local input="$1"
    local ipv4_octet='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
    local ipv4_re="^${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}"

    if [[ "$input" =~ ${ipv4_re}$ ]]; then
        # bare IP, no CIDR suffix -> treat as a single host
        echo "${input}/32"
    elif [[ "$input" =~ ${ipv4_re}/([0-9]|[12][0-9]|3[0-2])$ ]]; then
        # already a CIDR range, e.g. 52.138.218.0/24 -- use as provided
        echo "$input"
    else
        err "'${input}' does not look like a valid IPv4 address or CIDR range"
    fi
}

# Pulls the existing "Address <list>" value out of a previously-generated
# file's Match line, if any. Prints "" if the file or the Address clause
# doesn't exist yet.
extract_existing_address() {
    local file="$1"
    [[ -f "$file" ]] || { echo ""; return; }
    grep -m1 '^Match ' "$file" | sed -n 's/.*Address \([^ ]*\).*/\1/p'
}

# Merges a new CIDR into an existing comma-separated address list, keeping
# every old entry and only appending the new one if it isn't already present.
# This is what makes repeat runs additive instead of overwriting.
merge_address_list() {
    local existing_addr_field="$1"   # e.g. "1.2.3.4/32,5.6.7.8/32" or ""
    local new_cidr="$2"

    if [[ -z "$existing_addr_field" ]]; then
        echo "$new_cidr"
        return
    fi

    local c
    IFS=',' read -ra current <<< "$existing_addr_field"
    for c in "${current[@]}"; do
        if [[ "$c" == "$new_cidr" ]]; then
            # already recorded - nothing to add, return unchanged
            echo "$existing_addr_field"
            return
        fi
    done

    echo "${existing_addr_field},${new_cidr}"
}

# ---------------------------------------------------------------------------
# --global mode: admin allowlist, not tied to a cpanel user
# ---------------------------------------------------------------------------
handle_global() {
    local args=("$@")
    local dryrun=0
    local positional=()
    for a in "${args[@]}"; do
        if [[ "$a" == "--dryrun" ]]; then
            dryrun=1
        else
            positional+=("$a")
        fi
    done

    local ip_input="${positional[0]:-}"
    [[ -z "$ip_input" ]] && err "--global requires an IP or CIDR range"
    [[ ${#positional[@]} -gt 1 ]] && err "too many arguments for --global"

    local cidr
    cidr=$(normalize_ip "$ip_input")

    local existing_addr=""
    [[ -f "$GLOBAL_FILE" ]] && existing_addr=$(extract_existing_address "$GLOBAL_FILE")

    local merged
    merged=$(merge_address_list "$existing_addr" "$cidr")

    local match_line="Match Address ${merged} LocalPort ${GLOBAL_SSH_PORT}"
    local now
    now=$(date '+%Y-%m-%d %H:%M:%S %Z')

    local content
    content=$(cat <<EOF
# create file
# ${GLOBAL_FILE}
# content:
# date modified: ${now}
# Global admin allowlist - SSH/SFTP on port ${GLOBAL_SSH_PORT}
${match_line}
    PasswordAuthentication yes
    PubkeyAuthentication yes
EOF
)

    if [[ $dryrun -eq 1 ]]; then
        echo "[dryrun] would create/update: ${GLOBAL_FILE}"
        echo "----------------------------------------"
        echo "$content"
        echo "----------------------------------------"
        return
    fi

    mkdir -p "$SSHD_DIR"
    printf '%s\n' "$content" > "$GLOBAL_FILE"
    chmod 644 "$GLOBAL_FILE"
    echo "Updated ${GLOBAL_FILE}"
}

perms_final () {
	find /etc/ssh/sshd_config.d -type f -exec chmod 600 {} +
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
    local args=("$@")
    [[ ${#args[@]} -eq 0 ]] && { usage; exit 1; }

    if [[ "${args[0]}" == "--report" ]]; then
        run_report
	perms_final
        exit 0
    fi

    if [[ "${args[0]}" == "--global" ]]; then
	if grep "HOST.*dedicated.co.za" /etc/wwwacct.conf ; then
        handle_global "${args[@]:1}"
	perms_final
        exit 0
        else
	echo "== Only supported on dedicated! ==="
	perms_final
	exit 0
	fi
    fi

    if [[ "${args[0]}" == "-h" || "${args[0]}" == "--help" ]]; then
        usage
        exit 0
    fi

    # Pull --dryrun out of the args wherever it landed (2nd or 3rd position),
    # everything else stays positional (user, then optional ip/range).
    local dryrun=0
    local positional=()
    for a in "${args[@]}"; do
        if [[ "$a" == "--dryrun" ]]; then
            dryrun=1
        else
            positional+=("$a")
        fi
    done

    local cpuser="${positional[0]:-}"
    local ip_input="${positional[1]:-}"

    [[ -z "$cpuser" ]] && { usage; err "cpanel_user is required"; }
    [[ ${#positional[@]} -gt 2 ]] && err "too many positional arguments"

    validate_cpanel_user "$cpuser"

    local outfile="${SSHD_DIR}/${PREFIX}${cpuser}${SUFFIX}"

    # If this user already has a file, pull out whatever IP(s) are already
    # recorded so a new IP gets merged in instead of clobbering them.
    local existing_addr=""
    [[ -f "$outfile" ]] && existing_addr=$(extract_existing_address "$outfile")

    local match_line
    if [[ -n "$ip_input" ]]; then
        local cidr merged
        cidr=$(normalize_ip "$ip_input")
        merged=$(merge_address_list "$existing_addr" "$cidr")
        match_line="Match User ${cpuser} Address ${merged}"
    elif [[ -n "$existing_addr" ]]; then
        # No new IP supplied this run, but the file already had address
        # restrictions - preserve them rather than dropping to unrestricted.
        match_line="Match User ${cpuser} Address ${existing_addr}"
    else
        match_line="Match User ${cpuser}"
    fi

    local now
    now=$(date '+%Y-%m-%d %H:%M:%S %Z')

    local content
    content=$(cat <<EOF
# create file
# ${outfile}
# content:
# date modified: ${now}
${match_line}
    PasswordAuthentication yes
    PubkeyAuthentication yes
EOF
)

    if [[ $dryrun -eq 1 ]]; then
        echo "[dryrun] would create: ${outfile}"
        echo "----------------------------------------"
        echo "$content"
        echo "----------------------------------------"
        exit 0
    fi

    mkdir -p "$SSHD_DIR"
    printf '%s\n' "$content" > "$outfile"
    chmod 600 "$outfile"
    echo "Created ${outfile}"
    systemctl reload sshd
}

main "$@"
