#!/bin/bash

# 2018-07-31 Cornelius Koelbel <cornelius.koelbel@netknights.it>
#
# Copyright (c) 2018, Cornelius Koelbel
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# SPDX-FileCopyrightText: (C) 2018 NetKnights GmbH <https://netknights.it>
#
# SPDX-License-Identifier: BSD-3-Clause

if [[ $(id -u) != 0 ]]; then
	echo "Script must be run as root user"
	exit 1
fi

# The diagnostics file contains configuration and logs, only root may read it
umask 077

PICFG="/etc/privacyidea/pi.cfg"

if [ "$#" -gt 0 ]; then
	PICFG=$1
fi

if [[ ! -r $PICFG ]];then
	echo "Could not read $PICFG! Please specify the pi.cfg file!"
	exit 1
fi

OS=$(grep "^ID=" /etc/os-release | sed -e 's/^ID=//g' | tr -d '"')
diag_info=$(mktemp -d -t pi_diag_XXXXXXXXXX)
tempfile="${diag_info}/system.config"
pi_config="${diag_info}/privacyidea.config"
timestamp=$(date +"%y-%m-%d")
diag_file="/tmp/${timestamp}_$(basename "$diag_info").tar.gz"

case $OS in
	centos | rhel | debian | ubuntu)
	;;
	*)
		echo  "Your operating system $OS is not supported!"
		;;
esac

log() {
	echo "$1" >> "$tempfile"
}

log_pi_config() {
	echo "$1" >> "$pi_config"
}

call_pi_manage() {
	PRIVACYIDEA_CONFIGFILE=$PICFG pi-manage "$@"
}

upload_info() {
	echo
	echo "Please upload the diagnostics file $diag_file to your support team."
	echo
}

get_os() {
	log
	log "SECTION: Linux Distribution"
	log "==========================="
	cat /etc/*-release >> "$tempfile"
}

pi_python() {
	# The interpreter of pi-manage has the Python version privacyIDEA needs
	local pi_manage interpreter
	pi_manage=$(command -v pi-manage) && interpreter=$(head -n 1 "$pi_manage" | sed -e 's/^#!//')
	case "$interpreter" in
		*python*) echo "$interpreter" ;;
		*) echo python3 ;;
	esac
}

censor_pi_cfg() {
	# Print the statements of the pi.cfg file given as first argument without comments.
	# Assignments to names that look like secrets are censored. Of URIs and URLs only the
	# scheme is kept, so we see if e.g. pymysql, mysql or any other dialect is used.
	# shellcheck disable=SC2046
	$(pi_python) - "$1" <<'EOF'
import ast
import re
import sys
from typing import Optional

SECRET_NAME = re.compile(r"SECRET|PASSWORD|PASSWD|PEPPER|CREDENTIAL|_KEY$|_URI$|_URL$|_OPTIONS$|EXTRA_PARAMS$")
URL_NAME = re.compile(r"_URI$|_URL$")
URL_SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):")
PUBLIC_NAMES = {"PI_BASE_URL"}


def is_secret(name: str) -> bool:
    return name not in PUBLIC_NAMES and bool(SECRET_NAME.search(name))


def assigned_names(statement: ast.stmt) -> list:
    return [node.id for node in ast.walk(statement) if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)]


def url_scheme(value: ast.expr) -> Optional[str]:
    for node in ast.walk(value):
        if isinstance(node, ast.Constant) and isinstance(node.value, str):
            scheme_match = URL_SCHEME.match(node.value)
            return scheme_match.group(1) if scheme_match else None
    return None


def censor_statement(statement: ast.stmt, source: str) -> str:
    names = assigned_names(statement)
    if not any(is_secret(name) for name in names):
        return ast.get_source_segment(source, statement)
    if isinstance(statement, ast.Assign) and len(names) == 1 and URL_NAME.search(names[0]):
        scheme = url_scheme(statement.value)
        if scheme:
            return f"{names[0]} = '{scheme}:<censored>'"
    return "\n".join(f"{name} = <censored>" for name in names)


def main(config_file_name: str) -> None:
    with open(config_file_name) as config_file:
        source = config_file.read()
    try:
        module = ast.parse(source)
    except SyntaxError:
        print(f"{config_file_name} could not be parsed and is not included.")
        return
    for statement in module.body:
        print(censor_statement(statement, source))


main(sys.argv[1])
EOF
}

censor_json() {
	# Read a configuration export from stdin and replace the string values of keys that look like
	# secrets. JSON objects inside string values, e.g. HTTP headers, are censored as well.
	local program
	program=$(cat <<'EOF'
import json
import re
import sys
from typing import Any

SECRET_KEY = re.compile(r"secret|passw|bindpw|authorization|credential|token$|api[-_]?key|private[-_]?key|motppin|"
                        r"access[-_]?code", re.IGNORECASE)
CENSORED = "__CENSORED__"


def censor(value: Any, secret: bool = False) -> Any:
    if isinstance(value, dict):
        return {key: censor(item, bool(SECRET_KEY.search(str(key)))) for key, item in value.items()}
    if isinstance(value, list):
        return [censor(item, secret) for item in value]
    if isinstance(value, str) and value:
        if secret:
            return CENSORED
        if value.lstrip().startswith("{"):
            try:
                nested_value = json.loads(value)
            except ValueError:
                return value
            if isinstance(nested_value, dict):
                return json.dumps(censor(nested_value))
    return value


try:
    exported = json.load(sys.stdin)
except ValueError:
    print("The export could not be parsed and is not included.")
else:
    print(json.dumps(censor(exported), indent=2))
EOF
)
	# shellcheck disable=SC2046
	$(pi_python) -c "$program"
}

get_pi_cfg() {
	log_pi_config
	log_pi_config "SECTION: pi.cfg file (without comments, secrets censored)"
	log_pi_config "========================================================="
	censor_pi_cfg "$PICFG" >> "$pi_config"
}

current_db_revision() {
	log_pi_config
	log_pi_config "SECTION: current_db_revision"
	log_pi_config "============================"
	call_pi_manage db current -v >> "$pi_config"
}

pi_versions() {
	log_pi_config
	log_pi_config "SECTION: privacyIDEA Versions"
	log_pi_config "============================="
	log_pi_config "Installed packages"
	log_pi_config "------------------"
	FileName=$(mktemp)
	if [[ "${OS}" == "centos" || "${OS}" == "rhel" ]]; then
		# In case it is CentOS/RHEL
		rpm -qa | sort >> "$FileName"
	elif [[ "${OS}" == "ubuntu" || "${OS}" == "debian" ]]; then
		# In case it is Ubuntu/Debian
		dpkg -l | sort >> "$FileName";
	fi
	# save all installed packages
	cat "$FileName" >> "$pi_config"
	rm -f "$FileName"
	log_pi_config
	log_pi_config "Python packages in /opt/privacyidea:"
	log_pi_config "===================================="
	if [[ -x  /opt/privacyidea/bin/pip ]]; then
		/opt/privacyidea/bin/pip freeze >> "$pi_config"
	fi
}

pi_config() {
	log_pi_config
	log_pi_config "SECTION: privacyIDEA Configuration"
	log_pi_config "=================================="
	log_pi_config "Resolvers"
	log_pi_config "---------"
	call_pi_manage config export -t resolver -f json --censor | censor_json >> "$pi_config"
	log_pi_config "Realms"
	log_pi_config "------"
	call_pi_manage config realm list >> "$pi_config"
	log_pi_config "Events"
	log_pi_config "------"
	call_pi_manage config export -t event -f json | censor_json >> "$pi_config"
	log_pi_config "Policies"
	log_pi_config "--------"
	call_pi_manage config export -t policy -f json | censor_json >> "$pi_config"
}

server_config() {
	# Extract configurations for smtp, privacyIDEA and RADIUS server
	server_config=$(call_pi_manage config export -t smtpserver -t privacyideaserver -t radiusserver -f json --censor |
		censor_json)

	log_pi_config
	log_pi_config "SECTION: SMTP|privacyIDEA|RADIUS Server Configuration"
	log_pi_config "====================================================="
	log_pi_config "$server_config"
}

FreeRADIUS_config() {
	log
	log "SECTION: FreeRADIUS Configurations"
	log "=================================="
	if [[ "${OS}" == "centos" || "${OS}" == "rhel" ]]; then
		if (rpm -qa | grep freeradius > /dev/null); then
			ls -R /etc/raddb  >> "$tempfile";
		else
			log
			log "FreeRADIUS is not installed"
		fi
	elif [[ "${OS}" == "ubuntu" || "${OS}" == "debian" ]]; then
		if (dpkg -l | grep freeradius > /dev/null); then
			ls -R /etc//freeradius/3.0 >> "$tempfile";
		else
			log
			log "FreeRADIUS is not installed"
		fi
	fi
}

system_status() {
	log
	log "SECTION: System Status"
	log "======================"
	log "CPU cores: $(grep -c ^processor /proc/cpuinfo)"
	log "========================"
	top -b -n 1 >> "$tempfile"
	log
	log "HD"
	log "========================"
	df -h >> "$tempfile"
}

centos_auditlog() {
	if [[ "$(getenforce)" == "Enforcing" ]]; then
		log
		log "SECTION: centOS Auditlog"
		log "========================"
		grep "denied" /var/log/audit/audit.log  >> "$tempfile"
	fi
}

apache_log() {
	if [[ "${OS}" == "centos" || "${OS}" == "rhel" ]]; then
		if [ -f /var/log/httpd/ssl_error_log ] && [ -f /var/log/httpd/ssl_access_log ]; then
			log
			log "SECTION: httpd SSL_error_log"
			log "============================"
			tail -100 /var/log/httpd/ssl_error_log  >> "$tempfile"
			log
			log "SECTION: httpd SSL_access_log"
			log "============================"
			tail -100 /var/log/httpd/ssl_access_log  >> "$tempfile"
		fi
		if [ -f /etc/httpd/conf/httpd.conf ] && [ -f /etc/httpd/conf.d/privacyidea.conf ]; then
			log
			log "SECTION: apache configuration"
			log "============================="
			tail -n +1 /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/privacyidea.conf >>  "$tempfile"
		fi
	elif [[ "${OS}" == "ubuntu" || "${OS}" == "debian" ]]; then
		if [ -f /var/log/apache2/error.log ] && [ -f /var/log/apache2/ssl_access.log ]; then
			log
			log "SECTION: apache2 error_log"
			log "=========================="
			tail -100 /var/log/apache2/error.log >> "$tempfile"
			log
			log "SECTION: apache2 SSL_access_log"
			log "==============================="
			tail -100 /var/log/apache2/ssl_access.log >> "$tempfile"
		fi
		if [ -f /etc/apache2/apache2.conf ] && [ -d /etc/apache2/sites-enabled ]; then
			log
			log "SECTION: apache configuration"
			log "============================="
			tail -n +1 /etc/apache2/apache2.conf /etc/apache2/sites-enabled/* >> "$tempfile"
		fi
	fi
}

ngnix_log(){
	# Check if NGNIX logs exist
	if [ -f /var/log/nginx/error.log ] && [ -f /var/log/nginx/access.log ]; then
		log
		log "SECTION: NGNIX-ERROR.LOG"
		log "========================"
		tail -n 100 /var/log/nginx/error.log >> "$tempfile"
		log
		log "SECTION: NGNIX-ACCESS.LOG"
		log "========================"
		tail -n 100 /var/log/nginx/access.log >> "$tempfile"
	fi
}

pi_logfile() {
	R=$( grep "^PI_LOGFILE" "$PICFG" | cut -d "=" -f2 | tr -d "\t \'\"" )
	[[ -f ${R} ]] && cp ${R} "${diag_info}/privacyidea.logfile" || echo "Could not read logfile ${R}" >> "${diag_info}/privacyidea.logfile"
}

pi_auditlog() {
	call_pi_manage audit dump -f - -t 2d >> "${diag_info}/privacyidea.auditlog"
}

get_os
get_pi_cfg
current_db_revision
pi_versions
pi_config
server_config
FreeRADIUS_config
system_status
if [[ "${OS}" == "centos" ]]; then
	centos_auditlog
fi
apache_log
ngnix_log
pi_logfile
pi_auditlog

# mktemp honours TMPDIR, so the collected data is packed from where it actually is. It is only removed once the
# archive exists.
if tar -zcf "$diag_file" -C "$(dirname "$diag_info")" "$(basename "$diag_info")"; then
	rm -rf "$diag_info"
	upload_info
else
	echo "Could not create $diag_file, the collected data is kept in $diag_info." >&2
	exit 1
fi
