Aplicado el 7 de octubre de 2026. Este documento sirve tanto de registro de lo que se hizo como de procedimiento para la próxima vez.
El problema
El fichero config.json.bak estaba versionado en git desde el primer commit del repositorio INFRA/ansible, y contenía las cuatro claves que Semaphore usaba en ese momento, todas vivas:
| Clave | Qué protege |
|---|---|
postgres.pass | la base de datos de Semaphore |
cookie_encryption | cifra las cookies de sesión |
cookie_hash | firma las cookies de sesión |
access_key_encryption | cifra el Key Store entero |
La última es la grave. Con ella, más un volcado de la base —cuya contraseña estaba en el mismo fichero—, se descifran las 51 credenciales del Key Store: las llaves SSH de los 96 servidores gestionados, las contraseñas de ansible-vault y las de los dos vCenter.
El fichero estaba en Bitbucket y en todos los clones del repositorio.
Por qué NO se truncó el historial de git
Fue la primera idea y se descartó, por una razón concreta:
Un secreto publicado no se despublica.
- Quien ya había clonado el repositorio tiene las cuatro claves en su
.git. Unforce pushno le toca nada. - Bitbucket conserva un tiempo los commits sin referencia, y siguen siendo accesibles por su SHA. No desaparecen porque se reescriba
master. - Truncar costaba todo el historial del repositorio.
Es decir: se pagaba un precio alto y las claves seguían sirviendo. La pregunta no era cómo borrarlas del repositorio, sino cómo hacer que dejaran de servir. Lo único que lo consigue es rotarlas. Una vez rotadas, lo que quede en el historial ya no abre nada.
Lo que se temía, y por qué no pasó
La preocupación era access_key_encryption: si se cambia, Semaphore ya no puede descifrar el Key Store, y parecía que habría que volver a meter las 51 credenciales a mano.
No hizo falta. Semaphore sabe recifrarlo:
semaphore vaults rekey --old-key <clave-vieja>
Re-encrypt Key Store in database with using current encryption key
Se cambia la clave en config.json, se corre eso, y recifra las 51 sin tocar ninguna.
El procedimiento
El script completo está en /home/cgi/rotar_claves_semaphore.sh. Se corre como root:
su -c 'bash /home/cgi/rotar_claves_semaphore.sh'
Lo que hace, en orden:
| # | Paso | Detalle |
|---|---|---|
| 1 | Respaldos | config.json y un volcado de la base, fuera del repositorio, en /home/cgi/respaldos/. Verifica que el volcado trae la tabla access_key antes de seguir |
| 2 | Genera 4 claves nuevas | openssl rand -base64 32 para las tres de 44 caracteres |
| 3 | Para Semaphore | Si sigue corriendo mientras se recifra, él tiene la clave vieja en memoria y la base pasa a la nueva: cualquier tarea en esa ventana falla |
| 4 | Contraseña de PostgreSQL | ALTER USER + actualiza /home/cgi/.pgpass_semaphore |
| 5 | Reescribe config.json | Las cuatro claves. Comprueba después que la base acepta la nueva |
| 6 | Recifra el Key Store | semaphore vaults rekey --old-key. Si falla, aborta y dice qué restaurar |
| 7 | Levanta Semaphore | Y verifica que quedó activo |
| 8 | Comprueba | Que siguen siendo 51 credenciales |
Impacto
- Semaphore parado unos minutos.
- 🔴 Todos tienen que volver a entrar a la interfaz: las cookies cambian.
- Las credenciales del Key Store siguen funcionando.
🔴 La comprobación que de verdad valida el cambio
Que el script termine bien no basta. Un rekey a medias no se ve al arrancar.
Hay que correr una tarea real que use una llave SSH del Key Store. Si alguna quedó mal recifrada, se ve ahí: la tarea no puede conectarse al servidor.
El 2026-10-07 se validó con la plantilla PB | PROD | ADMISIÓN | DEBIAN | USOK | N: PROD-ADMISION-N01, que usa la llave 43: ok=32 changed=0 failed=0 unreachable=0.
🔑 Cómo consultar las claves actuales
# Contraseña de PostgreSQL
cut -d: -f5 /home/cgi/.pgpass_semaphore
# Las tres de config.json
python3 -c "import json; c=json.load(open('/home/cgi/semaphore/config.json')); \
[print('%-24s %s' % (k, c[k])) for k in ('cookie_encryption','cookie_hash','access_key_encryption')]"
# Datos de conexión a la base, sin la contraseña
python3 -c "import json; pg=json.load(open('/home/cgi/semaphore/config.json'))['postgres']; \
print('host:', pg['host'], ' base:', pg['name'], ' usuario:', pg['user'])"
⚠️ access_key_encryption es la crítica. Si se pierde, el Key Store queda ilegible y hay que volver a meter las 51 credenciales a mano. Guárdela donde no se pierda —TeamPass o equivalente— y nunca en el repositorio.
Comprobar que una clave antigua ya no sirve
python3 -c "
import json, hashlib
h = lambda s: hashlib.sha256(s.encode()).hexdigest()[:16]
c = json.load(open('/home/cgi/semaphore/config.json'))
vieja = input('clave antigua a comprobar: ')
print('SIGUE VIVA' if any(h(vieja)==h(v) for v in
[c['postgres']['pass'], c['cookie_encryption'], c['cookie_hash'], c['access_key_encryption']])
else 'ya no sirve')"
Vuelta atrás
Los respaldos quedan en /home/cgi/respaldos/ con la fecha en el nombre y permisos 600.
systemctl stop semaphore
cp /home/cgi/respaldos/config.json.pre-rotacion-<fecha> /home/cgi/semaphore/config.json
zcat /home/cgi/respaldos/semaphore_db_pre-rotacion-<fecha>.sql.gz \
| PGPASSFILE=/home/cgi/.pgpass_semaphore psql -h 127.0.0.1 -U semaphore_user -d semaphore_db
systemctl start semaphore
Después de rotar
- Los dos
config.json.bak*salieron del repositorio. El.gitignoreya bloqueaconfig.json.bak*, para que no vuelvan a entrar. - Quedan copias de consulta en
/home/cgi/respaldos/config-historicos/, con permisos600.
Un fallo del script, por si reaparece
El primer intento abortó con «el volcado no trae access_key» aunque el volcado sí lo traía. La causa:
set -o pipefail
zcat fichero_grande.gz | grep -q PATRON # devuelve 141 CUANDO EL PATRÓN SÍ ESTÁ
grep -q sale en cuanto encuentra la primera coincidencia y cierra la tubería. zcat, que iba por el 1% del fichero, recibe SIGPIPE y muere con 141. Con pipefail, ese 141 es el código del pipeline.
Con ficheros pequeños no se reproduce —grep los consume enteros antes de salir—, así que un ensayo con pocas líneas lo da por bueno. La forma correcta:
n=$(zcat fichero_grande.gz | grep -c PATRON || true)
[ "${n:-0}" -ge 1 ] || { echo "no está"; exit 2; }
El mismo problema afecta a cualquier | head, | grep -q o | first-match sobre algo grande con pipefail activo.