Certificate Hub: Centralizzare la gestione dei certificati SSL in vista della rivoluzione 2026

Il problema: quando 199 giorni diventano un incubo

Era una mattina come tante quando ho ricevuto l’email che ha cambiato tutto:

“Gentile Cliente, ti informiamo di un cambiamento importante che riguarda la validità dei certificati SSL: a partire dal 12 marzo 2026, i certificati avranno una validità tecnica di 199 giorni dalla data di emissione.”

Per chi gestisce un paio di siti personali, questo significa poco. Per chi, come me, gestisce una ventina di certificati SSL distribuiti su una trentina di macchine di diversi clienti, significa una cosa sola: il lavoro manuale sta per raddoppiare.

E non finisce qui. Il calendario del CA/Browser Forum è impietoso:

  • Marzo 2026: validità 199 giorni (circa 2 rinnovi/anno)
  • Marzo 2027: validità 100 giorni (circa 4 rinnovi/anno)
  • Marzo 2029: validità 47 giorni (circa 8 rinnovi/anno)

Ho fatto due conti: con 20 certificati e 8 rinnovi all’anno, parliamo di 160 operazioni manuali ogni anno. Tra conversioni di formato, upload su server diversi, configurazioni Nginx, Apache, firewall… un delirio.

L’idea: togliermi di mezzo

Il mio obiettivo era chiaro: non voglio più essere il collo di bottiglia.

Ogni volta che un certificato scade, non deve dipendere dalla mia disponibilità. I colleghi devono poter caricare i certificati. I server devono poterseli andare a prendere da soli. Io devo poter dormire la notte.

La soluzione? Un Certificate Hub centralizzato:

  • Una macchina Linux dove caricare i certificati
  • Conversione automatica in tutti i formati necessari
  • I server client pescano i certificati in autonomia
  • Log di tutto quello che succede

L’architettura

Dopo alcune riflessioni, ho definito questa architettura:

┌─────────────────────────────────────────────────────────────────┐
│                      CERTIFICATE HUB                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   INGRESSO (upload)           │    USCITA (distribuzione)       │
│   ─────────────────           │    ──────────────────────       │
│   • Solo rete interna/VPN     │    • HTTPS esposto              │
│   • SFTP con FileZilla        │    • Auth: IP whitelist         │
│   • Utente dedicato           │    • Fallback: user+password    │
│                               │    • Log ogni download          │
│                                                                 │
│   /incoming/ → [conversione] → /ready/                          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Le scelte architetturali e il perché

Perché SFTP e non FTP?
FTP trasmette le credenziali in chiaro. SFTP usa SSH, è cifrato, e non richiede di aprire porte aggiuntive se SSH è già attivo. Inoltre FileZilla lo supporta nativamente, quindi per i colleghi non cambia nulla.

Perché un utente condiviso?
Gestisco un team piccolo. Creare utenti separati aggiungerebbe complessità senza benefici reali. L’utente certmanager è dedicato solo a questa funzione e vive in una “gabbia” (chroot) che gli impedisce di vedere il resto del sistema.

Perché la separazione incoming/ready?

  • incoming: dove si caricano i file grezzi (può essere un disastro)
  • ready: dove finiscono i file elaborati, puliti, pronti all’uso

I colleghi toccano solo incoming. I server client leggono solo da ready. Nessuno può fare danni.

Perché convertire automaticamente in tutti i formati?
Gestisco ambienti misti:

  • Nginx: vuole fullchain.pem + chiave separata
  • Apache: vuole certificato, catena e chiave separati
  • Firewall/Windows: vogliono .pfx

Generare tutto automaticamente significa che ogni server trova già il formato che gli serve.

L’ambiente di test

Per questo progetto ho usato:

  • 3 VM VMware Workstation con Ubuntu
  • 1 VM = Certificate Hub
  • 2 VM = Server di test (simulano i client)

La VM del Certificate Hub è una Ubuntu Server minimale. Non serve interfaccia grafica.

Implementazione passo-passo

Fase 1: Preparazione del sistema

# Aggiorna il sistema
sudo apt update && sudo apt upgrade -y

# Installa i pacchetti necessari
sudo apt install -y nginx openssl apache2-utils inotify-tools

Cosa installiamo e perché:

  • nginx: servirà per la distribuzione HTTPS (fase successiva)
  • openssl: per le conversioni dei certificati
  • apache2-utils: per creare le password di autenticazione
  • inotify-tools: per monitorare le cartelle in tempo reale (opzionale)

Fase 2: Creazione dell’utente dedicato

# Crea l'utente certmanager
sudo useradd -m -s /bin/bash certmanager
sudo passwd certmanager   # Scegli una password robusta!

Fase 3: Struttura delle cartelle

# Crea la struttura
sudo mkdir -p /srv/certs/{incoming,ready,archive,config,scripts}

# Imposta i permessi base
sudo chown root:root /srv/certs
sudo chmod 755 /srv/certs

# incoming: scrivibile da certmanager
sudo chown certmanager:certmanager /srv/certs/incoming
sudo chmod 755 /srv/certs/incoming

La struttura finale:

/srv/certs/
├── incoming/     ← Upload via SFTP (scrivibile)
├── ready/        ← File elaborati (sola lettura)
├── archive/      ← Storico versioni
├── config/       ← Configurazioni
└── scripts/      ← Script di automazione

Fase 4: Configurazione SFTP con chroot

Questa è la parte cruciale per la sicurezza. Configuriamo SSH per “ingabbiare” l’utente certmanager.

# Backup della configurazione SSH
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup

# Modifica la configurazione
sudo nano /etc/ssh/sshd_config

Aggiungi in fondo al file:

# Chroot per certmanager - accesso solo a /srv/certs
Match User certmanager
    ChrootDirectory /srv/certs
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no

Cosa fa questa configurazione:

  • ChrootDirectory: l’utente vede /srv/certs come se fosse la root /
  • ForceCommand internal-sftp: può usare SOLO SFTP, niente shell
  • AllowTcpForwarding no: non può creare tunnel
  • X11Forwarding no: non può inoltrare sessioni grafiche
# Riavvia SSH (su Ubuntu il servizio si chiama ssh, non sshd!)
sudo systemctl restart ssh

Fase 5: Test con FileZilla

Ora verifica che tutto funzioni:

  1. Apri FileZilla
  2. Connessione rapida:
  • Host: sftp://IP_DELLA_VM
  • Utente: certmanager
  • Password: quella che hai scelto
  • Porta: 22

Dovresti vedere solo le cartelle incoming, ready, archive, ecc. Nessun accesso al resto del sistema.

Fase 6: Lo script di conversione automatica

Questo è il cuore del sistema. Lo script:

  1. Scansiona /incoming/ cercando nuovi certificati
  2. Estrae il CN (Common Name) dal certificato
  3. Genera tutti i formati necessari
  4. Li posiziona in /ready/ con una struttura ordinata
  5. Logga tutto
sudo nano /srv/certs/scripts/convert-certs.sh
#!/bin/bash

# === CONFIGURAZIONE ===
INCOMING_DIR="/srv/certs/incoming"
READY_DIR="/srv/certs/ready"
ARCHIVE_DIR="/srv/certs/archive"
LOG_FILE="/var/log/certs/conversion.log"
PFX_PASSWORD="12345678"

# === FUNZIONI ===
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') | $1" >> "$LOG_FILE"
    echo "$(date '+%Y-%m-%d %H:%M:%S') | $1"
}

log_warning() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') | WARNING: $1" >> "$LOG_FILE"
    echo "$(date '+%Y-%m-%d %H:%M:%S') | WARNING: $1"
}

# Estrae CN dal certificato
get_cn_from_cert() {
    openssl x509 -in "$1" -noout -subject 2>/dev/null | sed -n 's/.*CN *= *\([^,]*\).*/\1/p'
}

# Estrae data scadenza dal certificato
get_expiry_from_cert() {
    openssl x509 -in "$1" -noout -enddate 2>/dev/null | cut -d= -f2
}

# Converte * in star per nomi file
sanitize_domain() {
    echo "$1" | sed 's/\*/star/g'
}

# Crea cartella log se non esiste
mkdir -p "$(dirname "$LOG_FILE")"

log "=========================================="
log "=== Avvio scansione certificati ==="
log "=========================================="

# Scansiona ogni cartella in incoming
for CLIENT_FOLDER in "$INCOMING_DIR"/*/; do
    [ -d "$CLIENT_FOLDER" ] || continue

    FOLDER_NAME=$(basename "$CLIENT_FOLDER")

    # Salta cartelle che iniziano con . o _
    [[ "$FOLDER_NAME" == .* ]] || [[ "$FOLDER_NAME" == _* ]] && continue

    # Estrai cliente e dominio dal nome cartella (formato: cliente_dominio)
    if [[ "$FOLDER_NAME" == *"_"* ]]; then
        CLIENTE="${FOLDER_NAME%%_*}"
        DOMINIO_CARTELLA="${FOLDER_NAME#*_}"
    else
        log_warning "$FOLDER_NAME - formato non valido (usa cliente_dominio). Saltato."
        continue
    fi

    # Cerca il file certificato
    CRT_FILE=$(find "$CLIENT_FOLDER" -maxdepth 1 -name "*.crt" -type f 2>/dev/null | head -1)
    KEY_FILE=$(find "$CLIENT_FOLDER" -maxdepth 1 -name "*.key" -type f 2>/dev/null | head -1)
    CHAIN_FILE=$(find "$CLIENT_FOLDER" -maxdepth 1 \( -name "chain.pem" -o -name "*chain*.pem" -o -name "ca-bundle.crt" -o -name "*intermediate*" \) -type f 2>/dev/null | head -1)

    # Se non c'è .crt, salta
    if [ -z "$CRT_FILE" ]; then
        continue
    fi

    # Estrai CN dal certificato
    CN_RAW=$(get_cn_from_cert "$CRT_FILE")
    if [ -z "$CN_RAW" ]; then
        log_warning "$FOLDER_NAME - impossibile leggere CN dal certificato. Saltato."
        continue
    fi

    # Sanitizza il CN per i nomi file (sostituisce * con star)
    CN_SAFE=$(sanitize_domain "$CN_RAW")

    # Estrai data scadenza
    EXPIRY_DATE=$(get_expiry_from_cert "$CRT_FILE")

    log "Trovato: $CLIENTE / $CN_RAW"

    # Verifica corrispondenza nome cartella vs CN
    DOMINIO_CARTELLA_NORMALIZED=$(echo "$DOMINIO_CARTELLA" | tr '[:upper:]' '[:lower:]')
    CN_SAFE_NORMALIZED=$(echo "$CN_SAFE" | tr '[:upper:]' '[:lower:]')

    if [ "$DOMINIO_CARTELLA_NORMALIZED" != "$CN_SAFE_NORMALIZED" ]; then
        log_warning "Nome cartella ($DOMINIO_CARTELLA) non corrisponde al certificato ($CN_RAW)"
        log_warning "I file verranno generati come: $CN_SAFE.*"
    fi

    # Crea cartella destinazione usando il CN reale
    DEST_DIR="$READY_DIR/$CLIENTE/$CN_SAFE"
    mkdir -p "$DEST_DIR"

    # Copia i file con nome basato sul CN
    cp "$CRT_FILE" "$DEST_DIR/$CN_SAFE.crt"
    log "  -> $CN_SAFE.crt copiato"

    if [ -n "$KEY_FILE" ]; then
        cp "$KEY_FILE" "$DEST_DIR/$CN_SAFE.key"
        log "  -> $CN_SAFE.key copiato"
    elif [ -f "$DEST_DIR/$CN_SAFE.key" ]; then
        log "  -> $CN_SAFE.key già presente (riutilizzata)"
    else
        log_warning "Nessuna chiave privata trovata per $CN_RAW"
    fi

    if [ -n "$CHAIN_FILE" ]; then
        cp "$CHAIN_FILE" "$DEST_DIR/$CN_SAFE.chain.pem"
        log "  -> $CN_SAFE.chain.pem copiato"
    fi

    # === GENERA FORMATI ===

    # Fullchain per Nginx (cert + chain)
    if [ -f "$DEST_DIR/$CN_SAFE.chain.pem" ]; then
        cat "$DEST_DIR/$CN_SAFE.crt" "$DEST_DIR/$CN_SAFE.chain.pem" > "$DEST_DIR/$CN_SAFE.fullchain.pem"
    else
        cp "$DEST_DIR/$CN_SAFE.crt" "$DEST_DIR/$CN_SAFE.fullchain.pem"
    fi
    log "  -> $CN_SAFE.fullchain.pem generato (Nginx)"

    # PFX per firewall/Windows (se abbiamo la key)
    if [ -f "$DEST_DIR/$CN_SAFE.key" ]; then
        if [ -f "$DEST_DIR/$CN_SAFE.chain.pem" ]; then
            openssl pkcs12 -export \
                -out "$DEST_DIR/$CN_SAFE.pfx" \
                -inkey "$DEST_DIR/$CN_SAFE.key" \
                -in "$DEST_DIR/$CN_SAFE.crt" \
                -certfile "$DEST_DIR/$CN_SAFE.chain.pem" \
                -password pass:"$PFX_PASSWORD" 2>/dev/null
        else
            openssl pkcs12 -export \
                -out "$DEST_DIR/$CN_SAFE.pfx" \
                -inkey "$DEST_DIR/$CN_SAFE.key" \
                -in "$DEST_DIR/$CN_SAFE.crt" \
                -password pass:"$PFX_PASSWORD" 2>/dev/null
        fi
        log "  -> $CN_SAFE.pfx generato (Firewall/Windows)"
    fi

    # Genera INFO.txt
    cat > "$DEST_DIR/INFO.txt" << EOF
==========================================
 CERTIFICATO: $CN_RAW
==========================================
Cliente:     $CLIENTE
Dominio:     $CN_RAW
Scadenza:    $EXPIRY_DATE
Generato:    $(date '+%Y-%m-%d %H:%M:%S')

FILE DISPONIBILI:
  $CN_SAFE.crt           - Certificato (Apache)
  $CN_SAFE.key           - Chiave privata
  $CN_SAFE.chain.pem     - Catena intermedi (se presente)
  $CN_SAFE.fullchain.pem - Cert + Chain (Nginx)
  $CN_SAFE.pfx           - Bundle (Firewall/Windows)
                           Password PFX: $PFX_PASSWORD

URL DOWNLOAD (quando attivo HTTPS):
  /ready/$CLIENTE/$CN_SAFE/

==========================================
EOF
    log "  -> INFO.txt generato"

    # Imposta permessi
    chown -R root:certmanager "$READY_DIR/$CLIENTE"
    find "$READY_DIR/$CLIENTE" -type d -exec chmod 750 {} \;
    find "$READY_DIR/$CLIENTE" -type f -exec chmod 640 {} \;

    # Archivia con data
    ARCHIVE_DEST="$ARCHIVE_DIR/$CLIENTE/$CN_SAFE/$(date '+%Y%m%d_%H%M%S')"
    mkdir -p "$ARCHIVE_DEST"
    cp "$DEST_DIR"/* "$ARCHIVE_DEST/" 2>/dev/null
    log "  -> Archiviato in $ARCHIVE_DEST"

    log "OK: $CLIENTE / $CN_RAW completato"
    log "------------------------------------------"
done

log "=== Scansione completata ==="
log ""
# Rendi eseguibile lo script
sudo chmod +x /srv/certs/scripts/convert-certs.sh

# Crea la cartella per i log
sudo mkdir -p /var/log/certs

Fase 7: Automazione con cron

# Modifica il crontab di root
sudo crontab -e

Aggiungi questa riga:

*/5 * * * * /srv/certs/scripts/convert-certs.sh > /dev/null 2>&1

Ora lo script gira automaticamente ogni 5 minuti.

Note sulla gestione dei certificati wildcard

I certificati wildcard (es. *.example.com) presentano un problema: l’asterisco non è un carattere valido nei nomi file.

Convenzione adottata:

  • Nel nome cartella: usa star al posto di *
  • Esempio: cliente_star.example.com

Lo script rileva automaticamente il CN reale dal certificato, quindi anche se il collega sbaglia il nome della cartella, i file vengono generati con il nome corretto. Un warning viene scritto nel log per segnalare la discrepanza.

Cosa abbiamo ottenuto (Parte 1)

ComponenteStato
VM Ubuntu Certificate Hub
Utente dedicato con chroot SFTP
Accesso FileZilla funzionante
Conversione automatica multi-formato
Nomi file basati sul CN del certificato
Warning per discrepanze nei nomi
File INFO.txt con riepilogo
Archiviazione storica
Cron ogni 5 minuti

Prossimi passi (Parte 2)

Nella seconda parte dell’articolo implementeremo:

  • Nginx HTTPS per la distribuzione ai server client
  • Autenticazione mista: IP whitelist + basic auth fallback
  • Log di ogni download
  • Script curl da installare sui server client
  • Test end-to-end completo

Considerazioni finali

Questo progetto nasce da un’esigenza concreta: non impazzire quando i certificati passeranno a 47 giorni di validità. La soluzione non è rivoluzionaria, ma è pratica.

L’obiettivo non era creare il sistema perfetto, ma un sistema che:

  1. Funzioni in modo affidabile
  2. Sia gestibile anche da colleghi meno tecnici
  3. Mi tolga dal percorso critico

Con questa prima fase, abbiamo costruito le fondamenta. I colleghi possono già caricare certificati via FileZilla, e il sistema genera automaticamente tutti i formati necessari.

La vera magia arriverà con la seconda parte, quando i server potranno pescare i certificati in autonomia.


Questo articolo è parte di una serie. La Parte 2 coprirà la distribuzione automatica ai server client.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Torna in alto