Automatizar Backup SCADA: Herramientas y Scripts
Cómo automatizar el backup de sistemas SCADA con scripts y herramientas. Rsync, Veeam, snapshots y planes de recuperación para entornos industriales.
Pregunta rápida: si mañana un ransomware cifra tu servidor SCADA, ¿cuántas horas tarda tu planta en volver a producir? Si la respuesta es “no lo sé” o incluye la palabra “días”, tienes un problema serio.
Automatizar el backup de sistemas SCADA no es un proyecto “para cuando haya tiempo”. Es la diferencia entre recuperarte en horas o en semanas. Aquí tienes las herramientas, los scripts y los procedimientos que funcionan — todo probado en entornos reales, desde los programas de PLC hasta los históricos de proceso.
Para planes de recuperación completos, la guía sobre Backup SCADA y Recuperación ante Desastres cubre la estrategia global.
Qué respaldar (y lo que todo el mundo olvida)
“Hacer backup del SCADA” no es copiar una carpeta. Un sistema SCADA tiene componentes distribuidos por toda la planta, cada uno con sus propios mecanismos de respaldo:
Inventario completo de componentes
| Componente | Ubicación típica | Tipo de dato | Frecuencia recomendada |
|---|---|---|---|
| Proyecto SCADA (pantallas, scripts, configuración) | Servidor SCADA | Archivos de proyecto | Tras cada cambio + semanal |
| Base de datos de configuración | Servidor SCADA | SQL/propietario | Diaria |
| Históricos de proceso (Historian) | Servidor Historian | Base de datos temporal | Diaria incremental + semanal completa |
| Programas de PLC | PLCs en campo | Binario/proyecto | Tras cada cambio + mensual |
| Pantallas HMI (paneles locales) | Paneles HMI | Archivos de proyecto | Tras cada cambio |
| Configuración de red OT | Switches, firewalls, routers | Config text/binario | Semanal + tras cada cambio |
| Certificados y claves | Servidor SCADA, firewall | Archivos | Tras cada renovación |
| Recetas y parámetros de producción | SCADA/MES | Base de datos | Diaria |
| Documentación del sistema | Repositorio/NAS | Documentos | Continua (versionado) |
Lo que nadie respalda (y genera los peores dolores de cabeza)
Estos tres componentes causan los mayores problemas en una recuperación porque casi nunca se incluyen:
- Configuración de switches industriales: un switch Scalance o Stratix mal configurado después de un reemplazo puede dejar sin comunicación a toda una línea.
- Licencias de software: las licencias de WinCC, FactoryTalk o Ignition pueden estar vinculadas a hardware. Sin el archivo de licencia o la clave de activación, el software no arranca.
- Parámetros de variadores de frecuencia: un variador Sinamics o PowerFlex con 200 parámetros ajustados en puesta en marcha puede tardar días en reconfigurarse sin backup.
Arquitectura: dónde poner el servidor de backup
No puedes tratar la red OT como una red IT más. La arquitectura de backup debe respetar la segmentación de red de IEC 62443, detallada en la guía de ciberseguridad industrial OT.
Zona DMZ industrial
El servidor de backup debe ubicarse en la DMZ industrial, la zona intermedia entre la red OT (nivel 2-3 del modelo Purdue) y la red corporativa IT:
┌───────────────────────────────────────────────────────┐
│ RED CORPORATIVA (IT) │
│ - Correo, ERP, ofimática │
└──────────────────────┬────────────────────────────────┘
│ Firewall IT/OT
┌──────────────────────┴────────────────────────────────┐
│ DMZ INDUSTRIAL │
│ ┌──────────────────┐ ┌───────────────────────────┐ │
│ │ Servidor Backup │ │ Historian (réplica) │ │
│ │ - rsync receiver │ │ - Datos para IT │ │
│ │ - Veeam proxy │ │ │ │
│ └──────────────────┘ └───────────────────────────┘ │
└──────────────────────┬────────────────────────────────┘
│ Firewall OT (reglas estrictas)
┌──────────────────────┴────────────────────────────────┐
│ RED OT (SCADA / Control) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Servidor │ │ Historian│ │ HMI │ │ PLCs │ │
│ │ SCADA │ │ │ │ Servers │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────────────────────────────┘
Reglas de firewall
Mínimas y unidireccionales — siempre:
| Origen | Destino | Puerto | Protocolo | Dirección |
|---|---|---|---|---|
| Servidor SCADA | Servidor Backup (DMZ) | 22 | SSH/SCP | OT → DMZ |
| Servidor Historian | Servidor Backup (DMZ) | 22 | SSH/SCP | OT → DMZ |
| Servidor Backup (DMZ) | NAS externo / cloud | 443 | HTTPS (S3) | DMZ → IT |
Esto es fundamental: la red OT inicia la conexión hacia la DMZ, nunca al revés. Si tu servidor de backup puede conectarse a los PLCs, tienes un problema de seguridad independientemente de para qué lo uses.
rsync: el caballo de batalla
Rsync es la herramienta más versátil para backup en entornos Linux (y también funciona en Windows con Cygwin o WSL). Solo transfiere los cambios, lo que reduce drásticamente el tiempo y el ancho de banda. Para el 80 % de los casos, rsync con un buen script es todo lo que necesitas.
Script de backup SCADA con rsync
#!/bin/bash
# backup_scada.sh — Backup automatizado de servidor SCADA
# Ejecutar desde el SERVIDOR SCADA (OT) hacia el servidor de backup (DMZ)
set -euo pipefail
# === CONFIGURACIÓN ===
BACKUP_SERVER="[email protected]" # Servidor en DMZ industrial
BACKUP_BASE="/backup/scada"
SSH_KEY="/root/.ssh/id_backup_scada" # Clave SSH dedicada (sin passphrase)
LOG_FILE="/var/log/scada_backup.log"
RETENTION_DAYS=90 # Retención de backups diarios
DATE=$(date +%Y-%m-%d_%H%M)
HOSTNAME=$(hostname)
# Directorios a respaldar (ajustar según SCADA)
SCADA_DIRS=(
"/opt/ignition/data" # Ignition: proyectos, config, módulos
"/opt/ignition/backups" # Ignition: backups automáticos del gateway
"/var/lib/postgresql" # Base de datos PostgreSQL (historian)
"/etc/scada" # Configuraciones personalizadas
"/opt/hmi-projects" # Proyectos de paneles HMI
)
# === FUNCIONES ===
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
check_connectivity() {
if ! ssh -i "$SSH_KEY" -o ConnectTimeout=10 "$BACKUP_SERVER" "echo ok" &>/dev/null; then
log "ERROR: No se puede conectar al servidor de backup $BACKUP_SERVER"
exit 1
fi
}
backup_directory() {
local src="$1"
local dest="$BACKUP_BASE/$HOSTNAME/$(basename "$src")"
if [ ! -d "$src" ]; then
log "AVISO: Directorio $src no existe, saltando"
return 0
fi
log "Respaldando $src → $BACKUP_SERVER:$dest"
rsync -azh --delete \
--backup --backup-dir="$BACKUP_BASE/$HOSTNAME/histórico/$DATE/$(basename "$src")" \
--exclude='*.tmp' \
--exclude='*.lock' \
--exclude='core.*' \
--timeout=300 \
-e "ssh -i $SSH_KEY -o StrictHostKeyChecking=yes" \
"$src/" \
"$BACKUP_SERVER:$dest/" 2>&1 | tee -a "$LOG_FILE"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log "OK: $src respaldado correctamente"
else
log "ERROR: Fallo en backup de $src (código ${PIPESTATUS[0]})"
return 1
fi
}
backup_postgresql() {
local dump_file="/tmp/scada_db_${DATE}.sql.gz"
log "Exportando base de datos PostgreSQL..."
pg_dumpall -U postgres | gzip > "$dump_file"
rsync -azh \
-e "ssh -i $SSH_KEY" \
"$dump_file" \
"$BACKUP_SERVER:$BACKUP_BASE/$HOSTNAME/database/"
rm -f "$dump_file"
log "OK: Base de datos PostgreSQL respaldada"
}
cleanup_old_backups() {
log "Limpiando backups antiguos (> $RETENTION_DAYS días)..."
ssh -i "$SSH_KEY" "$BACKUP_SERVER" \
"find $BACKUP_BASE/$HOSTNAME/histórico -type d -mtime +$RETENTION_DAYS -exec rm -rf {} + 2>/dev/null || true"
ssh -i "$SSH_KEY" "$BACKUP_SERVER" \
"find $BACKUP_BASE/$HOSTNAME/database -type f -mtime +$RETENTION_DAYS -delete 2>/dev/null || true"
log "OK: Limpieza completada"
}
verify_backup() {
log "Verificando integridad del backup..."
local remote_size
remote_size=$(ssh -i "$SSH_KEY" "$BACKUP_SERVER" \
"du -sh $BACKUP_BASE/$HOSTNAME/ 2>/dev/null | cut -f1")
log "Tamaño total del backup en servidor: $remote_size"
# Verificar que los archivos críticos existen
local critical_files=(
"$BACKUP_BASE/$HOSTNAME/data/gateway.xml"
"$BACKUP_BASE/$HOSTNAME/database/scada_db_${DATE}.sql.gz"
)
for f in "${critical_files[@]}"; do
if ssh -i "$SSH_KEY" "$BACKUP_SERVER" "test -f $f"; then
log "OK: Archivo crítico verificado: $(basename "$f")"
else
log "AVISO: Archivo crítico no encontrado: $f"
fi
done
}
# === EJECUCIÓN ===
log "=========================================="
log "Inicio backup SCADA - $HOSTNAME"
log "=========================================="
check_connectivity
ERRORES=0
for dir in "${SCADA_DIRS[@]}"; do
backup_directory "$dir" || ((ERRORES++))
done
backup_postgresql || ((ERRORES++))
cleanup_old_backups
verify_backup
if [ $ERRORES -eq 0 ]; then
log "RESULTADO: Backup completado sin errores"
else
log "RESULTADO: Backup completado con $ERRORES errores — REVISAR"
# Enviar alerta (ajustar según sistema de notificación)
# mail -s "ALERTA: Backup SCADA con errores" [email protected] < "$LOG_FILE"
fi
log "=========================================="
Programación con cron
# /etc/cron.d/scada_backup
# Backup diario a las 02:00 (hora de menor actividad)
0 2 * * * root /opt/scripts/backup_scada.sh >> /var/log/scada_backup_cron.log 2>&1
# Backup adicional antes de ventana de mantenimiento (domingos 22:00)
0 22 * * 0 root /opt/scripts/backup_scada.sh >> /var/log/scada_backup_cron.log 2>&1
Backup de PLCs: lo más crítico y lo más descuidado
Un PLC sin programa es un ladrillo de 5.000 €. Reprogramar un S7-1500 desde cero puede llevar semanas. Restaurar desde backup, minutos. La diferencia es tener o no tener una copia.
Siemens S7 (TIA Portal)
TIA Portal almacena los proyectos en formato propietario (carpetas con archivos .ap1X). El backup puede hacerse de dos formas:
Opción A: Backup de archivos de proyecto
#!/bin/bash
# Backup de proyectos TIA Portal desde el PC de ingeniería
# Los proyectos suelen estar en C:\Users\<usuario>\Documents\Automation
TIA_PROJECTS="/mnt/engineering-pc/Users/Shared/TIA_Projects"
BACKUP_DEST="[email protected]:/backup/plc/siemens"
rsync -azh --delete \
--include='*/' \
--include='*.ap17' \
--include='*.ap18' \
--include='*.ap19' \
--include='*.zap17' \
--include='*.zap18' \
--include='*.zap19' \
--exclude='*' \
-e "ssh -i /root/.ssh/id_backup" \
"$TIA_PROJECTS/" \
"$BACKUP_DEST/$(date +%Y-%m-%d)/"
Opción B: Upload desde el PLC (online)
Para extraer el programa directamente del PLC se puede usar la librería python-snap7:
"""
Backup online de PLC Siemens S7-1500 usando snap7.
Extrae los bloques de programa del PLC y los guarda en archivos.
"""
import snap7
import os
from datetime import datetime
def backup_plc_s7(plc_ip: str, rack: int, slot: int, output_dir: str):
"""
Conecta al PLC y descarga todos los bloques de programa.
"""
client = snap7.client.Client()
client.connect(plc_ip, rack, slot)
if not client.get_connected():
raise ConnectionError(f"No se pudo conectar al PLC {plc_ip}")
fecha = datetime.now().strftime("%Y-%m-%d_%H%M")
backup_dir = os.path.join(output_dir, f"{plc_ip}_{fecha}")
os.makedirs(backup_dir, exist_ok=True)
# Listar y descargar bloques OB, FC, FB, DB
block_types = {
snap7.types.Block.OB: "OB",
snap7.types.Block.FC: "FC",
snap7.types.Block.FB: "FB",
snap7.types.Block.DB: "DB",
}
total_bloques = 0
for block_type, prefix in block_types.items():
block_list = client.list_blocks_of_type(block_type, 1024)
for block_num in block_list:
try:
data = client.full_upload(block_type, block_num)
filename = f"{prefix}{block_num}.blk"
filepath = os.path.join(backup_dir, filename)
with open(filepath, "wb") as f:
f.write(data)
total_bloques += 1
except Exception as e:
print(f"Error descargando {prefix}{block_num}: {e}")
client.disconnect()
print(f"Backup completado: {total_bloques} bloques guardados en {backup_dir}")
return backup_dir
# Ejemplo: backup de 3 PLCs de una línea
plcs = [
{"ip": "10.10.1.10", "rack": 0, "slot": 1, "nombre": "PLC_Linea1"},
{"ip": "10.10.1.11", "rack": 0, "slot": 1, "nombre": "PLC_Linea2"},
{"ip": "10.10.1.12", "rack": 0, "slot": 1, "nombre": "PLC_Robot"},
]
for plc in plcs:
try:
backup_plc_s7(plc["ip"], plc["rack"], plc["slot"], "/backup/plc/online")
print(f"OK: {plc['nombre']} ({plc['ip']})")
except Exception as e:
print(f"ERROR: {plc['nombre']} ({plc['ip']}): {e}")
Allen-Bradley (Rockwell)
Para PLCs CompactLogix y ControlLogix, el backup se puede automatizar con la herramienta de línea de comandos logix-cli o mediante scripts que interactúan con RSLogix/Studio 5000:
#!/bin/bash
# Backup de proyectos Rockwell desde el PC de ingeniería
# Los archivos .ACD son los proyectos de Studio 5000
ROCKWELL_PROJECTS="/mnt/engineering-pc/Rockwell/Projects"
BACKUP_DEST="[email protected]:/backup/plc/rockwell"
# Copiar todos los archivos .ACD y .RSS
find "$ROCKWELL_PROJECTS" \( -name "*.ACD" -o -name "*.RSS" -o -name "*.L5K" \) \
-newer /tmp/last_plc_backup 2>/dev/null | while read -r file; do
rsync -azh -e "ssh -i /root/.ssh/id_backup" \
"$file" "$BACKUP_DEST/$(date +%Y-%m-%d)/"
echo "Respaldado: $(basename "$file")"
done
touch /tmp/last_plc_backup
Octoplant y MDT AutoSave: cuándo merece la pena pagar
Si tienes decenas o cientos de dispositivos programables, las herramientas especializadas justifican cada euro:
- Octoplant (antes Versiondog): gestión de versiones de proyectos de automatización. Soporta TIA Portal, Studio 5000, Unity Pro, DCS y muchos más. Detecta cambios automáticamente y mantiene un historial completo de versiones.
- MDT AutoSave: similar a Octoplant, con fuerte presencia en el mercado norteamericano. Integración con Rockwell, Siemens, Schneider y GE.
Lo que justifica el coste: la comparación inteligente de programas. No solo te dicen “algo cambió” — te muestran exactamente qué rung se modificó, qué DB se añadió. Rsync no puede hacer eso.
Backup de red OT: switches y firewalls
Un switch industrial con VLANs, QoS, IGMP snooping y port mirroring configurados puede tardar días en reconstruirse desde cero. Hemos visto plantas paradas esperando a que alguien recuerde cómo estaba configurado un Scalance.
Script para switches Cisco / Cisco IE
"""
Backup de configuración de switches de red OT.
Compatible con Cisco IE (Industrial Ethernet) y switches gestionados genéricos.
Usa Netmiko para conexión SSH.
"""
from netmiko import ConnectHandler
from datetime import datetime
import os
import json
# Inventario de dispositivos de red OT
DISPOSITIVOS = [
{
"device_type": "cisco_ios",
"host": "10.10.0.1",
"username": "admin",
"password": "REDACTED", # Usar vault en producción
"nombre": "SW-OT-Core"
},
{
"device_type": "cisco_ios",
"host": "10.10.0.2",
"username": "admin",
"password": "REDACTED",
"nombre": "SW-OT-Linea1"
},
{
"device_type": "cisco_ios",
"host": "10.10.0.3",
"username": "admin",
"password": "REDACTED",
"nombre": "SW-OT-Linea2"
},
]
BACKUP_DIR = "/backup/red-ot"
fecha = datetime.now().strftime("%Y-%m-%d_%H%M")
resultados = []
for dispositivo in DISPOSITIVOS:
nombre = dispositivo.pop("nombre")
try:
conn = ConnectHandler(**dispositivo)
# Obtener running-config
running = conn.send_command("show running-config")
# Obtener versión y modelo
version = conn.send_command("show version")
# Obtener tabla de VLANs
vlans = conn.send_command("show vlan brief")
# Guardar archivos
device_dir = os.path.join(BACKUP_DIR, nombre, fecha)
os.makedirs(device_dir, exist_ok=True)
with open(os.path.join(device_dir, "running-config.txt"), "w") as f:
f.write(running)
with open(os.path.join(device_dir, "version.txt"), "w") as f:
f.write(version)
with open(os.path.join(device_dir, "vlans.txt"), "w") as f:
f.write(vlans)
conn.disconnect()
resultados.append({"nombre": nombre, "status": "OK"})
print(f"OK: {nombre} ({dispositivo['host']})")
except Exception as e:
resultados.append({"nombre": nombre, "status": "ERROR", "detalle": str(e)})
print(f"ERROR: {nombre} ({dispositivo['host']}): {e}")
# Restaurar el campo nombre para siguiente iteración
dispositivo["nombre"] = nombre
# Guardar resumen
with open(os.path.join(BACKUP_DIR, f"resumen_{fecha}.json"), "w") as f:
json.dump(resultados, f, indent=2)
Firewalls industriales (Fortinet FortiGate, Palo Alto)
#!/bin/bash
# Backup de firewall FortiGate (muy común en segmentación IT/OT)
# FortiGate permite backup por SSH o API REST
FIREWALL_IP="10.10.0.254"
BACKUP_DIR="/backup/firewall"
DATE=$(date +%Y-%m-%d_%H%M)
# Opción 1: Backup por SSH
ssh admin@$FIREWALL_IP "execute backup config ssh" > \
"$BACKUP_DIR/fortigate_${DATE}.conf"
# Opción 2: Backup por API REST (requiere API key)
API_KEY="REDACTED"
curl -sk "https://$FIREWALL_IP/api/v2/monitor/system/config/backup?scope=global" \
-H "Authorization: Bearer $API_KEY" \
-o "$BACKUP_DIR/fortigate_${DATE}.conf"
echo "Backup firewall completado: fortigate_${DATE}.conf"
Veeam: la solución para entornos virtualizados
Si tus servidores SCADA corren sobre VMware o Hyper-V (cada vez más habitual), Veeam Backup & Replication es la herramienta de referencia:
- Backup a nivel de VM completa: no es necesario instalar agentes dentro del SCADA
- Snapshots consistentes: coordina con VMware/Hyper-V para garantizar consistencia
- Restauración granular: puede restaurar una VM completa, un disco individual o archivos sueltos
- Réplica para disaster recovery: mantiene una copia actualizada de la VM lista para arrancar en otro host
Configuración para SCADA (cuidado con estas trampas)
- No usar agentes dentro de la VM SCADA: algunos SCADA (WinCC, FactoryTalk) son sensibles a procesos adicionales. Usar backup a nivel de hipervisor.
- Excluir el procesamiento de aplicaciones (Application-Aware Processing) si causa problemas con el SCADA. Mejor un backup crash-consistent que un backup fallido.
- Programar el backup fuera del horario de producción o durante turnos de baja carga.
- Verificar con SureBackup: Veeam puede arrancar automáticamente la VM desde el backup en un entorno aislado para verificar que arranca correctamente.
Script PowerShell para Veeam (backup programado)
# Verificar estado del último backup de las VMs SCADA
# Ejecutar desde el servidor Veeam
$scada_vms = @("SCADA-Server-01", "Historian-01", "HMI-Server-01")
foreach ($vm in $scada_vms) {
$session = Get-VBRBackupSession |
Where-Object { $_.JobName -match $vm } |
Sort-Object EndTimeUTC -Descending |
Select-Object -First 1
$status = $session.Result
$end_time = $session.EndTimeUTC
$size_gb = [math]::Round($session.BackupStats.BackupSize / 1GB, 2)
Write-Host "$vm : $status | Fin: $end_time | Tamaño: ${size_gb} GB"
if ($status -ne "Success") {
# Enviar alerta
Send-MailMessage -From "[email protected]" -To "[email protected]" `
-Subject "ALERTA: Backup fallido de $vm" `
-Body "El backup de $vm finalizó con estado: $status" `
-SmtpServer "smtp.empresa.com"
}
}
Backup del Historian: datos que no puedes perder
Los datos históricos de proceso no son “nice to have”. En sectores como farmacéutico o alimentario, perder la trazabilidad tiene implicaciones regulatorias graves. Y en cualquier sector, perder meses de datos históricos elimina la capacidad de análisis y mejora.
PostgreSQL / TimescaleDB (Ignition, Historian open-source)
#!/bin/bash
# Backup de base de datos Historian (PostgreSQL/TimescaleDB)
# Usa pg_dump con compresión y paralelismo
DB_NAME="scada_historian"
DB_USER="postgres"
BACKUP_DIR="/backup/historian"
DATE=$(date +%Y-%m-%d_%H%M)
JOBS=4 # Paralelismo (ajustar según CPU)
# Backup completo con compresión
pg_dump -U "$DB_USER" -d "$DB_NAME" \
-Fd -j "$JOBS" \
-f "$BACKUP_DIR/${DB_NAME}_${DATE}" \
--verbose 2>&1 | tee "/var/log/historian_backup.log"
# Verificar integridad
pg_restore -U "$DB_USER" -d template1 \
--list "$BACKUP_DIR/${DB_NAME}_${DATE}" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "OK: Backup verificado correctamente"
else
echo "ERROR: Backup corrupto — verificar manualmente"
fi
# Comprimir para transferencia
tar czf "$BACKUP_DIR/${DB_NAME}_${DATE}.tar.gz" \
-C "$BACKUP_DIR" "${DB_NAME}_${DATE}"
rm -rf "$BACKUP_DIR/${DB_NAME}_${DATE}"
echo "Tamaño: $(du -h "$BACKUP_DIR/${DB_NAME}_${DATE}.tar.gz" | cut -f1)"
InfluxDB (popular en IoT industrial)
#!/bin/bash
# Backup de InfluxDB (v2.x)
DATE=$(date +%Y-%m-%d_%H%M)
BACKUP_DIR="/backup/influxdb"
influx backup "$BACKUP_DIR/$DATE" \
--org "scada" \
--token "$INFLUX_TOKEN"
# Comprimir
tar czf "$BACKUP_DIR/influxdb_${DATE}.tar.gz" \
-C "$BACKUP_DIR" "$DATE"
rm -rf "$BACKUP_DIR/$DATE"
echo "Backup InfluxDB completado: influxdb_${DATE}.tar.gz"
Verificación: sin esto, no tienes backup
Un backup que no se verifica no es un backup — es una esperanza. Hemos visto empresas descubrir que sus backups estaban corruptos justo en el momento de la crisis. La verificación tiene que ser automática.
Script de verificación integral
#!/bin/bash
# verify_backups.sh — Verificación diaria de todos los backups SCADA
# Ejecutar a las 06:00, después de que todos los backups nocturnos hayan terminado
set -euo pipefail
BACKUP_BASE="/backup"
REPORT_FILE="/tmp/backup_verification_$(date +%Y-%m-%d).txt"
ALERTAS=0
log() { echo "[$(date '+%H:%M:%S')] $1" | tee -a "$REPORT_FILE"; }
log "=== VERIFICACIÓN DE BACKUPS SCADA ==="
log "Fecha: $(date '+%Y-%m-%d %H:%M')"
log ""
# 1. Verificar que existe backup de hoy
verificar_existencia() {
local nombre="$1"
local patron="$2"
local min_size="$3" # Tamaño mínimo en KB
local archivo
archivo=$(find "$BACKUP_BASE" -name "$patron" -newer /tmp/last_verify 2>/dev/null | head -1)
if [ -z "$archivo" ]; then
log "FALLO: No se encontró backup reciente de $nombre"
((ALERTAS++))
return
fi
local size
size=$(du -k "$archivo" | cut -f1)
if [ "$size" -lt "$min_size" ]; then
log "FALLO: Backup de $nombre demasiado pequeño (${size}KB < ${min_size}KB esperado)"
((ALERTAS++))
return
fi
log "OK: $nombre — $(du -h "$archivo" | cut -f1) — $(basename "$archivo")"
}
# Verificar cada componente
verificar_existencia "SCADA (Ignition)" "data" 50000
verificar_existencia "Base de datos" "scada_db_*.sql.gz" 10000
verificar_existencia "Red OT" "running-config.txt" 1
verificar_existencia "Historian" "scada_historian_*.tar.gz" 100000
# 2. Verificar integridad de archivos comprimidos
log ""
log "--- Verificación de integridad ---"
find "$BACKUP_BASE" -name "*.tar.gz" -newer /tmp/last_verify | while read -r archivo; do
if gzip -t "$archivo" 2>/dev/null; then
log "OK: Integridad verificada: $(basename "$archivo")"
else
log "FALLO: Archivo corrupto: $(basename "$archivo")"
((ALERTAS++))
fi
done
# 3. Verificar espacio en disco
ESPACIO_LIBRE=$(df -BG "$BACKUP_BASE" | tail -1 | awk '{print $4}' | tr -d 'G')
if [ "$ESPACIO_LIBRE" -lt 50 ]; then
log "AVISO: Espacio libre bajo en servidor de backup: ${ESPACIO_LIBRE}GB"
((ALERTAS++))
fi
# Resumen
log ""
log "=== RESUMEN ==="
if [ $ALERTAS -eq 0 ]; then
log "RESULTADO: Todos los backups verificados correctamente"
else
log "RESULTADO: $ALERTAS alertas detectadas — REQUIERE ATENCIÓN"
fi
touch /tmp/last_verify
# Enviar alerta si hay problemas
if [ $ALERTAS -gt 0 ]; then
cat "$REPORT_FILE"
# Integrar con sistema de notificación (email, Telegram, SNMP trap)
fi
Plan de recuperación: si no lo has probado, no funciona
Tener backups es la mitad del trabajo. La otra mitad es saber restaurar — y haberlo practicado.
Procedimiento de restauración SCADA (Ignition)
# 1. Detener el servicio Ignition
sudo systemctl stop ignition
# 2. Restaurar datos desde backup
rsync -azh [email protected]:/backup/scada/$(hostname)/data/ /opt/ignition/data/
# 3. Restaurar base de datos
gunzip -c /tmp/scada_db_latest.sql.gz | psql -U postgres
# 4. Verificar permisos
chown -R ignition:ignition /opt/ignition/data
# 5. Arrancar servicio
sudo systemctl start ignition
# 6. Verificar en el gateway web (https://localhost:8088)
echo "Verificar estado del gateway en https://$(hostname):8088"
Simulacros de recuperación
El cumplimiento de IEC 62443 exige demostrar que la recuperación funciona, no solo que los backups existen. Programa simulacros que cumplan esto:
- Realizarse al menos una vez al año (idealmente cada 6 meses)
- Restaurar en un entorno de prueba, nunca en producción
- Documentar el tiempo de recuperación real (RTO) y compararlo con el objetivo
- Identificar gaps (¿faltó algún componente? ¿las contraseñas estaban documentadas?)
Métricas clave de recuperación
| Métrica | Definición | Objetivo típico |
|---|---|---|
| RPO (Recovery Point Objective) | Máxima pérdida de datos tolerable | 24 h para configuración, 1 h para historian |
| RTO (Recovery Time Objective) | Tiempo máximo de inactividad tolerable | 4-8 h para SCADA completo |
| Tiempo real de recuperación | Tiempo medido en simulacro | Debe ser < RTO |
Estrategia 3-2-1: no te compliques más
La regla de oro que funciona:
- 3 copias del dato (original + 2 backups)
- 2 tipos de soporte diferentes (disco local + NAS/cinta/cloud)
- 1 copia fuera del sitio (en otra ubicación física)
Aplicación al entorno SCADA
Copia 1: Datos originales en el servidor SCADA (red OT)
Copia 2: Backup en servidor de la DMZ industrial (disco NAS)
Copia 3: Copia cifrada en almacenamiento fuera del sitio
- Opción A: NAS en otra ubicación de la empresa
- Opción B: Cloud privado (S3 compatible, cifrado AES-256)
- Opción C: Cinta LTO en armario ignífugo externo
Cifrado obligatorio fuera de la red OT
Cualquier copia que salga de la red OT va cifrada. Sin excepciones. Los datos SCADA contienen información sobre tu proceso productivo que un atacante podría usar para planificar un ataque dirigido.
#!/bin/bash
# Cifrar backup antes de enviar a almacenamiento externo
# Usa GPG con clave simétrica (AES-256)
BACKUP_FILE="$1"
PASSPHRASE_FILE="/root/.backup_passphrase" # Archivo con passphrase (permisos 600)
# Cifrar
gpg --batch --yes --passphrase-file "$PASSPHRASE_FILE" \
--symmetric --cipher-algo AES256 \
--output "${BACKUP_FILE}.gpg" \
"$BACKUP_FILE"
# Subir a S3 compatible (MinIO, Wasabi, AWS S3)
aws s3 cp "${BACKUP_FILE}.gpg" \
s3://scada-backup-offsite/$(date +%Y/%m)/ \
--endpoint-url https://s3.backup-externo.com \
--storage-class STANDARD_IA
# Limpiar archivo cifrado local
rm -f "${BACKUP_FILE}.gpg"
Monitorización centralizada
Con múltiples sistemas, revisar logs manualmente en cada servidor no escala. Centraliza el estado de todos los backups en un punto.
Dashboard de estado de backups
Una opción sencilla pero efectiva es generar un JSON de estado que el dashboard de planta pueda consultar:
"""
Generador de estado de backups para dashboard.
Ejecutar después de la verificación diaria.
"""
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
BACKUP_BASE = Path("/backup")
STATUS_FILE = "/var/www/html/api/backup_status.json"
def check_backup(nombre: str, path_pattern: str, max_age_hours: int = 26) -> dict:
"""Verifica un backup y retorna su estado."""
archivos = sorted(BACKUP_BASE.rglob(path_pattern), key=os.path.getmtime, reverse=True)
if not archivos:
return {"nombre": nombre, "status": "MISSING", "ultimo": None, "tamaño": None}
ultimo = archivos[0]
mtime = datetime.fromtimestamp(ultimo.stat().st_mtime)
edad = datetime.now() - mtime
size_mb = ultimo.stat().st_size / (1024 * 1024)
status = "OK" if edad < timedelta(hours=max_age_hours) else "STALE"
return {
"nombre": nombre,
"status": status,
"ultimo": mtime.isoformat(),
"edad_horas": round(edad.total_seconds() / 3600, 1),
"tamaño_mb": round(size_mb, 1),
"archivo": str(ultimo.name)
}
# Verificar todos los backups
estado = {
"timestamp": datetime.now().isoformat(),
"backups": [
check_backup("SCADA Server", "scada/*/data/gateway.xml"),
check_backup("Base de datos", "scada/*/database/scada_db_*.sql.gz"),
check_backup("Historian", "historian/scada_historian_*.tar.gz"),
check_backup("Red OT", "red-ot/*/running-config.txt"),
check_backup("PLC Siemens", "plc/siemens/**/*.ap*"),
check_backup("PLC Rockwell", "plc/rockwell/**/*.ACD"),
check_backup("Firewall", "firewall/fortigate_*.conf"),
]
}
# Resumen
ok = sum(1 for b in estado["backups"] if b["status"] == "OK")
total = len(estado["backups"])
estado["resumen"] = {
"ok": ok,
"total": total,
"porcentaje": round(ok / total * 100) if total > 0 else 0
}
with open(STATUS_FILE, "w") as f:
json.dump(estado, f, indent=2)
print(f"Estado actualizado: {ok}/{total} backups OK")
Los errores que se repiten en cada planta
1. Backup sin verificación
El peor de todos. Empresas que llevan años “haciendo backup” y descubren en el momento de la crisis que los archivos están corruptos. Años de falsa tranquilidad.
2. Backup solo del servidor, no de los PLCs
El servidor SCADA se reinstala en horas. Reprogramar 15 PLCs desde cero lleva semanas. Respalda los PLCs. Versiónalos. No es opcional.
3. Credenciales no documentadas
El backup se restaura, pero nadie recuerda la contraseña del SCADA, la clave API del Historian o el pin del firewall. Documenta todas las credenciales necesarias para la recuperación en un lugar seguro y accesible durante emergencias.
4. Backups accesibles desde la máquina comprometida
Si el ransomware puede llegar a tus backups, los cifrará también. Las copias deben ser inmutables (write-once) o estar en una red que el ransomware no pueda alcanzar. Si tus backups están en un share montado del servidor SCADA, no tienes backup.
5. Nunca probar la restauración
Tener backups sin probar la restauración es como tener un extintor sellado. Cuando lo necesitas, descubres que no funciona.
Empieza hoy
Las herramientas están ahí: rsync para ficheros, Veeam para VMs, scripts para PLCs y red, Octoplant para gestión de versiones. Lo que falta en la mayoría de plantas no es tecnología — es disciplina.
La próxima vez que alguien diga “ya haremos el backup cuando haya tiempo”, pregúntale cuánto cuesta un día entero de parada. Ese número suele acelerar la conversación.
Sigue leyendo
Automatizar Auditorías de Ciberseguridad OT
Aprende a automatizar auditorías de ciberseguridad en entornos OT con Python y n8n. Scripts, workflows y checklist para cumplir NIS2 e IEC 62443.
Leer artículoIEC 62443: Cumplimiento en Ciberseguridad Industrial
Guía práctica de IEC 62443 para ciberseguridad industrial. Estructura, niveles de seguridad, roles y pasos concretos para cumplir la norma en tu planta OT.
Leer artículo