#!/usr/bin/env bash
# Contrôle technique du serveur, par MarketCompose (https://marketcompose.com/fr/controle-technique/)
#
# Ce script NE MODIFIE RIEN et N'ENVOIE RIEN : il lit l'état du serveur, affiche ce qu'il a relevé,
# puis un code à coller sur la page du contrôle technique, qui fait le diagnostic dans votre navigateur.
# Le code ne contient ni mot de passe, ni adresse IP, ni nom de machine. Il contient vos noms de
# domaine (pour vérifier les certificats) et les noms de vos conteneurs.
#
# Utilisation :  sudo bash controle-technique.sh
# Lancé sans sudo, certains points (SSH, Docker, ports) ne peuvent pas être vérifiés.
set -uo pipefail
export LC_ALL=C

V=1
OUT=()
add() { OUT+=("$1=$2"); }
has() { command -v "$1" >/dev/null 2>&1; }
# Une liste séparée par des virgules, sans espaces ni caractères bizarres
csv() { tr -s ' \n\t' ',,,' | tr -cd 'A-Za-z0-9._:/@+,-' | sed 's/^,//; s/,$//'; }

add v "$V"
add date "$(date -u +%Y-%m-%dT%H:%MZ)"
add root "$([ "$(id -u)" -eq 0 ] && echo 1 || echo 0)"

# ------------------------------------------------------------------ Système
if [ -r /etc/os-release ]; then
  . /etc/os-release
  add os "${ID:-inconnu}"
  add os_ver "${VERSION_ID:-}"
fi
add kernel "$(uname -r | csv)"
add arch "$(uname -m)"
add uptime_d "$(awk '{print int($1/86400)}' /proc/uptime)"
add cpus "$(nproc 2>/dev/null || echo 1)"
mem_total=$(awk '/^MemTotal/{print int($2/1024)}' /proc/meminfo)
mem_avail=$(awk '/^MemAvailable/{print int($2/1024)}' /proc/meminfo)
add mem_mb "$mem_total"
add mem_avail_pct "$(( mem_total > 0 ? mem_avail * 100 / mem_total : 0 ))"
add swap_mb "$(awk '/^SwapTotal/{print int($2/1024)}' /proc/meminfo)"
add load "$(cut -d' ' -f2 /proc/loadavg)"
add disk_pct "$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')"
add disk_free_gb "$(df -P -BG / | awk 'NR==2{gsub("G","",$4); print $4}')"
add inode_pct "$(df -P -i / | awk 'NR==2{gsub("%","",$5); print $5+0}')"

if has timedatectl; then
  add ntp "$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo inconnu)"
fi

if has apt-get; then
  # Simulation : ne télécharge rien, utilise les listes déjà présentes
  sim=$(apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep '^Inst ' || true)
  add upd "$(printf '%s' "$sim" | grep -c '^Inst' || true)"
  add upd_sec "$(printf '%s' "$sim" | grep -ci 'securi' || true)"
  # Âge de la dernière mise à jour des listes de paquets, en jours
  lists=$(stat -c %Y /var/lib/apt/lists/partial 2>/dev/null || stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0)
  add apt_age_d "$(( ($(date +%s) - lists) / 86400 ))"
  auto=0
  if dpkg -s unattended-upgrades >/dev/null 2>&1 && grep -qsE 'Unattended-Upgrade\s+"1"' /etc/apt/apt.conf.d/*; then auto=1; fi
  add auto_upd "$auto"
fi
add reboot_req "$([ -f /var/run/reboot-required ] && echo 1 || echo 0)"

# ------------------------------------------------------------------ Sécurité
if [ "$(id -u)" -eq 0 ] && has sshd; then
  cfg=$(sshd -T 2>/dev/null)
  add ssh_pw "$(awk '$1=="passwordauthentication"{print $2}' <<<"$cfg")"
  add ssh_kbd "$(awk '$1=="kbdinteractiveauthentication"{print $2}' <<<"$cfg")"
  add ssh_root "$(awk '$1=="permitrootlogin"{print $2}' <<<"$cfg")"
  add ssh_port "$(awk '$1=="port"{print $2}' <<<"$cfg" | head -1)"
fi
add uid0 "$(awk -F: '$3==0 && $1!="root"{print $1}' /etc/passwd | csv)"

fw=aucun
if has ufw && [ "$(id -u)" -eq 0 ]; then
  if ufw status 2>/dev/null | grep -q 'Status: active'; then
    fw=ufw
    add fw_allow "$(ufw status 2>/dev/null | awk '/ALLOW/ && $0 !~ /\(v6\)/ {print $1}' | sort -u | csv)"
  fi
fi
if [ "$fw" = aucun ] && has nft && [ "$(id -u)" -eq 0 ]; then
  # Un pare-feu nftables qui filtre l'entrée (hors règles de Docker)
  nft list ruleset 2>/dev/null | grep -qE 'hook input.*policy drop' && fw=nftables
fi
add fw "$fw"

# Ports ouverts sur une adresse publique (pas 127.0.0.1, ni le réseau privé de Tailscale)
if has ss; then
  pub=$(ss -H -tuln 2>/dev/null | awk '{print $1, $5}' | while read -r proto addr; do
    ip=${addr%:*}; port=${addr##*:}
    case "$ip" in 127.*|"[::1]"|100.*|"[fd7a:"*|"[fe80:"*|*%lo) continue ;; esac
    # Client DHCP (68, 546) : il écoute pour recevoir l'adresse du serveur, ce n'est pas un service
    [ "$proto" = udp ] && { [ "$port" = 68 ] || [ "$port" = 546 ]; } && continue
    echo "$port/$proto"
  done | sort -u -t/ -k1,1n | csv)
  add listen "$pub"
fi

f2b=absent
if has fail2ban-client || [ -d /etc/fail2ban ]; then
  f2b=$(systemctl is-active fail2ban 2>/dev/null || echo inactif)
fi
add f2b "$f2b"

# ------------------------------------------------------------------ Docker
crowdsec=absent
systemctl is-active --quiet crowdsec 2>/dev/null && crowdsec=service
if has docker && docker info >/dev/null 2>&1; then
  add docker "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
  ids=$(docker ps -aq)
  add dk_total "$(printf '%s\n' $ids | grep -c . || true)"
  add dk_running "$(docker ps -q | grep -c . || true)"
  exited=""; restarting=""; unhealthy=""; priv=""; sock=""; hostnet=""; rootc=0; latest=0
  if [ -n "$ids" ]; then
    while IFS='|' read -r name status policy health privileged netmode user image mounts; do
      name=${name#/}
      case "$status" in
        exited|dead) [[ "$policy" == unless-stopped || "$policy" == always ]] && exited+="$name " ;;
        restarting) restarting+="$name " ;;
      esac
      [ "$health" = unhealthy ] && [ "$status" = running ] && unhealthy+="$name "
      [ "$privileged" = true ] && priv+="$name "
      [ "$netmode" = host ] && hostnet+="$name "
      [ -z "$user" ] || [ "$user" = root ] || [ "$user" = 0 ] && rootc=$((rootc + 1))
      [[ "$image" == *:latest || "$image" != *:* ]] && latest=$((latest + 1))
      if [[ "$mounts" == *docker.sock* ]] && [[ "$name" != *socket-proxy* ]]; then sock+="$name "; fi
    done < <(docker inspect --format '{{.Name}}|{{.State.Status}}|{{.HostConfig.RestartPolicy.Name}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{.HostConfig.Privileged}}|{{.HostConfig.NetworkMode}}|{{.Config.User}}|{{.Config.Image}}|{{range .Mounts}}{{.Source}} {{end}}' $ids 2>/dev/null)
  fi
  add dk_exited "$(echo "$exited" | csv)"
  add dk_restarting "$(echo "$restarting" | csv)"
  add dk_unhealthy "$(echo "$unhealthy" | csv)"
  add dk_priv "$(echo "$priv" | csv)"
  add dk_sock "$(echo "$sock" | csv)"
  add dk_hostnet "$(echo "$hostnet" | csv)"
  add dk_root "$rootc"
  add dk_latest "$latest"
  # Ports publiés sur toutes les adresses : Docker les ouvre en contournant le pare-feu UFW
  add dk_pub "$(docker ps --format '{{.Names}} {{.Ports}}' | while read -r n p; do
    grep -oE '(0\.0\.0\.0|\[::\]|:::):[0-9]+' <<<"$p" | grep -oE '[0-9]+$' | sort -un | while read -r port; do
      [ "$port" = 80 ] || [ "$port" = 443 ] || echo "$n:$port"
    done
  done | sort -u | csv)"
  add dk_loglimit "$(grep -qs 'max-size' /etc/docker/daemon.json && echo 1 || echo 0)"
  add dk_reclaim_gb "$(docker system df --format '{{.Type}} {{.Reclaimable}}' 2>/dev/null | awk '$1=="Images"{v=$2; u=v; gsub(/[0-9.]/,"",u); gsub(/[^0-9.]/,"",v); if(u ~ /^GB/) print int(v); else if (u ~ /^TB/) print int(v*1024); else print 0}')"
  docker ps --format '{{.Names}} {{.Image}}' | grep -qi crowdsec && crowdsec=conteneur
else
  add docker "$(has docker && echo injoignable || echo absent)"
fi
add crowdsec "$crowdsec"

# ------------------------------------------------------------------ Sauvegardes
if [ -d /opt/backups ]; then
  newest=$(find /opt/backups -maxdepth 2 -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1 | cut -d. -f1)
  add bk_count "$(find /opt/backups -maxdepth 2 -type f 2>/dev/null | wc -l)"
  [ -n "$newest" ] && add bk_age_h "$(( ($(date +%s) - newest) / 3600 ))"
  add bk_size_gb "$(du -s --block-size=1G /opt/backups 2>/dev/null | cut -f1)"
fi
add bk_timer "$(systemctl is-enabled sauvegarde-serveur.timer 2>/dev/null || echo absent)"
add restic "$(has restic && echo 1 || echo 0)"
add backrest "$(has docker && docker ps --format '{{.Image}}' 2>/dev/null | grep -qi backrest && echo 1 || echo 0)"

# ------------------------------------------------------------------ Certificats HTTPS
# Domaines trouvés dans les configurations (Traefik, Caddy, Nginx), vérifiés sur ce serveur même
domains=$( {
  grep -rhoE 'Host\(`[^`]+`\)' /opt/stacks 2>/dev/null --include='*.yml' --include='*.yaml' | grep -oE '`[^`]+`' | tr -d '`'
  for f in /opt/stacks/caddy/conf/Caddyfile /etc/caddy/Caddyfile; do
    [ -r "$f" ] && grep -oE '^[a-z0-9.-]+\.[a-z]{2,}([ ,]+[a-z0-9.-]+\.[a-z]{2,})* *\{' "$f" | tr -d '{' | tr ', ' '\n\n'
  done
  grep -rhoE '^\s*server_name\s+[^;]+' /etc/nginx/sites-enabled 2>/dev/null | sed -E 's/^\s*server_name\s+//' | tr ' ' '\n'
} | grep -E '^[a-z0-9.-]+\.[a-z]{2,}$' | grep -v '^www\.' | sort -u | head -15)
certs=""
if has openssl; then
  for d in $domains; do
    pem=$(echo | timeout 8 openssl s_client -servername "$d" -connect 127.0.0.1:443 2>/dev/null)
    end=$(openssl x509 -noout -enddate 2>/dev/null <<<"$pem" | cut -d= -f2)
    sub=$(openssl x509 -noout -subject -issuer 2>/dev/null <<<"$pem" | tr -d ' ')
    if [ -z "$end" ]; then certs+="$d:aucun "
    elif [[ "$sub" == *TRAEFIKDEFAULT* ]] || [[ "$sub" == *selfsigned* ]]; then certs+="$d:defaut "
    else certs+="$d:$(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 )) "
    fi
  done
fi
add certs "$(echo "$certs" | csv)"

# ------------------------------------------------------------------ Résultat
printf '\n\033[1m Contrôle technique : relevé du serveur\033[0m\n'
printf ' (voici exactement ce que contient le code, rien d'"'"'autre)\n\n'
for l in "${OUT[@]}"; do printf '   %-15s %s\n' "${l%%=*}" "${l#*=}"; done
code="MCCT$V-$(printf '%s\n' "${OUT[@]}" | gzip -9 | base64 | tr -d '\n')"
printf '\n\033[1m Copiez ce code (une seule ligne) et collez-le sur la page :\033[0m\n'
printf ' https://marketcompose.com/fr/controle-technique/\n\n%s\n\n' "$code"
[ "$(id -u)" -ne 0 ] && printf '\033[33m Lancé sans sudo : SSH, pare-feu et une partie de Docker n'"'"'ont pas pu être vérifiés.\033[0m\n\n'
exit 0
