Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HMAC secret unused in Docker

Medium Severity

NEUBAT_HMAC_SECRET is documented for Docker Compose via .env, but docker-compose.yml never injects it into the portal container (unlike ADMIN_TOKEN). The portal therefore never signs configs in the recommended deploy path, even when the secret is set.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e5bee6. Configure here.


# Puerto de la caché opcional de paquetes pacman (docker compose --profile cache up -d)
NEUBAT_CACHE_PORT=8090
9 changes: 9 additions & 0 deletions configs/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,14 @@
"passphrase": "",
"cipher": "aes-xts-plain64",
"key_size": 512
},
"snapshots": {
"enabled": false,
"cleanup": {
"hourly": 5,
"daily": 7,
"weekly": 2,
"monthly": 2
}
}
}
9 changes: 9 additions & 0 deletions configs/developer.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,14 @@
"passphrase": "",
"cipher": "aes-xts-plain64",
"key_size": 512
},
"snapshots": {
"enabled": false,
"cleanup": {
"hourly": 5,
"daily": 7,
"weekly": 2,
"monthly": 2
}
}
}
9 changes: 9 additions & 0 deletions configs/production.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
80 changes: 79 additions & 1 deletion docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down Expand Up @@ -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 <id>..<id>

# Restaurar un snapshot (boot desde snapshot + rollback)
sudo snapper -c root rollback <id>
```

## 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://<portal>: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:
Expand Down
39 changes: 38 additions & 1 deletion portal/lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('|');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HMAC omits critical config fields

Medium Severity

The HMAC payload skips encryption and snapshots, and signConfig runs before those request overrides are merged. A transit attacker can disable LUKS or change snapshot policy without invalidating signature, which contradicts the claim that the downloaded config is integrity-protected.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e5bee6. Configure here.

}

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,
Expand All @@ -64,5 +98,8 @@ module.exports = {
readDB,
writeDB,
loadProfile,
configPathFor
configPathFor,
hmacSecret,
signingPayload,
signConfig
};
19 changes: 17 additions & 2 deletions portal/routes/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ router.post('/install', async (req, res) => {
password,
desktop,
packages = [],
encryption
encryption,
snapshots
} = req.body;

const token = db.generateToken();
Expand All @@ -51,13 +52,26 @@ 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 || {}),
...encryption
};
}

if (snapshots && typeof snapshots === 'object') {
config.snapshots = {
...(baseProfile.snapshots || {}),
...snapshots
};
}

const configPath = db.configPathFor(token);
await fs.writeFile(configPath, JSON.stringify(config, null, 2));

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions portal/routes/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
28 changes: 28 additions & 0 deletions portal/tests/lib/db.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
26 changes: 26 additions & 0 deletions portal/tests/routes/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
16 changes: 16 additions & 0 deletions portal/tests/routes/status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading