From 1e5bee69429fe74226310a6b5996520e8263792d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Domingo=20Agust=C3=AD?= Date: Tue, 22 Sep 2026 21:41:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(snapper,hmac):=20snapshots=20autom=C3=A1ti?= =?UTF-8?q?cos=20y=20firma=20HMAC=20de=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fase 7: snapshots btrfs automáticos con snapper + snap-pac. - Nuevo módulo scripts/35-snapper.sh. - Configuraciones por perfil: snapshots.enabled y límites de retención. - Habilita timers snapper-timeline y snapper-cleanup. - Fase 8: firma HMAC-SHA256 de configuraciones y métricas. - El portal firma configs cuando NEUBAT_HMAC_SECRET está definido. - El instalador verifica la firma al descargar la config. - El instalador mide duración y la envía a /api/complete. - Nuevo endpoint /api/metrics con estadísticas agregadas. - Añade tests: 35 Jest, 10 bats. - Documentación actualizada en docs/INSTALL.md y .env.example. --- .env.example | 4 ++ configs/base.json | 9 ++++ configs/developer.json | 9 ++++ configs/production.json | 9 ++++ docs/INSTALL.md | 80 ++++++++++++++++++++++++++++- portal/lib/db.js | 39 +++++++++++++- portal/routes/install.js | 19 ++++++- portal/routes/status.js | 30 +++++++++++ portal/tests/lib/db.test.js | 28 ++++++++++ portal/tests/routes/install.test.js | 26 ++++++++++ portal/tests/routes/status.test.js | 16 ++++++ scripts/20-archinstall.sh | 59 +++++++++++++++++++++ scripts/35-snapper.sh | 50 ++++++++++++++++++ scripts/neubat-install.sh | 12 ++++- tests/bash/utils.bats | 12 +++++ 15 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 scripts/35-snapper.sh diff --git a/.env.example b/.env.example index b1f8473..f64937d 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,9 @@ NEUBAT_PORT=3000 # Mirror base para el netboot iPXE (puede apuntar a una caché local HTTP) NEUBAT_MIRROR_BASE=https://geo.mirror.pkgbuild.com/iso/latest +# Secreto compartido para firmar/verificar configuraciones con HMAC-SHA256. +# Debe coincidir con el valor usado por el instalador (NEUBAT_HMAC_SECRET). +NEUBAT_HMAC_SECRET=cambia-este-secreto-por-una-cadena-larga-y-aleatoria + # Puerto de la caché opcional de paquetes pacman (docker compose --profile cache up -d) NEUBAT_CACHE_PORT=8090 diff --git a/configs/base.json b/configs/base.json index 0db7e67..8f1b88b 100644 --- a/configs/base.json +++ b/configs/base.json @@ -27,5 +27,14 @@ "passphrase": "", "cipher": "aes-xts-plain64", "key_size": 512 + }, + "snapshots": { + "enabled": false, + "cleanup": { + "hourly": 5, + "daily": 7, + "weekly": 2, + "monthly": 2 + } } } diff --git a/configs/developer.json b/configs/developer.json index d7fbcf3..487ca43 100644 --- a/configs/developer.json +++ b/configs/developer.json @@ -44,5 +44,14 @@ "passphrase": "", "cipher": "aes-xts-plain64", "key_size": 512 + }, + "snapshots": { + "enabled": false, + "cleanup": { + "hourly": 5, + "daily": 7, + "weekly": 2, + "monthly": 2 + } } } diff --git a/configs/production.json b/configs/production.json index 0c65242..e49beae 100644 --- a/configs/production.json +++ b/configs/production.json @@ -55,5 +55,14 @@ "cipher": "aes-xts-plain64", "key_size": 512, "_warning": "El método 'keyfile' almacena la llave en /boot para arranque desatendido. Para mayor seguridad física cambia a 'passphrase' tras la instalación o usa TPM2." + }, + "snapshots": { + "enabled": true, + "cleanup": { + "hourly": 5, + "daily": 7, + "weekly": 2, + "monthly": 2 + } } } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c8f65f8..d2e0cea 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -49,6 +49,7 @@ Variables de entorno útiles: | `ADMIN_TOKEN` | Token para el panel `/admin` | — (panel deshabilitado si falta) | | `NEUBAT_MIRROR_BASE` | Mirror base para el netboot iPXE | `https://geo.mirror.pkgbuild.com/iso/latest` | | `NEUBAT_PORT` | Puerto expuesto del portal | `3000` | +| `NEUBAT_HMAC_SECRET` | Secreto compartido para firma HMAC de configuraciones | — | ### Node.js nativo @@ -67,7 +68,8 @@ Como servicio systemd, usar como plantilla la unidad que genera `scripts/40-port |--------|------|-------------| | POST | `/api/install` | Crea instalación; body: `profile`, `hostname?`, `username?`, `password?`, `desktop?`, `packages?[]`, `encryption?` | | GET | `/api/config/:token` | Devuelve el JSON de configuración (consumido por el instalador) | -| POST | `/api/complete` | El instalador notifica `status`, `hostname`, `error?` | +| POST | `/api/complete` | El instalador notifica `status`, `hostname`, `duration?`, `error?` | +| GET | `/api/metrics` | Métricas agregadas de instalaciones | | GET | `/api/installations` | Últimas 50 instalaciones | | GET | `/api/installations/:token` | Estado de una instalación | | GET | `/api/health` | Health check | @@ -219,6 +221,82 @@ sudo rm /boot/luks-keyfile Para TPM2 o FIDO2, consulta `systemd-cryptenroll` (fuera del alcance del MVP). +## 6.2 Snapshots btrfs automáticos (Fase 7) + +Cuando el perfil activa `snapshots.enabled`, NEUBAT instala `snapper` y `snap-pac` y configura snapshots automáticos de `/` y `/home`: + +- **Timeline:** snapshot cada hora (gestionado por `snapper-timeline.timer`). +- **Pacman:** `snap-pac` crea snapshots `pre`/`post` en cada operación de paquetes, permitiendo rollback si una actualización rompe el sistema. +- **Limpieza:** `snapper-cleanup.timer` aplica los límites configurados. + +### Configuración en el perfil + +```json +{ + "snapshots": { + "enabled": true, + "cleanup": { + "hourly": 5, + "daily": 7, + "weekly": 2, + "monthly": 2 + } + } +} +``` + +### Gestión básica + +```bash +# Listar snapshots de raíz +sudo snapper -c root list + +# Ver diferencias entre dos snapshots +sudo snapper -c root status .. + +# Restaurar un snapshot (boot desde snapshot + rollback) +sudo snapper -c root rollback +``` + +## 6.3 Firma HMAC y métricas de instalación (Fase 8) + +El portal puede firmar cada configuración con **HMAC-SHA256** para que el instalador verifique que no ha sido alterada en tránsito. + +### Configuración + +Establece el mismo secreto en el portal y en el entorno live del instalador: + +```bash +# .env del portal (o docker compose) +NEUBAT_HMAC_SECRET=una-cadena-larga-y-aleatoria + +# Entorno live del instalador +export NEUBAT_HMAC_SECRET="una-cadena-larga-y-aleatoria" +``` + +Si el secreto está configurado, el portal añade un campo `signature` al JSON de configuración. El instalador lo verifica automáticamente en `fetch_configuration()` y aborta si la firma no coincide. + +### Métricas + +El instalador mide su duración en segundos y la envía al portal en `/api/complete`: + +```bash +curl http://:3000/api/metrics +``` + +Respuesta: + +```json +{ + "total": 10, + "completed": 8, + "failed": 1, + "pending": 1, + "avg_duration_seconds": 420, + "duration_count": 8 +} +``` + ## 7. Perfiles de configuración Los perfiles viven en `configs/` (`base`, `production`, `developer`). Claves: diff --git a/portal/lib/db.js b/portal/lib/db.js index 0c77f29..e41ed72 100644 --- a/portal/lib/db.js +++ b/portal/lib/db.js @@ -54,6 +54,40 @@ function configPathFor(token) { return path.join(CONFIG_DIR, `${token}.json`); } +// Carga el secreto HMAC desde el entorno. Si no está definido, la firma +// queda deshabilitada (modo desarrollo o despliegues sin verificación). +function hmacSecret() { + return process.env.NEUBAT_HMAC_SECRET || ''; +} + +// Payload determinista usado para la firma. Debe coincidir exactamente con +// la reconstrucción que hace el instalador en scripts/20-archinstall.sh. +function signingPayload(config) { + const parts = [ + String(config.token || ''), + String(config.machine_id || ''), + String(config.hostname || ''), + String(config.username || ''), + String(config.desktop || ''), + String(config.password || ''), + String(config.disk || ''), + String(config.timezone || ''), + String(config.locale || ''), + String(config.keyboard || ''), + ...(Array.isArray(config.packages) ? config.packages.sort() : []), + ...(Array.isArray(config.services) ? config.services.sort() : []) + ]; + return parts.join('|'); +} + +function signConfig(config) { + const secret = hmacSecret(); + if (!secret) return null; + return crypto.createHmac('sha256', secret) + .update(signingPayload(config)) + .digest('hex'); +} + module.exports = { PORTAL_ROOT, CONFIG_DIR, @@ -64,5 +98,8 @@ module.exports = { readDB, writeDB, loadProfile, - configPathFor + configPathFor, + hmacSecret, + signingPayload, + signConfig }; diff --git a/portal/routes/install.js b/portal/routes/install.js index 0d750fb..04cf465 100644 --- a/portal/routes/install.js +++ b/portal/routes/install.js @@ -25,7 +25,8 @@ router.post('/install', async (req, res) => { password, desktop, packages = [], - encryption + encryption, + snapshots } = req.body; const token = db.generateToken(); @@ -51,6 +52,12 @@ router.post('/install', async (req, res) => { status: 'pending' }; + // Firma HMAC de la configuración (solo si el portal tiene secreto) + const signature = db.signConfig(config); + if (signature) { + config.signature = signature; + } + if (encryption && typeof encryption === 'object') { config.encryption = { ...(baseProfile.encryption || {}), @@ -58,6 +65,13 @@ router.post('/install', async (req, res) => { }; } + if (snapshots && typeof snapshots === 'object') { + config.snapshots = { + ...(baseProfile.snapshots || {}), + ...snapshots + }; + } + const configPath = db.configPathFor(token); await fs.writeFile(configPath, JSON.stringify(config, null, 2)); @@ -110,7 +124,7 @@ router.get('/config/:token', async (req, res) => { // POST /api/complete — el instalador notifica el resultado router.post('/complete', async (req, res) => { try { - const { token, status, hostname, error } = req.body; + const { token, status, hostname, duration, error } = req.body; const store = await db.readDB(); const install = store.installations.find(i => i.token === token); @@ -119,6 +133,7 @@ router.post('/complete', async (req, res) => { install.status = status || 'completed'; install.completed_at = new Date().toISOString(); if (hostname) install.hostname = hostname; + if (typeof duration === 'number') install.duration = duration; if (error) install.error = error; await db.writeDB(store); diff --git a/portal/routes/status.js b/portal/routes/status.js index 586fbb1..39d3c3a 100644 --- a/portal/routes/status.js +++ b/portal/routes/status.js @@ -35,4 +35,34 @@ router.get('/installations/:token', async (req, res) => { } }); +// GET /api/metrics — métricas agregadas de instalaciones +router.get('/metrics', async (req, res) => { + try { + const store = await db.readDB(); + const installs = store.installations || []; + const total = installs.length; + const completed = installs.filter(i => i.status === 'completed').length; + const failed = installs.filter(i => i.status === 'failed').length; + const pending = installs.filter(i => i.status === 'pending' || i.status === 'downloaded').length; + const durations = installs + .filter(i => typeof i.duration === 'number' && i.duration > 0) + .map(i => i.duration); + + const avgDuration = durations.length + ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) + : 0; + + res.json({ + total, + completed, + failed, + pending, + avg_duration_seconds: avgDuration, + duration_count: durations.length + }); + } catch { + res.status(500).json({ error: 'Error interno' }); + } +}); + module.exports = router; diff --git a/portal/tests/lib/db.test.js b/portal/tests/lib/db.test.js index 9910f2b..2915061 100644 --- a/portal/tests/lib/db.test.js +++ b/portal/tests/lib/db.test.js @@ -41,4 +41,32 @@ describe('lib/db', () => { test('loadProfile falla con perfil inexistente', async () => { await expect(db.loadProfile('noexiste')).rejects.toThrow(); }); + + test('signConfig devuelve null sin secreto', () => { + delete process.env.NEUBAT_HMAC_SECRET; + const sig = db.signConfig({ token: 'a', hostname: 'h' }); + expect(sig).toBeNull(); + }); + + test('signConfig produce firma HMAC determinista', () => { + process.env.NEUBAT_HMAC_SECRET = 'test-secret'; + const config = { + token: 'tok', + machine_id: 'mid', + hostname: 'host', + username: 'user', + desktop: 'none', + password: 'pass', + disk: '/dev/sda', + timezone: 'UTC', + locale: 'en_US.UTF-8', + keyboard: 'us', + packages: ['a', 'b'], + services: ['sshd'] + }; + const sig1 = db.signConfig(config); + const sig2 = db.signConfig(config); + expect(sig1).toMatch(/^[0-9a-f]{64}$/); + expect(sig1).toBe(sig2); + }); }); diff --git a/portal/tests/routes/install.test.js b/portal/tests/routes/install.test.js index 580a94c..6d9d800 100644 --- a/portal/tests/routes/install.test.js +++ b/portal/tests/routes/install.test.js @@ -113,6 +113,32 @@ describe('routes/install', () => { expect(res.body.password).toBe('custom-password'); }); + test('POST /api/install acepta opciones de snapshots', async () => { + const create = await request(app) + .post('/api/install') + .send({ + profile: 'base', + snapshots: { enabled: true, cleanup: { hourly: 10 } } + }) + .expect(200); + + const res = await request(app).get(create.body.config_url).expect(200); + expect(res.body.snapshots.enabled).toBe(true); + expect(res.body.snapshots.cleanup.hourly).toBe(10); + }); + + test('POST /api/install firma la configuración cuando hay HMAC_SECRET', async () => { + process.env.NEUBAT_HMAC_SECRET = 'test-secret'; + const create = await request(app) + .post('/api/install') + .send({ profile: 'base', hostname: 'signed' }) + .expect(200); + + const res = await request(app).get(create.body.config_url).expect(200); + expect(res.body.signature).toMatch(/^[0-9a-f]{64}$/); + delete process.env.NEUBAT_HMAC_SECRET; + }); + test('GET /boot/:token inválido devuelve 404', async () => { await request(app).get('/boot/00000000000000000000000000000000').expect(404); }); diff --git a/portal/tests/routes/status.test.js b/portal/tests/routes/status.test.js index 3c2a047..2424f97 100644 --- a/portal/tests/routes/status.test.js +++ b/portal/tests/routes/status.test.js @@ -42,4 +42,20 @@ describe('routes/status', () => { .get('/api/installations/00000000000000000000000000000000') .expect(404); }); + + test('GET /api/metrics devuelve métricas agregadas', async () => { + const create = await request(app) + .post('/api/install') + .send({ profile: 'base' }); + + await request(app) + .post('/api/complete') + .send({ token: create.body.token, status: 'completed', duration: 120 }) + .expect(200); + + const res = await request(app).get('/api/metrics').expect(200); + expect(res.body.total).toBeGreaterThanOrEqual(1); + expect(res.body.completed).toBeGreaterThanOrEqual(1); + expect(res.body.avg_duration_seconds).toBe(120); + }); }); diff --git a/scripts/20-archinstall.sh b/scripts/20-archinstall.sh index e839dd4..81351a8 100755 --- a/scripts/20-archinstall.sh +++ b/scripts/20-archinstall.sh @@ -55,6 +55,17 @@ fetch_configuration() { # shellcheck disable=SC2034 LUKS_KEY_SIZE=$(cfg_get_nested "${NEUBAT_CONFIG_FILE}" encryption/key_size "512") + # shellcheck disable=SC2034 + SNAPSHOTS_ENABLED=$(cfg_get_nested "${NEUBAT_CONFIG_FILE}" snapshots/enabled "false") + # shellcheck disable=SC2034 + SNAP_KEEP_HOURLY=$(cfg_get_nested "${NEUBAT_CONFIG_FILE}" snapshots/cleanup/hourly "5") + # shellcheck disable=SC2034 + SNAP_KEEP_DAILY=$(cfg_get_nested "${NEUBAT_CONFIG_FILE}" snapshots/cleanup/daily "7") + # shellcheck disable=SC2034 + SNAP_KEEP_WEEKLY=$(cfg_get_nested "${NEUBAT_CONFIG_FILE}" snapshots/cleanup/weekly "2") + # shellcheck disable=SC2034 + SNAP_KEEP_MONTHLY=$(cfg_get_nested "${NEUBAT_CONFIG_FILE}" snapshots/cleanup/monthly "2") + # shellcheck disable=SC2034 LUKS_KEYFILE="" if [[ "${ENCRYPTION_ENABLED}" == "true" && "${ENCRYPTION_METHOD}" == "keyfile" ]]; then @@ -64,6 +75,10 @@ fetch_configuration() { chmod 0400 "${LUKS_KEYFILE}" fi + # Verificar firma HMAC de la configuración si el instalador tiene secreto. + # Si la config viene de un perfil local (sin portal) y no hay firma, se omite. + verify_config_signature "${NEUBAT_CONFIG_FILE}" + if [[ "${PASSWORD}" == "neubat" ]]; then warning "Contraseña por defecto en uso. Cámbiala en el primer acceso." fi @@ -71,6 +86,50 @@ fetch_configuration() { success "Configuración cargada: ${HOSTNAME} @ ${DISK} (perfil ${NEUBAT_PROFILE})" } +# Verifica la firma HMAC-SHA256 de un archivo JSON descargado. +# Si NEUBAT_HMAC_SECRET está vacío, la verificación se omite. +# Si el archivo no contiene signature pero hay secreto, se omite con advertencia +# (útil para perfiles locales sin portal). +verify_config_signature() { + local config_file="$1" + local secret="${NEUBAT_HMAC_SECRET:-}" + + [[ -z "${secret}" ]] && return 0 + + if ! python3 - "${config_file}" "${secret}" <<'PYEOF' +import json, hmac, hashlib, sys +with open(sys.argv[1]) as f: + cfg = json.load(f) +secret = sys.argv[2].encode() +sig = cfg.pop('signature', None) +if sig is None: + sys.exit(2) +parts = [ + str(cfg.get('token', '')), + str(cfg.get('machine_id', '')), + str(cfg.get('hostname', '')), + str(cfg.get('username', '')), + str(cfg.get('desktop', '')), + str(cfg.get('password', '')), + str(cfg.get('disk', '')), + str(cfg.get('timezone', '')), + str(cfg.get('locale', '')), + str(cfg.get('keyboard', '')), + *(sorted(cfg.get('packages', [])) if isinstance(cfg.get('packages'), list) else []), + *(sorted(cfg.get('services', [])) if isinstance(cfg.get('services'), list) else []) +] +payload = '|'.join(parts).encode() +expected = hmac.new(secret, payload, hashlib.sha256).hexdigest() +sys.exit(0 if hmac.compare_digest(sig, expected) else 1) +PYEOF + then + case $? in + 1) error "Firma HMAC de la configuración inválida. Posible manipulación en tránsito." ;; + 2) warning "Configuración sin firma HMAC; se omite la verificación" ;; + esac + fi +} + install_base_system() { log "Instalando sistema base Arch Linux..." diff --git a/scripts/35-snapper.sh b/scripts/35-snapper.sh new file mode 100644 index 0000000..80cb72f --- /dev/null +++ b/scripts/35-snapper.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# ============================================================================= +# NEUBAT - Fase 7: snapshots automáticos de btrfs con snapper +# Módulo cargado por neubat-install.sh (no ejecutar directamente) +# ============================================================================= + +# Configura snapper en / y /home cuando snapshots.enabled es true. +# Se ejecuta dentro del entorno instalado (arch-chroot /mnt). +configure_snapper() { + [[ "${SNAPSHOTS_ENABLED:-false}" != "true" ]] && return 0 + + log "Configurando snapshots btrfs automáticos (snapper)..." + + # snap-pac añade hooks de pacman para snapshots pre/post transacción + arch-chroot /mnt pacman -S --noconfirm --needed snapper snap-pac \ + || warning "No se pudieron instalar snapper/snap-pac" + + # Crear configuraciones de snapper. Si fallan (p.ej. FS no btrfs), advertir + # pero no abortar: la instalación sigue usable sin snapshots. + arch-chroot /mnt snapper -c root create-config / \ + || warning "No se pudo crear configuración snapper para /" + arch-chroot /mnt snapper -c home create-config /home \ + || warning "No se pudo crear configuración snapper para /home" + + # Aplicar límites de retención desde el perfil + local root_cfg="/mnt/etc/snapper/configs/root" + local home_cfg="/mnt/etc/snapper/configs/home" + + if [[ -f "${root_cfg}" ]]; then + sed -i "s/^TIMELINE_LIMIT_HOURLY=.*/TIMELINE_LIMIT_HOURLY=\"${SNAP_KEEP_HOURLY}\"/" "${root_cfg}" + sed -i "s/^TIMELINE_LIMIT_DAILY=.*/TIMELINE_LIMIT_DAILY=\"${SNAP_KEEP_DAILY}\"/" "${root_cfg}" + sed -i "s/^TIMELINE_LIMIT_WEEKLY=.*/TIMELINE_LIMIT_WEEKLY=\"${SNAP_KEEP_WEEKLY}\"/" "${root_cfg}" + sed -i "s/^TIMELINE_LIMIT_MONTHLY=.*/TIMELINE_LIMIT_MONTHLY=\"${SNAP_KEEP_MONTHLY}\"/" "${root_cfg}" + fi + + if [[ -f "${home_cfg}" ]]; then + sed -i "s/^TIMELINE_LIMIT_HOURLY=.*/TIMELINE_LIMIT_HOURLY=\"${SNAP_KEEP_HOURLY}\"/" "${home_cfg}" + sed -i "s/^TIMELINE_LIMIT_DAILY=.*/TIMELINE_LIMIT_DAILY=\"${SNAP_KEEP_DAILY}\"/" "${home_cfg}" + sed -i "s/^TIMELINE_LIMIT_WEEKLY=.*/TIMELINE_LIMIT_WEEKLY=\"${SNAP_KEEP_WEEKLY}\"/" "${home_cfg}" + sed -i "s/^TIMELINE_LIMIT_MONTHLY=.*/TIMELINE_LIMIT_MONTHLY=\"${SNAP_KEEP_MONTHLY}\"/" "${home_cfg}" + fi + + # Timers: timeline (crear snapshots) y cleanup (borrar antiguos) + arch-chroot /mnt systemctl enable snapper-timeline.timer \ + || warning "No se pudo habilitar snapper-timeline.timer" + arch-chroot /mnt systemctl enable snapper-cleanup.timer \ + || warning "No se pudo habilitar snapper-cleanup.timer" + + success "Snapshots configurados" +} diff --git a/scripts/neubat-install.sh b/scripts/neubat-install.sh index a58caff..e7818bd 100755 --- a/scripts/neubat-install.sh +++ b/scripts/neubat-install.sh @@ -60,6 +60,7 @@ source "${NEUBAT_ROOT}/scripts/00-preinstall.sh" # check_requirements source "${NEUBAT_ROOT}/scripts/10-partition.sh" # partition_disk source "${NEUBAT_ROOT}/scripts/20-archinstall.sh" # fetch_configuration, install_base_system source "${NEUBAT_ROOT}/scripts/30-postinstall.sh" # configure_system, install_applications +source "${NEUBAT_ROOT}/scripts/35-snapper.sh" # configure_snapper source "${NEUBAT_ROOT}/scripts/40-portal-deploy.sh" # deploy_local_portal source "${NEUBAT_ROOT}/scripts/50-firstboot-ansible.sh" # prepare_ansible_firstboot @@ -70,13 +71,19 @@ source "${NEUBAT_ROOT}/scripts/50-firstboot-ansible.sh" # prepare_ansible_first finalize_installation() { log "Finalizando instalación..." + # Calcular duración de la instalación para métricas + local duration=0 + if [[ -n "${NEUBAT_START_TIME:-}" ]]; then + duration=$(($(date +%s) - NEUBAT_START_TIME)) + fi + # Limpiar archivos temporales rm -f /mnt/root/neubat-config.json # Notificar al portal central (si existe conectividad) curl -sf -X POST "${NEUBAT_PORTAL_URL}/api/complete" \ -H "Content-Type: application/json" \ - -d "{\"token\":\"${NEUBAT_TOKEN}\",\"status\":\"completed\",\"hostname\":\"${HOSTNAME}\"}" \ + -d "{\"token\":\"${NEUBAT_TOKEN}\",\"status\":\"completed\",\"hostname\":\"${HOSTNAME}\",\"duration\":${duration}}" \ || warning "No se pudo notificar al portal central" echo "" @@ -111,12 +118,15 @@ main() { echo "═══════════════════════════════════════════════════════════════" echo "" + NEUBAT_START_TIME=$(date +%s) + check_requirements fetch_configuration partition_disk install_base_system configure_system install_applications + configure_snapper deploy_local_portal prepare_ansible_firstboot finalize_installation diff --git a/tests/bash/utils.bats b/tests/bash/utils.bats index af569c7..86e8905 100644 --- a/tests/bash/utils.bats +++ b/tests/bash/utils.bats @@ -21,6 +21,13 @@ setup() { "enabled": true, "method": "keyfile", "passphrase": "secret" + }, + "snapshots": { + "enabled": false, + "cleanup": { + "hourly": 5, + "daily": 7 + } } } JSON @@ -75,3 +82,8 @@ teardown() { [ "$(cfg_get_nested "${TMP_CONFIG}" encryption/nonexistent "fallback")" = "fallback" ] [ "$(cfg_get_nested "${TMP_CONFIG}" no/such/path "fallback")" = "fallback" ] } + +@test "cfg_get_nested lee rutas de tres niveles" { + [ "$(cfg_get_nested "${TMP_CONFIG}" snapshots/cleanup/hourly "0")" = "5" ] + [ "$(cfg_get_nested "${TMP_CONFIG}" snapshots.cleanup.daily "0")" = "7" ] +}