Instalación creada
@@ -243,43 +224,13 @@ export function HomePage() {
1. Crea una instalación.
2. Configura iPXE para hacer chain a la URL de arranque.
3. La máquina descargará el perfil e instalará Arch automáticamente.
-
-
-
-
-
-
-
- Instalaciones recientes
-
-
-
- {loading ? (
-
-
-
-
-
- ) : installations.length === 0 ? (
- Sin instalaciones registradas.
- ) : (
-
- {installations.slice(0, 8).map((i) => (
-
-
-
{i.token.slice(0, 8)}…
-
{i.profile}
-
-
- {i.status}
-
-
- ))}
-
- )}
+
+ Las instalaciones registradas solo son visibles en el{' '}
+
+ panel de administración
+
+ .
+
@@ -293,7 +244,14 @@ function CopyField({ label, value, onCopy }: { label: string; value: string; onC
{label}
{value}
- onCopy(value)}>
+ onCopy(value)}
+ aria-label={`Copiar ${label}`}
+ >
diff --git a/portal/frontend/src/pages/LandingPage.test.tsx b/portal/frontend/src/pages/LandingPage.test.tsx
new file mode 100644
index 0000000..0c21591
--- /dev/null
+++ b/portal/frontend/src/pages/LandingPage.test.tsx
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import { LandingPage } from './LandingPage';
+import { AuthProvider } from '@/lib/auth';
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+describe('LandingPage', () => {
+ beforeEach(() => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ json: async () => ({}),
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('presenta el producto y enlaces principales', () => {
+ render(
, { wrapper: Wrapper });
+ expect(screen.getByRole('heading', { name: /NEUBAT: tu Arch/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Configurar instalación/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Descargar ISO/i })).toBeInTheDocument();
+ });
+});
diff --git a/portal/frontend/src/pages/LandingPage.tsx b/portal/frontend/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..30a841c
--- /dev/null
+++ b/portal/frontend/src/pages/LandingPage.tsx
@@ -0,0 +1,98 @@
+import { Link } from 'react-router-dom';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Cable, Download, HardDrive, ShieldCheck, Sparkles } from 'lucide-react';
+
+export function LandingPage() {
+ return (
+
+
+ Arch Linux personalizado
+
+ NEUBAT: tu Arch, tu ISO, tu red
+
+
+ Configura desde el navegador una instalación desatendida de Arch Linux, descarga la ISO con
+ verificación automática del hash o arranca solo con un cable Ethernet.
+
+
+
+ Configurar instalación
+
+
+ Descargar ISO
+
+
+ Mi cuenta
+
+
+
+
+
+
+ Características
+
+ }
+ title="Configurador web"
+ text="Paquetes, escritorio o compositor (KDE, GNOME, Hyprland, Sway…), locale y cifrado desde el navegador."
+ />
+ }
+ title="Netinstall por URL"
+ text="Copia el enlace iPXE, conecta Ethernet y la máquina arranca e instala sin USB."
+ />
+ }
+ title="Absorbe tu sistema"
+ text="Un agente local inventaría paquetes y dotfiles allowlist y los guarda en tu perfil NEUBAT."
+ />
+ }
+ title="ISO con hash automático"
+ text="Descarga desde el repositorio oficial; el navegador comprueba SHA-256 antes de guardar el fichero."
+ />
+ }
+ title="Cuenta y recomendaciones"
+ text="Guarda configuraciones, confirma copias del sistema y aplica perfiles curados por el equipo."
+ />
+
+
+
+
+ Cómo funciona
+
+
+ Crea una cuenta y elige un perfil recomendado o absorbe tu Arch actual.
+ Personaliza paquetes y escritorio en el configurador.
+ Arranca por iPXE con la URL generada, o descarga la ISO verificada.
+ archinstall instalará la base; los scripts NEUBAT completan LUKS, snapper y el portal local.
+
+
+
+ );
+}
+
+function Feature({
+ icon,
+ title,
+ text,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ text: string;
+}) {
+ return (
+
+
+
+ {icon}
+ {title}
+
+ {text}
+
+
+
+ );
+}
diff --git a/portal/frontend/src/types.ts b/portal/frontend/src/types.ts
index 0590201..d2860cf 100644
--- a/portal/frontend/src/types.ts
+++ b/portal/frontend/src/types.ts
@@ -8,6 +8,7 @@ export interface Installation {
completed_at?: string;
hostname?: string;
error?: string;
+ user_id?: string | null;
}
export interface InstallRequest {
@@ -16,9 +17,13 @@ export interface InstallRequest {
username?: string;
desktop?: string;
packages?: string[];
+ aur_packages?: string[];
+ locale?: string;
+ keyboard?: string;
+ timezone?: string;
encryption?: {
enabled: boolean;
- method?: 'keyfile' | 'passphrase';
+ method?: 'keyfile' | 'passphrase' | 'interactive';
};
snapshots?: {
enabled: boolean;
@@ -39,3 +44,52 @@ export interface HealthResponse {
version: string;
timestamp: string;
}
+
+export interface User {
+ id: string;
+ email: string;
+ display_name: string;
+ created_at: string;
+ saved_configs?: SavedConfig[];
+ system_copies?: SystemCopy[];
+}
+
+export interface SavedConfig {
+ id: string;
+ name: string;
+ created_at: string;
+ profile: string;
+ hostname?: string;
+ username?: string;
+ desktop?: string;
+ packages?: string[];
+ aur_packages?: string[];
+ locale?: string;
+ keyboard?: string;
+ timezone?: string;
+}
+
+export interface SystemCopy {
+ id: string;
+ created_at: string;
+ packages: string[];
+ desktop: string;
+ locale?: string;
+ keyboard?: string;
+ timezone?: string;
+ status: string;
+}
+
+export interface Recommendation {
+ id: string;
+ title: string;
+ description: string;
+ profile: string;
+ desktop?: string;
+ packages?: string[];
+}
+
+export interface ReleaseInfo {
+ neubat: { version: string; iso_url: string; sha256_url: string };
+ arch: { iso_url: string; sha256_url: string };
+}
diff --git a/portal/lib/archinstall.js b/portal/lib/archinstall.js
new file mode 100644
index 0000000..98ccf7b
--- /dev/null
+++ b/portal/lib/archinstall.js
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * Traduce un perfil NEUBAT a los JSON que consume archinstall (--config / --creds).
+ */
+
+const DESKTOP_MAP = {
+ none: null,
+ kde: 'plasma',
+ plasma: 'plasma',
+ gnome: 'gnome',
+ xfce: 'xfce',
+ hyprland: 'hyprland',
+ sway: 'sway',
+ i3: 'i3',
+ niri: 'niri'
+};
+
+function toArchinstallPair(profile) {
+ const desktopKey = String(profile.desktop || 'none').toLowerCase();
+ const profileName = DESKTOP_MAP[desktopKey];
+
+ const config = {
+ version: '2.8.0',
+ 'archinstall-language': 'Spanish',
+ hostname: profile.hostname || 'neubat',
+ timezone: profile.timezone || 'Europe/Madrid',
+ locale_config: {
+ kb_layout: profile.keyboard || 'es',
+ sys_lang: (profile.locale || 'es_ES.UTF-8').split('.')[0].replace('_', '-'),
+ sys_enc: 'UTF-8'
+ },
+ disk_config: {
+ config_type: 'manual_partitioning',
+ device: profile.disk || '/dev/sda'
+ },
+ network_config: { type: 'nm' },
+ packages: Array.isArray(profile.packages) ? profile.packages.filter(Boolean) : [],
+ profile_config: profileName
+ ? { profile: { details: { [profileName]: {} } } }
+ : { profile: { details: {} } },
+ bootloader: 'Systemd-boot',
+ ntp: true
+ };
+
+ const creds = {
+ root_enc_password: profile.password || 'neubat',
+ '!users': [
+ {
+ username: profile.username || 'neubat',
+ '!password': profile.password || 'neubat',
+ sudo: true
+ }
+ ]
+ };
+
+ return { config, creds, desktop: desktopKey, aur_packages: profile.aur_packages || [] };
+}
+
+module.exports = { toArchinstallPair, DESKTOP_MAP };
diff --git a/portal/lib/auth.js b/portal/lib/auth.js
new file mode 100644
index 0000000..7badcf5
--- /dev/null
+++ b/portal/lib/auth.js
@@ -0,0 +1,19 @@
+'use strict';
+/**
+ * Autenticación de operador (ADMIN_TOKEN).
+ */
+
+function requireAdmin(req, res, next) {
+ if (!process.env.ADMIN_TOKEN) {
+ return res.status(503).json({ error: 'Panel de administración no configurado (falta ADMIN_TOKEN)' });
+ }
+ const header = req.headers.authorization || '';
+ const token = header.replace(/^Bearer\s+/i, '');
+ if (token !== process.env.ADMIN_TOKEN) {
+ res.set('WWW-Authenticate', 'Bearer');
+ return res.status(401).json({ error: 'No autorizado' });
+ }
+ next();
+}
+
+module.exports = { requireAdmin };
diff --git a/portal/lib/db.js b/portal/lib/db.js
index e41ed72..6993e49 100644
--- a/portal/lib/db.js
+++ b/portal/lib/db.js
@@ -60,6 +60,16 @@ function hmacSecret() {
return process.env.NEUBAT_HMAC_SECRET || '';
}
+// Forma canónica de un objeto anidado (claves ordenadas). Ausente → ''.
+// Debe coincidir con scripts/20-archinstall.sh (verify_config_signature).
+function canonicalObject(value) {
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
+ return '';
+ }
+ const keys = Object.keys(value).sort();
+ return keys.map((k) => `${k}=${value[k] == null ? '' : String(value[k])}`).join(',');
+}
+
// 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) {
@@ -74,8 +84,10 @@ function signingPayload(config) {
String(config.timezone || ''),
String(config.locale || ''),
String(config.keyboard || ''),
- ...(Array.isArray(config.packages) ? config.packages.sort() : []),
- ...(Array.isArray(config.services) ? config.services.sort() : [])
+ ...(Array.isArray(config.packages) ? [...config.packages].sort() : []),
+ ...(Array.isArray(config.services) ? [...config.services].sort() : []),
+ canonicalObject(config.encryption),
+ canonicalObject(config.snapshots)
];
return parts.join('|');
}
@@ -100,6 +112,7 @@ module.exports = {
loadProfile,
configPathFor,
hmacSecret,
+ canonicalObject,
signingPayload,
signConfig
};
diff --git a/portal/lib/users.js b/portal/lib/users.js
new file mode 100644
index 0000000..a1eed94
--- /dev/null
+++ b/portal/lib/users.js
@@ -0,0 +1,216 @@
+'use strict';
+/**
+ * Usuarios, sesiones y perfiles guardados (JSON en portal/data/users/).
+ */
+
+const crypto = require('crypto');
+const fs = require('fs').promises;
+const path = require('path');
+const { PORTAL_ROOT } = require('./db');
+
+const USERS_DIR = process.env.NEUBAT_USERS_DIR || path.join(PORTAL_ROOT, 'data', 'users');
+const USERS_INDEX = path.join(USERS_DIR, 'index.json');
+const SESSIONS_PATH = path.join(USERS_DIR, 'sessions.json');
+const COOKIE_NAME = 'neubat_session';
+
+async function ensureUsersStore() {
+ await fs.mkdir(USERS_DIR, { recursive: true });
+ try {
+ await fs.access(USERS_INDEX);
+ } catch {
+ await writeJson(USERS_INDEX, { users: [] });
+ }
+ try {
+ await fs.access(SESSIONS_PATH);
+ } catch {
+ await writeJson(SESSIONS_PATH, { sessions: {} });
+ }
+}
+
+async function readJson(file, fallback) {
+ try {
+ return JSON.parse(await fs.readFile(file, 'utf8'));
+ } catch {
+ return fallback;
+ }
+}
+
+async function writeJson(file, data) {
+ const tmp = `${file}.tmp`;
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
+ await fs.rename(tmp, file);
+}
+
+function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
+ const hash = crypto.scryptSync(password, salt, 64).toString('hex');
+ return { salt, hash };
+}
+
+function verifyPassword(password, salt, hash) {
+ try {
+ const check = crypto.scryptSync(password, salt, 64).toString('hex');
+ if (check.length !== hash.length) return false;
+ return crypto.timingSafeEqual(Buffer.from(check, 'hex'), Buffer.from(hash, 'hex'));
+ } catch {
+ return false;
+ }
+}
+
+function userPath(userId) {
+ if (!/^[0-9a-f]{16}$/.test(userId)) return null;
+ return path.join(USERS_DIR, `${userId}.json`);
+}
+
+async function createUser(email, password, displayName) {
+ const normalized = String(email || '').trim().toLowerCase();
+ if (!normalized || !normalized.includes('@')) {
+ throw Object.assign(new Error('Correo no válido'), { status: 400 });
+ }
+ if (!password || String(password).length < 8) {
+ throw Object.assign(new Error('La contraseña debe tener al menos 8 caracteres'), { status: 400 });
+ }
+
+ const index = await readJson(USERS_INDEX, { users: [] });
+ if (index.users.some((u) => u.email === normalized)) {
+ throw Object.assign(new Error('Ya existe una cuenta con ese correo'), { status: 409 });
+ }
+
+ const id = crypto.randomBytes(8).toString('hex');
+ const { salt, hash } = hashPassword(password);
+ const user = {
+ id,
+ email: normalized,
+ display_name: displayName || normalized.split('@')[0],
+ password_salt: salt,
+ password_hash: hash,
+ created_at: new Date().toISOString(),
+ saved_configs: [],
+ system_copies: [],
+ absorb_codes: []
+ };
+
+ await writeJson(userPath(id), user);
+ index.users.push({ id, email: normalized });
+ await writeJson(USERS_INDEX, index);
+
+ const { password_salt, password_hash, absorb_codes, ...safe } = user;
+ return safe;
+}
+
+async function findUserByEmail(email) {
+ const index = await readJson(USERS_INDEX, { users: [] });
+ const entry = index.users.find((u) => u.email === String(email || '').trim().toLowerCase());
+ if (!entry) return null;
+ return readJson(userPath(entry.id), null);
+}
+
+async function getUserById(id) {
+ const p = userPath(id);
+ if (!p) return null;
+ return readJson(p, null);
+}
+
+async function saveUser(user) {
+ await writeJson(userPath(user.id), user);
+}
+
+async function createSession(userId) {
+ const sid = crypto.randomBytes(24).toString('hex');
+ const store = await readJson(SESSIONS_PATH, { sessions: {} });
+ store.sessions[sid] = {
+ user_id: userId,
+ created_at: new Date().toISOString(),
+ expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
+ };
+ await writeJson(SESSIONS_PATH, store);
+ return sid;
+}
+
+async function destroySession(sid) {
+ if (!sid) return;
+ const store = await readJson(SESSIONS_PATH, { sessions: {} });
+ delete store.sessions[sid];
+ await writeJson(SESSIONS_PATH, store);
+}
+
+async function resolveSession(sid) {
+ if (!sid) return null;
+ const store = await readJson(SESSIONS_PATH, { sessions: {} });
+ const session = store.sessions[sid];
+ if (!session) return null;
+ if (new Date(session.expires_at) < new Date()) {
+ delete store.sessions[sid];
+ await writeJson(SESSIONS_PATH, store);
+ return null;
+ }
+ const user = await getUserById(session.user_id);
+ if (!user) return null;
+ const { password_salt, password_hash, absorb_codes, ...safe } = user;
+ return { session, user: safe, fullUser: user };
+}
+
+function parseCookies(header) {
+ const out = {};
+ if (!header) return out;
+ for (const part of header.split(';')) {
+ const idx = part.indexOf('=');
+ if (idx === -1) continue;
+ const k = part.slice(0, idx).trim();
+ const v = part.slice(idx + 1).trim();
+ out[k] = decodeURIComponent(v);
+ }
+ return out;
+}
+
+function setSessionCookie(res, sid) {
+ const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
+ res.setHeader(
+ 'Set-Cookie',
+ `${COOKIE_NAME}=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}${secure}`
+ );
+}
+
+function clearSessionCookie(res) {
+ res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
+}
+
+async function optionalUser(req, res, next) {
+ try {
+ const cookies = parseCookies(req.headers.cookie);
+ const resolved = await resolveSession(cookies[COOKIE_NAME]);
+ req.user = resolved ? resolved.user : null;
+ req.fullUser = resolved ? resolved.fullUser : null;
+ req.sessionId = resolved ? cookies[COOKIE_NAME] : null;
+ next();
+ } catch (err) {
+ next(err);
+ }
+}
+
+async function requireUser(req, res, next) {
+ await optionalUser(req, res, () => {
+ if (!req.user) {
+ return res.status(401).json({ error: 'Debes iniciar sesión' });
+ }
+ next();
+ });
+}
+
+module.exports = {
+ COOKIE_NAME,
+ USERS_DIR,
+ ensureUsersStore,
+ createUser,
+ findUserByEmail,
+ getUserById,
+ saveUser,
+ createSession,
+ destroySession,
+ resolveSession,
+ verifyPassword,
+ parseCookies,
+ setSessionCookie,
+ clearSessionCookie,
+ optionalUser,
+ requireUser
+};
diff --git a/portal/routes/account.js b/portal/routes/account.js
new file mode 100644
index 0000000..0079d98
--- /dev/null
+++ b/portal/routes/account.js
@@ -0,0 +1,193 @@
+'use strict';
+/**
+ * Perfil de usuario: configs guardadas, recomendaciones, códigos de absorción.
+ */
+
+const express = require('express');
+const crypto = require('crypto');
+const path = require('path');
+const fs = require('fs').promises;
+const users = require('../lib/users');
+const db = require('../lib/db');
+const { toArchinstallPair } = require('../lib/archinstall');
+
+const router = express.Router();
+
+const RECOMMENDATIONS = [
+ {
+ id: 'production',
+ title: 'Producción (KDE)',
+ description: 'Escritorio Plasma, servicios de servidor y cifrado recomendado.',
+ profile: 'production'
+ },
+ {
+ id: 'developer',
+ title: 'Desarrollo (GNOME)',
+ description: 'Toolchains, contenedores y herramientas de desarrollo.',
+ profile: 'developer'
+ },
+ {
+ id: 'base',
+ title: 'Base mínima',
+ description: 'Sistema sin GUI, ideal para servidores o personalización total.',
+ profile: 'base'
+ },
+ {
+ id: 'hyprland',
+ title: 'Hyprland (recomendado equipo)',
+ description: 'Compositor Wayland tiling; parte de los perfiles curados NEUBAT.',
+ profile: 'base',
+ desktop: 'hyprland',
+ packages: ['hyprland', 'waybar', 'kitty', 'xdg-desktop-portal-hyprland']
+ }
+];
+
+router.get('/recommendations', (_req, res) => {
+ res.json({ recommendations: RECOMMENDATIONS });
+});
+
+router.get('/configs', users.requireUser, async (req, res) => {
+ const full = await users.getUserById(req.user.id);
+ res.json({ configs: full.saved_configs || [] });
+});
+
+router.post('/configs', users.requireUser, async (req, res) => {
+ try {
+ const full = await users.getUserById(req.user.id);
+ const body = req.body || {};
+ const id = crypto.randomBytes(8).toString('hex');
+ const entry = {
+ id,
+ name: body.name || `config-${id.slice(0, 6)}`,
+ created_at: new Date().toISOString(),
+ profile: body.profile || 'base',
+ hostname: body.hostname,
+ username: body.username,
+ desktop: body.desktop,
+ packages: body.packages || [],
+ aur_packages: body.aur_packages || [],
+ locale: body.locale,
+ keyboard: body.keyboard,
+ timezone: body.timezone,
+ encryption: body.encryption,
+ snapshots: body.snapshots,
+ archinstall: toArchinstallPair(body)
+ };
+ full.saved_configs = full.saved_configs || [];
+ full.saved_configs.push(entry);
+ await users.saveUser(full);
+ res.status(201).json({ success: true, config: entry });
+ } catch (err) {
+ res.status(500).json({ error: err.message || 'Error interno' });
+ }
+});
+
+router.get('/copies', users.requireUser, async (req, res) => {
+ const full = await users.getUserById(req.user.id);
+ res.json({
+ copies: full.system_copies || [],
+ note: 'El contenido de documentos personales no se clona en el instalador; solo paquetes y dotfiles allowlist.'
+ });
+});
+
+router.post('/absorb-code', users.requireUser, async (req, res) => {
+ const full = await users.getUserById(req.user.id);
+ const code = crypto.randomBytes(16).toString('hex');
+ full.absorb_codes = (full.absorb_codes || []).filter((c) => new Date(c.expires_at) > new Date());
+ full.absorb_codes.push({
+ code,
+ created_at: new Date().toISOString(),
+ expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
+ used: false
+ });
+ await users.saveUser(full);
+ res.json({
+ code,
+ expires_in_seconds: 900,
+ usage: `neubat-absorb.sh --code ${code} --portal ${req.protocol}://${req.get('host')}`
+ });
+});
+
+router.post('/absorb', async (req, res) => {
+ try {
+ const { code, inventory } = req.body || {};
+ if (!code || !inventory) {
+ return res.status(400).json({ error: 'Faltan code o inventory' });
+ }
+
+ const index = JSON.parse(
+ await fs.readFile(path.join(users.USERS_DIR, 'index.json'), 'utf8').catch(() => '{"users":[]}')
+ );
+ let owner = null;
+ for (const entry of index.users || []) {
+ const u = await users.getUserById(entry.id);
+ if (!u) continue;
+ const match = (u.absorb_codes || []).find((c) => c.code === code && !c.used && new Date(c.expires_at) > new Date());
+ if (match) {
+ owner = u;
+ match.used = true;
+ break;
+ }
+ }
+ if (!owner) {
+ return res.status(401).json({ error: 'Código de absorción inválido o caducado' });
+ }
+
+ const copy = {
+ id: crypto.randomBytes(8).toString('hex'),
+ created_at: new Date().toISOString(),
+ packages: inventory.packages || [],
+ desktop: inventory.desktop || 'none',
+ locale: inventory.locale,
+ keyboard: inventory.keyboard,
+ timezone: inventory.timezone,
+ dotfiles: inventory.dotfiles || [],
+ status: 'pending_confirmation'
+ };
+ owner.system_copies = owner.system_copies || [];
+ owner.system_copies.push(copy);
+ await users.saveUser(owner);
+ res.status(201).json({ success: true, copy_id: copy.id, message: 'Copia recibida. Confírmala en /cuenta.' });
+ } catch (err) {
+ res.status(500).json({ error: err.message || 'Error interno' });
+ }
+});
+
+router.post('/copies/:id/confirm', users.requireUser, async (req, res) => {
+ const full = await users.getUserById(req.user.id);
+ const copy = (full.system_copies || []).find((c) => c.id === req.params.id);
+ if (!copy) return res.status(404).json({ error: 'Copia no encontrada' });
+ copy.status = 'confirmed';
+ copy.confirmed_at = new Date().toISOString();
+ await users.saveUser(full);
+ res.json({ success: true, copy });
+});
+
+router.get('/releases', async (_req, res) => {
+ const tag = process.env.NEUBAT_RELEASE_TAG || 'v1.0.0';
+ const version = tag.replace(/^v/, '');
+ const base = process.env.NEUBAT_RELEASE_BASE
+ || `https://github.com/Alexendros/neubat/releases/download/${tag}`;
+ res.json({
+ neubat: {
+ version,
+ iso_url: `${base}/neubat-${version}-x86_64.iso`,
+ sha256_url: `${base}/neubat-${version}-x86_64.iso.sha256`
+ },
+ arch: {
+ iso_url: 'https://geo.mirror.pkgbuild.com/iso/latest/archlinux-x86_64.iso',
+ sha256_url: 'https://geo.mirror.pkgbuild.com/iso/latest/sha256sums.txt'
+ }
+ });
+});
+
+router.get('/profiles/:name', async (req, res) => {
+ try {
+ const profile = await db.loadProfile(req.params.name);
+ res.json(profile);
+ } catch {
+ res.status(404).json({ error: 'Perfil no encontrado' });
+ }
+});
+
+module.exports = router;
diff --git a/portal/routes/admin.js b/portal/routes/admin.js
index cf0407e..1066a14 100644
--- a/portal/routes/admin.js
+++ b/portal/routes/admin.js
@@ -8,24 +8,11 @@
const express = require('express');
const fs = require('fs').promises;
-const path = require('path');
const db = require('../lib/db');
+const { requireAdmin } = require('../lib/auth');
const router = express.Router();
-function requireAdmin(req, res, next) {
- if (!process.env.ADMIN_TOKEN) {
- return res.status(503).json({ error: 'Panel de administración no configurado (falta ADMIN_TOKEN)' });
- }
- const header = req.headers.authorization || '';
- const token = header.replace(/^Bearer\s+/i, '');
- if (token !== process.env.ADMIN_TOKEN) {
- res.set('WWW-Authenticate', 'Bearer');
- return res.status(401).json({ error: 'No autorizado' });
- }
- next();
-}
-
// Listado completo de instalaciones
router.get('/installations', requireAdmin, async (req, res) => {
try {
diff --git a/portal/routes/auth.js b/portal/routes/auth.js
new file mode 100644
index 0000000..c989466
--- /dev/null
+++ b/portal/routes/auth.js
@@ -0,0 +1,50 @@
+'use strict';
+/**
+ * Auth de usuario final (registro / login / sesión).
+ */
+
+const express = require('express');
+const users = require('../lib/users');
+
+const router = express.Router();
+
+router.post('/register', async (req, res) => {
+ try {
+ const { email, password, display_name: displayName } = req.body || {};
+ const user = await users.createUser(email, password, displayName);
+ const sid = await users.createSession(user.id);
+ users.setSessionCookie(res, sid);
+ res.status(201).json({ success: true, user });
+ } catch (err) {
+ res.status(err.status || 500).json({ error: err.message || 'Error interno' });
+ }
+});
+
+router.post('/login', async (req, res) => {
+ try {
+ const { email, password } = req.body || {};
+ const full = await users.findUserByEmail(email);
+ if (!full || !users.verifyPassword(password, full.password_salt, full.password_hash)) {
+ return res.status(401).json({ error: 'Correo o contraseña incorrectos' });
+ }
+ const sid = await users.createSession(full.id);
+ users.setSessionCookie(res, sid);
+ const { password_salt, password_hash, absorb_codes, ...safe } = full;
+ res.json({ success: true, user: safe });
+ } catch (err) {
+ res.status(500).json({ error: err.message || 'Error interno' });
+ }
+});
+
+router.post('/logout', users.optionalUser, async (req, res) => {
+ await users.destroySession(req.sessionId);
+ users.clearSessionCookie(res);
+ res.json({ success: true });
+});
+
+router.get('/me', users.optionalUser, (req, res) => {
+ if (!req.user) return res.status(401).json({ error: 'Sin sesión' });
+ res.json({ user: req.user });
+});
+
+module.exports = router;
diff --git a/portal/routes/install.js b/portal/routes/install.js
index 04cf465..3c599a8 100644
--- a/portal/routes/install.js
+++ b/portal/routes/install.js
@@ -25,8 +25,12 @@ router.post('/install', async (req, res) => {
password,
desktop,
packages = [],
+ aur_packages = [],
encryption,
- snapshots
+ snapshots,
+ locale,
+ keyboard,
+ timezone
} = req.body;
const token = db.generateToken();
@@ -39,6 +43,8 @@ router.post('/install', async (req, res) => {
return res.status(400).json({ error: `Perfil desconocido: ${profile}` });
}
+ const { toArchinstallPair } = require('../lib/archinstall');
+
const config = {
...baseProfile,
token,
@@ -48,20 +54,20 @@ router.post('/install', async (req, res) => {
password: password || baseProfile.password,
desktop: desktop || baseProfile.desktop,
packages: [...new Set([...(baseProfile.packages || []), ...packages])],
+ aur_packages: [...new Set([...(baseProfile.aur_packages || []), ...aur_packages])],
+ locale: locale || baseProfile.locale,
+ keyboard: keyboard || baseProfile.keyboard,
+ timezone: timezone || baseProfile.timezone,
created_at: new Date().toISOString(),
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') {
+ const method = encryption.method === 'interactive' ? 'passphrase' : encryption.method;
config.encryption = {
...(baseProfile.encryption || {}),
- ...encryption
+ ...encryption,
+ ...(method ? { method } : {})
};
}
@@ -72,6 +78,14 @@ router.post('/install', async (req, res) => {
};
}
+ config.archinstall = toArchinstallPair(config);
+
+ // Firma HMAC de la configuración (solo si el portal tiene secreto)
+ const signature = db.signConfig(config);
+ if (signature) {
+ config.signature = signature;
+ }
+
const configPath = db.configPathFor(token);
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
@@ -81,7 +95,8 @@ router.post('/install', async (req, res) => {
machine_id: machineId,
profile,
status: 'pending',
- created_at: config.created_at
+ created_at: config.created_at,
+ user_id: req.user ? req.user.id : null
});
await db.writeDB(store);
@@ -147,18 +162,40 @@ router.post('/complete', async (req, res) => {
// GET /boot/:token — script iPXE personalizado para arranque por red
bootRouter.get('/:token', async (req, res) => {
const configPath = db.configPathFor(req.params.token);
+ let profile = 'production';
try {
if (!configPath) throw new Error('token inválido');
await fs.access(configPath);
+ const cfg = JSON.parse(await fs.readFile(configPath, 'utf8'));
+ if (cfg && typeof cfg === 'object') {
+ // perfil base no se guarda en el JSON siempre; intentar desde instalaciones
+ }
} catch {
return res.status(404).type('text/plain').send('#!ipxe\necho Configuracion no encontrada\nshell\n');
}
+ try {
+ const store = await db.readDB();
+ const install = store.installations.find((i) => i.token === req.params.token);
+ if (install && install.profile) profile = install.profile;
+ } catch {
+ /* ignore */
+ }
+
+ // Live NEUBAT (con hook) si NEUBAT_LIVE_BASE está definido; si no, mirror Arch.
+ const portalPublic = process.env.NEUBAT_PUBLIC_URL
+ || `${req.protocol}://${req.get('host')}`;
+ const liveBase = process.env.NEUBAT_LIVE_BASE || `${portalPublic}/live`;
+ const useNeubatLive = Boolean(process.env.NEUBAT_LIVE_BASE) || process.env.NEUBAT_USE_LIVE === '1';
+ const baseUrl = useNeubatLive ? liveBase : BOOT_BASE_URL;
+ const archSrv = useNeubatLive ? `${liveBase}/` : `${BOOT_BASE_URL}/arch/`;
+
const script = `#!ipxe
dhcp
-set base-url ${BOOT_BASE_URL}
-kernel \${base-url}/arch/boot/x86_64/vmlinuz-linux initrd=initramfs-linux.img archiso_http_srv=\${base-url}/arch/ ip=dhcp net.ifnames=0 console=ttyS0 neubat_token=${req.params.token}
-initrd \${base-url}/arch/boot/x86_64/initramfs-linux.img
+set base-url ${baseUrl}
+set portal-url ${portalPublic}
+kernel \${base-url}${useNeubatLive ? '/boot/x86_64/vmlinuz-linux' : '/arch/boot/x86_64/vmlinuz-linux'} initrd=initramfs-linux.img archiso_http_srv=${archSrv} ip=dhcp net.ifnames=0 console=ttyS0 neubat_token=${req.params.token} neubat_profile=${profile} neubat_portal_url=\${portal-url}
+initrd \${base-url}${useNeubatLive ? '/boot/x86_64/initramfs-linux.img' : '/arch/boot/x86_64/initramfs-linux.img'}
boot
`;
res.type('text/plain').send(script);
diff --git a/portal/routes/status.js b/portal/routes/status.js
index 39d3c3a..a461b6e 100644
--- a/portal/routes/status.js
+++ b/portal/routes/status.js
@@ -5,16 +5,17 @@
const express = require('express');
const db = require('../lib/db');
+const { requireAdmin } = require('../lib/auth');
const router = express.Router();
-// GET /api/health — health check
+// GET /api/health — health check (público)
router.get('/health', (req, res) => {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() });
});
-// GET /api/installations — últimas 50 instalaciones (más recientes primero)
-router.get('/installations', async (req, res) => {
+// GET /api/installations — últimas 50 (requiere ADMIN_TOKEN)
+router.get('/installations', requireAdmin, async (req, res) => {
try {
const store = await db.readDB();
res.json(store.installations.slice(-50).reverse());
@@ -23,8 +24,8 @@ router.get('/installations', async (req, res) => {
}
});
-// GET /api/installations/:token — estado de una instalación concreta
-router.get('/installations/:token', async (req, res) => {
+// GET /api/installations/:token — estado de una instalación (requiere ADMIN_TOKEN)
+router.get('/installations/:token', requireAdmin, async (req, res) => {
try {
const store = await db.readDB();
const install = store.installations.find(i => i.token === req.params.token);
@@ -35,8 +36,8 @@ router.get('/installations/:token', async (req, res) => {
}
});
-// GET /api/metrics — métricas agregadas de instalaciones
-router.get('/metrics', async (req, res) => {
+// GET /api/metrics — métricas agregadas (requiere ADMIN_TOKEN)
+router.get('/metrics', requireAdmin, async (req, res) => {
try {
const store = await db.readDB();
const installs = store.installations || [];
diff --git a/portal/server.js b/portal/server.js
index 620b192..45897ba 100644
--- a/portal/server.js
+++ b/portal/server.js
@@ -8,15 +8,18 @@
const express = require('express');
const path = require('path');
const db = require('./lib/db');
+const users = require('./lib/users');
const install = require('./routes/install');
const statusRoutes = require('./routes/status');
const adminRoutes = require('./routes/admin');
+const authRoutes = require('./routes/auth');
+const accountRoutes = require('./routes/account');
const app = express();
const PORT = process.env.PORT || 3000;
app.disable('x-powered-by');
-app.use(express.json({ limit: '64kb' }));
+app.use(express.json({ limit: '2mb' }));
// Rate limiting simple en memoria (100 req / 15 min por IP)
const requestCounts = new Map();
@@ -38,6 +41,9 @@ function apiLimiter(req, res, next) {
}
app.use('/api', apiLimiter);
+app.use('/api', users.optionalUser);
+app.use('/api/auth', authRoutes);
+app.use('/api/account', accountRoutes);
app.use('/api', install.router);
app.use('/api', statusRoutes);
app.use('/api/admin', adminRoutes);
@@ -45,6 +51,10 @@ app.use('/boot', install.bootRouter);
app.use(express.static(path.join(__dirname, 'public')));
+// Live NEUBAT para netboot (si existe out/live o NEUBAT_LIVE_DIR)
+const LIVE_DIR = process.env.NEUBAT_LIVE_DIR || path.join(__dirname, '..', 'out', 'live');
+app.use('/live', express.static(LIVE_DIR));
+
// 404 JSON para rutas API no definidas
app.use('/api', (req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' });
@@ -57,6 +67,7 @@ app.get('*', (req, res) => {
async function start() {
await db.initStorage();
+ await users.ensureUsersStore();
app.listen(PORT, () => {
console.log(`
╔══════════════════════════════════════════════════════════════╗
@@ -78,4 +89,3 @@ if (require.main === module) {
}
module.exports = app;
-
diff --git a/portal/tests/app.test.js b/portal/tests/app.test.js
index 6316f2a..02ca927 100644
--- a/portal/tests/app.test.js
+++ b/portal/tests/app.test.js
@@ -12,6 +12,7 @@ let wroteStubIndex = false;
describe('app integration', () => {
beforeAll(async () => {
+ process.env.ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'test-admin-token';
await db.initStorage();
// public/index.html es artefacto de Vite (gitignored); el job test no lo genera.
if (!fs.existsSync(publicIndex)) {
@@ -50,6 +51,7 @@ describe('app integration', () => {
const status = await request(app)
.get(`/api/installations/${token}`)
+ .set('Authorization', `Bearer ${process.env.ADMIN_TOKEN || 'test-admin-token'}`)
.expect(200);
expect(status.body.status).toBe('completed');
diff --git a/portal/tests/lib/db.test.js b/portal/tests/lib/db.test.js
index 2915061..32d7efd 100644
--- a/portal/tests/lib/db.test.js
+++ b/portal/tests/lib/db.test.js
@@ -69,4 +69,58 @@ describe('lib/db', () => {
expect(sig1).toMatch(/^[0-9a-f]{64}$/);
expect(sig1).toBe(sig2);
});
+
+ test('signingPayload incluye encryption y snapshots canónicos', () => {
+ const base = {
+ 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: ['b', 'a'],
+ services: ['sshd']
+ };
+ const without = db.signingPayload(base);
+ expect(without.endsWith('||')).toBe(true);
+
+ const withEnc = db.signingPayload({
+ ...base,
+ encryption: { method: 'keyfile', enabled: true },
+ snapshots: { enabled: true }
+ });
+ expect(withEnc).toContain('enabled=true,method=keyfile');
+ expect(withEnc).toContain('enabled=true');
+ expect(withEnc).not.toBe(without);
+ });
+
+ test('alterar encryption invalida la firma HMAC', () => {
+ 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'],
+ services: [],
+ encryption: { enabled: true, method: 'keyfile' },
+ snapshots: { enabled: true }
+ };
+ const sig = db.signConfig(config);
+ const tampered = {
+ ...config,
+ encryption: { enabled: true, method: 'passphrase' }
+ };
+ expect(db.signConfig(tampered)).not.toBe(sig);
+ });
});
diff --git a/portal/tests/routes/auth.test.js b/portal/tests/routes/auth.test.js
new file mode 100644
index 0000000..e34cd85
--- /dev/null
+++ b/portal/tests/routes/auth.test.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const request = require('supertest');
+const app = require('../../server');
+const users = require('../../lib/users');
+const { toArchinstallPair } = require('../../lib/archinstall');
+
+describe('auth + account + archinstall', () => {
+ beforeAll(async () => {
+ await users.ensureUsersStore();
+ });
+
+ test('registro e inicio de sesión', async () => {
+ const email = `user${Date.now()}@example.com`;
+ const reg = await request(app)
+ .post('/api/auth/register')
+ .send({ email, password: 'secreto123', display_name: 'Neo' })
+ .expect(201);
+ expect(reg.body.user.email).toBe(email);
+ expect(reg.headers['set-cookie']).toBeDefined();
+
+ await request(app).post('/api/auth/logout').expect(200);
+
+ const login = await request(app)
+ .post('/api/auth/login')
+ .send({ email, password: 'secreto123' })
+ .expect(200);
+ expect(login.body.user.display_name).toBe('Neo');
+ });
+
+ test('recomendaciones públicas', async () => {
+ const res = await request(app).get('/api/account/recommendations').expect(200);
+ expect(res.body.recommendations.length).toBeGreaterThan(0);
+ });
+
+ test('guardar config requiere sesión', async () => {
+ await request(app).post('/api/account/configs').send({ name: 'x' }).expect(401);
+ });
+
+ test('toArchinstallPair genera config y creds', () => {
+ const pair = toArchinstallPair({
+ hostname: 'h',
+ username: 'u',
+ password: 'p',
+ desktop: 'hyprland',
+ packages: ['git'],
+ timezone: 'Europe/Madrid',
+ locale: 'es_ES.UTF-8',
+ keyboard: 'es'
+ });
+ expect(pair.config.hostname).toBe('h');
+ expect(pair.creds['!users'][0].username).toBe('u');
+ expect(pair.desktop).toBe('hyprland');
+ });
+
+ test('releases expone URLs de ISO y hash', async () => {
+ const res = await request(app).get('/api/account/releases').expect(200);
+ expect(res.body.neubat.iso_url).toContain('.iso');
+ expect(res.body.neubat.sha256_url).toContain('.sha256');
+ expect(res.body.arch.sha256_url).toContain('sha256');
+ });
+});
diff --git a/portal/tests/routes/status.test.js b/portal/tests/routes/status.test.js
index 2424f97..9ba506e 100644
--- a/portal/tests/routes/status.test.js
+++ b/portal/tests/routes/status.test.js
@@ -5,45 +5,75 @@ const app = require('../../server');
const db = require('../../lib/db');
describe('routes/status', () => {
+ const adminToken = 'test-admin-token';
+
beforeAll(async () => {
+ process.env.ADMIN_TOKEN = adminToken;
await db.initStorage();
});
+ afterAll(() => {
+ delete process.env.ADMIN_TOKEN;
+ });
+
test('GET /api/health responde ok', async () => {
const res = await request(app).get('/api/health').expect(200);
expect(res.body.status).toBe('ok');
expect(res.body.version).toBe('1.0.0');
});
- test('GET /api/installations lista instalaciones', async () => {
+ test('GET /api/installations sin token → 401', async () => {
+ await request(app).get('/api/installations').expect(401);
+ });
+
+ test('GET /api/installations con ADMIN_TOKEN lista instalaciones', async () => {
const create = await request(app)
.post('/api/install')
.send({ profile: 'base' });
- const res = await request(app).get('/api/installations').expect(200);
+ const res = await request(app)
+ .get('/api/installations')
+ .set('Authorization', `Bearer ${adminToken}`)
+ .expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.some(i => i.token === create.body.token)).toBe(true);
});
- test('GET /api/installations/:token devuelve una instalación', async () => {
+ test('GET /api/installations/:token sin auth → 401', async () => {
+ const create = await request(app)
+ .post('/api/install')
+ .send({ profile: 'base' });
+
+ await request(app)
+ .get(`/api/installations/${create.body.token}`)
+ .expect(401);
+ });
+
+ test('GET /api/installations/:token con auth devuelve una instalación', async () => {
const create = await request(app)
.post('/api/install')
.send({ profile: 'base' });
const res = await request(app)
.get(`/api/installations/${create.body.token}`)
+ .set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(res.body.token).toBe(create.body.token);
});
- test('GET /api/installations/:token devuelve 404 si no existe', async () => {
+ test('GET /api/installations/:token con auth → 404 si no existe', async () => {
await request(app)
.get('/api/installations/00000000000000000000000000000000')
+ .set('Authorization', `Bearer ${adminToken}`)
.expect(404);
});
- test('GET /api/metrics devuelve métricas agregadas', async () => {
+ test('GET /api/metrics sin auth → 401', async () => {
+ await request(app).get('/api/metrics').expect(401);
+ });
+
+ test('GET /api/metrics con auth devuelve métricas agregadas', async () => {
const create = await request(app)
.post('/api/install')
.send({ profile: 'base' });
@@ -53,7 +83,10 @@ describe('routes/status', () => {
.send({ token: create.body.token, status: 'completed', duration: 120 })
.expect(200);
- const res = await request(app).get('/api/metrics').expect(200);
+ const res = await request(app)
+ .get('/api/metrics')
+ .set('Authorization', `Bearer ${adminToken}`)
+ .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/portal/tests/setup.js b/portal/tests/setup.js
index 6ac1746..4a4c351 100644
--- a/portal/tests/setup.js
+++ b/portal/tests/setup.js
@@ -5,4 +5,6 @@ const path = require('path');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neubat-test-'));
process.env.NEUBAT_DATA_DIR = tmpDir;
process.env.NEUBAT_CONFIGS_DIR = path.join(tmpDir, 'configs', 'generated');
+process.env.NEUBAT_USERS_DIR = path.join(tmpDir, 'users');
fs.mkdirSync(process.env.NEUBAT_CONFIGS_DIR, { recursive: true });
+fs.mkdirSync(process.env.NEUBAT_USERS_DIR, { recursive: true });
diff --git a/scripts/10-partition.sh b/scripts/10-partition.sh
index ba63884..7b1f350 100755
--- a/scripts/10-partition.sh
+++ b/scripts/10-partition.sh
@@ -4,10 +4,15 @@
# Módulo cargado por neubat-install.sh (no ejecutar directamente)
#
# Esquema resultante:
-# p1 EFI 512 MiB FAT32 /boot/efi
-# p2 raíz 20-30 GiB btrfs /
-# p3 home resto-4G btrfs /home
-# p4 swap 4 GiB swap
+# p1 EFI/boot 1 GiB FAT32 /boot (también /boot/efi para UEFI)
+# p2 raíz 19-29 GiB btrfs /
+# p3 home resto-4G btrfs /home
+# p4 swap 4 GiB swap
+#
+# La partición EFI se monta directamente en /boot para simplificar el arranque:
+# GRUB/systemd-boot leen kernel, initramfs y su configuración desde una única
+# partición FAT32 accesible sin cifrado. /boot/efi es simplemente el subdirectorio
+# EFI dentro de esa partición.
#
# Cuando encryption.enabled es true, p2/p3 se convierten a contenedores
# LUKS y el sistema de archivos btrfs vive dentro de /dev/mapper/neubat_*.
@@ -61,7 +66,7 @@ partition_disk() {
local fs_root="${p_root}" fs_home="${p_home}"
# Tamaños (en GiB) calculados con awk (bc no está garantizado en el ISO)
- local disk_gib swap_gib=4 root_gib home_end_gib
+ local disk_gib swap_gib=4 efi_gib=1 root_gib home_end_gib
disk_gib=$(blockdev --getsize64 "${DISK}" | awk '{printf "%d", $1/1073741824}')
if (( disk_gib < 32 )); then
@@ -69,13 +74,17 @@ partition_disk() {
fi
if (( disk_gib < 64 )); then
- root_gib=20
+ root_gib=19
else
- root_gib=30
+ root_gib=29
fi
home_end_gib=$(( disk_gib - swap_gib ))
- log "Disco: ${disk_gib} GiB | raíz: ${root_gib} GiB | swap: ${swap_gib} GiB"
+ # Límite en MiB para la partición EFI/boot (1 GiB = 1024 MiB)
+ local efi_end_mib=1025
+ local root_end_gib=$(( efi_gib + root_gib ))
+
+ log "Disco: ${disk_gib} GiB | EFI/boot: ${efi_gib} GiB | raíz: ${root_gib} GiB | swap: ${swap_gib} GiB"
# Limpiar sector de arranque y firmas previas
log "Limpiando tabla de particiones previa..."
@@ -86,18 +95,18 @@ partition_disk() {
log "Creando tabla de particiones GPT..."
parted -s "${DISK}" mklabel gpt
- # p1: EFI (512 MiB)
- log "Creando partición EFI..."
- parted -s "${DISK}" mkpart primary fat32 1MiB 513MiB
+ # p1: EFI + /boot (1 GiB, FAT32)
+ log "Creando partición EFI/boot (${efi_gib} GiB)..."
+ parted -s "${DISK}" mkpart primary fat32 1MiB "${efi_end_mib}MiB"
parted -s "${DISK}" set 1 esp on
# p2: raíz btrfs
log "Creando partición raíz (${root_gib} GiB)..."
- parted -s "${DISK}" mkpart primary btrfs 513MiB "${root_gib}GiB"
+ parted -s "${DISK}" mkpart primary btrfs "${efi_end_mib}MiB" "${root_end_gib}GiB"
# p3: home btrfs (hasta disco - swap)
log "Creando partición home (hasta ${home_end_gib} GiB)..."
- parted -s "${DISK}" mkpart primary btrfs "${root_gib}GiB" "${home_end_gib}GiB"
+ parted -s "${DISK}" mkpart primary btrfs "${root_end_gib}GiB" "${home_end_gib}GiB"
# p4: swap
log "Creando partición swap (${swap_gib} GiB)..."
@@ -107,7 +116,7 @@ partition_disk() {
partprobe "${DISK}" || true
sleep 2
- # Cifrado opcional de raíz y home
+ # Cifrado opcional de raíz y home (EFI/boot se mantiene en claro)
if [[ "${ENCRYPTION_ENABLED:-false}" == "true" ]]; then
if ! command -v cryptsetup &>/dev/null; then
error "cryptsetup no está disponible en el entorno live; necesario para LUKS"
@@ -130,11 +139,14 @@ partition_disk() {
mkswap "${p_swap}"
swapon "${p_swap}"
- # Montaje con opciones optimizadas para SSD
+ # Montaje: /boot es la partición EFI; /boot/efi es un subdirectorio.
+ # Esto simplifica el arranque porque GRUB lee /boot/grub desde la misma
+ # partición FAT32 donde vive el binario EFI.
log "Montando particiones..."
mount -o noatime,compress=zstd,space_cache=v2 "${fs_root}" /mnt
- mkdir -p /mnt/boot/efi /mnt/home
- mount "${p_efi}" /mnt/boot/efi
+ mkdir -p /mnt/boot /mnt/home
+ mount "${p_efi}" /mnt/boot
+ mkdir -p /mnt/boot/efi
mount -o noatime,compress=zstd,space_cache=v2 "${fs_home}" /mnt/home
success "Particionado completado"
diff --git a/scripts/20-archinstall.sh b/scripts/20-archinstall.sh
index 81351a8..ecc1472 100755
--- a/scripts/20-archinstall.sh
+++ b/scripts/20-archinstall.sh
@@ -38,6 +38,8 @@ fetch_configuration() {
# shellcheck disable=SC2034
PACKAGES=$(cfg_get "${NEUBAT_CONFIG_FILE}" packages "")
# shellcheck disable=SC2034
+ AUR_PACKAGES=$(cfg_get "${NEUBAT_CONFIG_FILE}" aur_packages "")
+ # shellcheck disable=SC2034
TIMEZONE=$(cfg_get "${NEUBAT_CONFIG_FILE}" timezone "Europe/Madrid")
# shellcheck disable=SC2034
LOCALE=$(cfg_get "${NEUBAT_CONFIG_FILE}" locale "es_ES.UTF-8")
@@ -104,6 +106,11 @@ secret = sys.argv[2].encode()
sig = cfg.pop('signature', None)
if sig is None:
sys.exit(2)
+def canonical_object(value):
+ if not isinstance(value, dict):
+ return ''
+ return ','.join(f'{k}={"" if value[k] is None else str(value[k])}' for k in sorted(value))
+
parts = [
str(cfg.get('token', '')),
str(cfg.get('machine_id', '')),
@@ -116,7 +123,9 @@ parts = [
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 [])
+ *(sorted(cfg.get('services', [])) if isinstance(cfg.get('services'), list) else []),
+ canonical_object(cfg.get('encryption')),
+ canonical_object(cfg.get('snapshots')),
]
payload = '|'.join(parts).encode()
expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
@@ -133,6 +142,35 @@ PYEOF
install_base_system() {
log "Instalando sistema base Arch Linux..."
+ # Exportar JSON de archinstall para auditoría y para el motor desatendido.
+ if [[ -f "${NEUBAT_CONFIG_FILE}" ]]; then
+ python3 - "${NEUBAT_CONFIG_FILE}" "${NEUBAT_WORKDIR}" <<'PYEOF' || warning "No se pudo exportar config archinstall"
+import json, sys
+cfg = json.load(open(sys.argv[1]))
+out = sys.argv[2]
+pair = cfg.get("archinstall")
+if not pair:
+ sys.exit(0)
+open(f"{out}/user_configuration.json", "w").write(json.dumps(pair.get("config", {}), indent=2))
+open(f"{out}/user_credentials.json", "w").write(json.dumps(pair.get("creds", {}), indent=2))
+print("archinstall configs written")
+PYEOF
+ fi
+
+ # Motor archinstall: solo si está en el live y NEUBAT_USE_ARCHINSTALL=1.
+ # En ese modo se asume que 10-partition aún no montó /mnt (ver neubat-install.sh).
+ if [[ "${NEUBAT_USE_ARCHINSTALL:-0}" == "1" ]] && command -v archinstall >/dev/null 2>&1 \
+ && [[ -f "${NEUBAT_WORKDIR}/user_configuration.json" ]]; then
+ log "Invocando archinstall --config/--creds (desatendido)..."
+ if archinstall --config "${NEUBAT_WORKDIR}/user_configuration.json" \
+ --creds "${NEUBAT_WORKDIR}/user_credentials.json" \
+ --silent; then
+ success "Sistema base instalado con archinstall"
+ return 0
+ fi
+ warning "archinstall falló; se continúa con pacstrap"
+ fi
+
# Optimizar mirrors (España y vecinos prioritarios)
log "Optimizando mirrors..."
reflector --country Spain,Germany,France \
@@ -142,12 +180,8 @@ install_base_system() {
--sort rate \
--save /etc/pacman.d/mirrorlist || warning "reflector falló; se usan los mirrors por defecto"
- # Paquetes esenciales
- log "Instalando paquetes base (esto puede tardar)..."
- # Paquetes base; cryptsetup es obligatorio si el perfil usa LUKS,
- # y se instala siempre para simplificar la lógica y poder reutilizar
- # el mismo ISO para instalaciones cifradas o no.
- pacstrap -K /mnt \
+ log "Instalando paquetes base con pacstrap..."
+ pacstrap -K /mnt --noconfirm \
base linux linux-firmware \
btrfs-progs \
cryptsetup \
@@ -160,9 +194,9 @@ install_base_system() {
neovim nano \
terminus-font \
openssh \
- ansible
+ ansible \
+ python-archinstall || true
- # fstab
log "Generando fstab..."
genfstab -U /mnt >> /mnt/etc/fstab
diff --git a/scripts/30-postinstall.sh b/scripts/30-postinstall.sh
index a9fb5f6..ac90203 100755
--- a/scripts/30-postinstall.sh
+++ b/scripts/30-postinstall.sh
@@ -12,33 +12,40 @@ configure_luks() {
log "Configurando cifrado LUKS para el arranque..."
- local p_root p_home root_uuid home_uuid
+ local p_root p_home
p_root=$(part_name "${DISK}" 2)
p_home=$(part_name "${DISK}" 3)
+ # shellcheck disable=SC2034
root_uuid=$(blkid -s UUID -o value "${p_root}")
+ # shellcheck disable=SC2034
home_uuid=$(blkid -s UUID -o value "${p_home}")
if [[ -z "${root_uuid}" || -z "${home_uuid}" ]]; then
error "No se pudo obtener el UUID de las particiones cifradas"
fi
- local keyfile_path=""
+ keyfile_path=""
if [[ "${ENCRYPTION_METHOD}" == "keyfile" ]]; then
if [[ -z "${LUKS_KEYFILE:-}" || ! -f "${LUKS_KEYFILE}" ]]; then
error "Método keyfile seleccionado pero no existe LUKS_KEYFILE"
fi
- cp "${LUKS_KEYFILE}" /mnt/boot/luks-keyfile
- chmod 0400 /mnt/boot/luks-keyfile
- keyfile_path="/boot/luks-keyfile"
- log "Keyfile LUKS copiado a /boot/luks-keyfile"
+ # systemd-cryptsetup busca automáticamente /etc/cryptsetup-keys.d/
.key
+ # tanto en el initramfs (para root) como en el sistema real (para home).
+ keyfile_path="/etc/cryptsetup-keys.d/neubat_root.key"
+ install -dm 0700 /mnt/etc/cryptsetup-keys.d
+ cp "${LUKS_KEYFILE}" /mnt/etc/cryptsetup-keys.d/neubat_root.key
+ cp "${LUKS_KEYFILE}" /mnt/etc/cryptsetup-keys.d/neubat_home.key
+ chmod 0400 /mnt/etc/cryptsetup-keys.d/neubat_root.key /mnt/etc/cryptsetup-keys.d/neubat_home.key
+ log "Keyfiles LUKS copiados a /etc/cryptsetup-keys.d/"
fi
# crypttab: systemd-cryptsetup abrirá home tras el initramfs;
- # neubat_root debe abrirse en el initramfs vía el hook encrypt.
+ # neubat_root se abre en el initramfs gracias a rd.luks.name y la
+ # ubicación automática /etc/cryptsetup-keys.d/neubat_root.key.
{
if [[ -n "${keyfile_path}" ]]; then
printf "neubat_root UUID=%s %s luks\n" "${root_uuid}" "${keyfile_path}"
- printf "neubat_home UUID=%s %s luks\n" "${home_uuid}" "${keyfile_path}"
+ printf "neubat_home UUID=%s %s luks\n" "${home_uuid}" "/etc/cryptsetup-keys.d/neubat_home.key"
else
printf "neubat_root UUID=%s none luks\n" "${root_uuid}"
printf "neubat_home UUID=%s none luks\n" "${home_uuid}"
@@ -46,18 +53,32 @@ configure_luks() {
} > /mnt/etc/crypttab
chmod 0600 /mnt/etc/crypttab
- # Añadir hook encrypt antes de filesystems en mkinitcpio.conf
+ # Añadir hook de cifrado en mkinitcpio.conf.
+ # El ISO actual de Arch usa el hook systemd en lugar de udev; en ese
+ # caso el hook correspondiente es sd-encrypt. Si no, usamos encrypt.
+ # Si usamos keyfile, empaquetarlo en el initramfs para que el hook
+ # pueda abrir los contenedores sin montar /boot previamente.
if [[ -f /mnt/etc/mkinitcpio.conf ]]; then
- if grep -q 'HOOKS=.*filesystems' /mnt/etc/mkinitcpio.conf; then
- sed -i 's/\(filesystems\)/encrypt \1/' /mnt/etc/mkinitcpio.conf
+ local encrypt_hook="encrypt"
+ if grep -qE '^HOOKS=.*\bsystemd\b' /mnt/etc/mkinitcpio.conf; then
+ encrypt_hook="sd-encrypt"
+ fi
+
+ if grep -qE '^HOOKS=.*\bfilesystems\b' /mnt/etc/mkinitcpio.conf; then
+ sed -i "s/\(filesystems\)/${encrypt_hook} \1/" /mnt/etc/mkinitcpio.conf
+ log "Hook de cifrado añadido: ${encrypt_hook}"
else
- warning "No se encontró 'filesystems' en HOOKS; añade 'encrypt' manualmente a mkinitcpio.conf"
+ warning "No se encontró 'filesystems' en HOOKS; añade '${encrypt_hook}' manualmente a mkinitcpio.conf"
fi
- fi
- # GRUB: indicar al hook encrypt qué dispositivo abrir
- if [[ -f /mnt/etc/default/grub ]]; then
- sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=\"|GRUB_CMDLINE_LINUX_DEFAULT=\"cryptdevice=UUID=${root_uuid}:neubat_root |" /mnt/etc/default/grub
+ if [[ -n "${keyfile_path}" ]]; then
+ if grep -q '^FILES=' /mnt/etc/mkinitcpio.conf; then
+ sed -i "s|^FILES=(|FILES=(${keyfile_path} /etc/cryptsetup-keys.d/neubat_home.key |" /mnt/etc/mkinitcpio.conf
+ else
+ echo "FILES=(${keyfile_path} /etc/cryptsetup-keys.d/neubat_home.key)" >> /mnt/etc/mkinitcpio.conf
+ fi
+ log "Keyfiles empaquetados en initramfs"
+ fi
fi
success "Configuración LUKS preparada"
@@ -72,6 +93,18 @@ configure_system() {
# Preparar LUKS antes de entrar al chroot para que mkinitcpio lo vea
configure_luks
+ # Opciones del kernel: LUKS (si aplica) + root + parámetros básicos.
+ # Se calculan en el entorno live porque el heredoc del chroot expande
+ # las variables antes de ejecutarse (set -u en neubat-install.sh).
+ luks_options=""
+ root_device=""
+ if [[ "${ENCRYPTION_ENABLED:-false}" == "true" ]]; then
+ luks_options="rd.luks.name=${root_uuid}=neubat_root"
+ root_device="/dev/mapper/neubat_root"
+ else
+ root_device="$(part_name "${DISK}" 2)"
+ fi
+
# NOTA: el heredoc usa EOF sin comillas a propósito: las variables
# (HOSTNAME, USERNAME, etc.) se expanden en el entorno live antes de
# entrar al chroot.
@@ -117,19 +150,38 @@ chmod 440 /etc/sudoers.d/neubat
# Initramfs (ya preparado con hooks/crypttab si LUKS está activo)
mkinitcpio -P
-# GRUB (UEFI)
-grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=NEUBAT
-grub-mkconfig -o /boot/grub/grub.cfg
+# Bootloader UEFI (systemd-boot). La partición EFI está montada en /boot,
+# por lo que bootctl instala el loader directamente en /boot/EFI.
+bootctl install --esp-path=/boot
+mkdir -p /boot/EFI/NEUBAT
+cp /boot/vmlinuz-linux /boot/EFI/NEUBAT/vmlinuz-linux
+cp /boot/initramfs-linux.img /boot/EFI/NEUBAT/initramfs-linux.img
+cp /boot/initramfs-linux-fallback.img /boot/EFI/NEUBAT/initramfs-linux-fallback.img 2>/dev/null || true
+
+# Opciones del kernel: LUKS (si aplica) + root + parámetros básicos
+cat > /boot/loader/loader.conf <<'LOADER'
+default neubat.conf
+timeout 3
+console-mode max
+LOADER
+
+cat > /boot/loader/entries/neubat.conf <<'ENTRYEOF'
+title NEUBAT Arch Linux
+linux /EFI/NEUBAT/vmlinuz-linux
+initrd /EFI/NEUBAT/initramfs-linux.img
+options ENTRY_LUKS_OPTIONS root=ENTRY_ROOT_DEVICE rw console=ttyS0
+ENTRYEOF
+sed -i "s|ENTRY_LUKS_OPTIONS|${luks_options}|; s|ENTRY_ROOT_DEVICE|${root_device}|" /boot/loader/entries/neubat.conf
# Servicios
systemctl enable NetworkManager
systemctl enable sshd
-# AUR helper (yay) - no crítico: un fallo no aborta la instalación
+# AUR helper (yay-bin) - no crítico: un fallo no aborta la instalación
su - ${USERNAME} -c '
set -e
cd /tmp
- git clone https://aur.archlinux.org/yay.git
+ git clone https://aur.archlinux.org/yay-bin.git yay
cd yay
makepkg -si --noconfirm
' || echo "[WARNING] La construcción de yay falló; instálalo manualmente tras el primer arranque"
@@ -158,6 +210,18 @@ install_applications() {
"xfce")
desktop_packages="xfce4 xfce4-goodies lightdm lightdm-gtk-greeter"
;;
+ "hyprland")
+ desktop_packages="hyprland waybar kitty xdg-desktop-portal-hyprland"
+ ;;
+ "sway")
+ desktop_packages="sway swaybg swaylock waybar foot"
+ ;;
+ "i3")
+ desktop_packages="i3-wm i3status i3lock dmenu"
+ ;;
+ "niri")
+ desktop_packages="niri waybar alacritty xdg-desktop-portal-gnome"
+ ;;
"none"|"minimal"|"")
desktop_packages=""
;;
@@ -167,7 +231,6 @@ install_applications() {
esac
if [[ -n "${desktop_packages}" ]]; then
- # Expansión sin comillas intencionada: lista de paquetes separada por espacios
# shellcheck disable=SC2086
arch-chroot /mnt pacman -S --noconfirm --needed ${desktop_packages}
@@ -178,12 +241,19 @@ install_applications() {
esac
fi
- # Paquetes de la configuración (ya incluyen los de infraestructura NEUBAT)
+ # Paquetes oficiales (repo). Los AUR van en aur_packages.
if [[ -n "${PACKAGES}" ]]; then
- # Expansión sin comillas intencionada: lista de paquetes separada por espacios
# shellcheck disable=SC2086
arch-chroot /mnt pacman -S --noconfirm --needed ${PACKAGES} \
- || warning "Algunos paquetes personalizados fallaron (¿paquetes AUR en la lista?)"
+ || warning "Algunos paquetes del repositorio fallaron"
+ fi
+
+ # AUR allowlist (yay ya instalado en configure_system)
+ if [[ -n "${AUR_PACKAGES:-}" ]]; then
+ log "Instalando paquetes AUR allowlist: ${AUR_PACKAGES}"
+ # shellcheck disable=SC2086
+ arch-chroot /mnt sudo -u "${USERNAME}" yay -S --noconfirm --needed ${AUR_PACKAGES} \
+ || warning "Algunos paquetes AUR fallaron"
fi
# Habilitar servicios declarados en la configuración
diff --git a/scripts/35-snapper.sh b/scripts/35-snapper.sh
index 80cb72f..075dace 100644
--- a/scripts/35-snapper.sh
+++ b/scripts/35-snapper.sh
@@ -15,11 +15,11 @@ configure_snapper() {
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 / \
+ # Crear configuraciones de snapper. En un chroot no hay bus D-Bus disponible;
+ # --no-dbus evita el error org.freedesktop.DBus.Error.ServiceUnknown.
+ arch-chroot /mnt snapper --no-dbus -c root create-config / \
|| warning "No se pudo crear configuración snapper para /"
- arch-chroot /mnt snapper -c home create-config /home \
+ arch-chroot /mnt snapper --no-dbus -c home create-config /home \
|| warning "No se pudo crear configuración snapper para /home"
# Aplicar límites de retención desde el perfil
diff --git a/scripts/neubat-absorb.sh b/scripts/neubat-absorb.sh
new file mode 100755
index 0000000..821e022
--- /dev/null
+++ b/scripts/neubat-absorb.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+# NEUBAT — Absorbe paquetes y dotfiles allowlist del sistema actual.
+# Uso:
+# ./scripts/neubat-absorb.sh --code --portal http://localhost:3000
+set -euo pipefail
+
+PORTAL=""
+CODE=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --portal) PORTAL="$2"; shift 2 ;;
+ --code) CODE="$2"; shift 2 ;;
+ -h|--help)
+ echo "Uso: $0 --code --portal "
+ exit 0
+ ;;
+ *) echo "Opción desconocida: $1"; exit 1 ;;
+ esac
+done
+
+if [[ -z "$PORTAL" || -z "$CODE" ]]; then
+ echo "Faltan --portal o --code" >&2
+ exit 1
+fi
+
+if ! command -v pacman >/dev/null 2>&1; then
+ echo "Este agente requiere pacman (Arch o derivado)." >&2
+ exit 1
+fi
+
+DESKTOP="none"
+if [[ -n "${XDG_CURRENT_DESKTOP:-}" ]]; then
+ DESKTOP=$(echo "$XDG_CURRENT_DESKTOP" | tr '[:upper:]' '[:lower:]' | cut -d: -f1)
+elif [[ -n "${DESKTOP_SESSION:-}" ]]; then
+ DESKTOP=$(echo "$DESKTOP_SESSION" | tr '[:upper:]' '[:lower:]')
+fi
+
+LOCALE=$(grep -E '^LANG=' /etc/locale.conf 2>/dev/null | cut -d= -f2 || echo "es_ES.UTF-8")
+KEYBOARD=$(localectl status 2>/dev/null | awk -F: '/VC Keymap/ {gsub(/ /,"",$2); print $2}' || echo "es")
+TIMEZONE=$(timedatectl show -p Timezone --value 2>/dev/null || echo "Europe/Madrid")
+
+export CODE DESKTOP LOCALE KEYBOARD TIMEZONE
+PAYLOAD=$(python3 <<'PY'
+import json, os, pathlib, subprocess
+packages = []
+try:
+ out = subprocess.check_output(["pacman", "-Qe"], text=True)
+ packages = [line.split()[0] for line in out.splitlines() if line.strip()]
+except Exception:
+ pass
+allow = [
+ ".bashrc", ".zshrc", ".profile", ".config/hypr", ".config/sway",
+ ".config/i3", ".config/niri", ".config/kitty", ".config/foot",
+ ".config/waybar", ".config/gtk-3.0", ".config/gtk-4.0",
+]
+home = pathlib.Path.home()
+dotfiles = [p for p in allow if (home / p).exists()]
+print(json.dumps({
+ "code": os.environ["CODE"],
+ "inventory": {
+ "packages": packages,
+ "desktop": os.environ.get("DESKTOP", "none"),
+ "locale": os.environ.get("LOCALE", "es_ES.UTF-8"),
+ "keyboard": os.environ.get("KEYBOARD", "es"),
+ "timezone": os.environ.get("TIMEZONE", "Europe/Madrid"),
+ "dotfiles": dotfiles,
+ },
+}))
+PY
+)
+
+echo "Enviando inventario a ${PORTAL}/api/account/absorb …"
+curl -sf -X POST "${PORTAL}/api/account/absorb" \
+ -H 'Content-Type: application/json' \
+ -d "${PAYLOAD}"
+echo
+echo "Listo. Confirma la copia en ${PORTAL}/cuenta"
diff --git a/scripts/neubat-install.sh b/scripts/neubat-install.sh
index e7818bd..1961fb3 100755
--- a/scripts/neubat-install.sh
+++ b/scripts/neubat-install.sh
@@ -112,7 +112,8 @@ finalize_installation() {
# -----------------------------------------------------------------------------
main() {
- clear
+ # No limpiar la pantalla en salida no interactiva (consola serie/VM)
+ [[ -t 1 ]] && clear || true
echo "═══════════════════════════════════════════════════════════════"
echo " NEUBAT v${NEUBAT_VERSION} - Instalador Desatendido Arch Linux "
echo "═══════════════════════════════════════════════════════════════"
diff --git a/tests/vm/neubat_vm_test.py b/tests/vm/neubat_vm_test.py
index 4e6ce68..4298130 100644
--- a/tests/vm/neubat_vm_test.py
+++ b/tests/vm/neubat_vm_test.py
@@ -26,6 +26,7 @@
"""
import json
import os
+import shlex
import shutil
import signal
import subprocess
@@ -95,9 +96,10 @@ def prepare_workdir():
subprocess.run(["qemu-img", "create", "-f", "qcow2",
os.path.join(VM, "neubat-disk.qcow2"), f"{DISK_GIB}G"],
check=True, capture_output=True)
- # Tarball del repo para descargarlo dentro de la VM
+ # Tarball del repo para descargarlo dentro de la VM (sin ISO ni dependencias)
subprocess.run(["tar", "--exclude=.git", "--exclude=node_modules",
"--exclude=portal/data", "--exclude=portal/configs/generated",
+ "--exclude=out",
"-czf", os.path.join(VM, "neubat.tar.gz"),
"-C", os.path.dirname(REPO), os.path.basename(REPO)],
check=True)
@@ -124,7 +126,7 @@ def phase1_install(token):
"-cdrom", ISO,
"-kernel", f"{VM}/vmlinuz-linux",
"-initrd", f"{VM}/initramfs-linux.img",
- "-append", f"archisobasedir=arch archisolabel={label} console=ttyS0",
+ "-append", f"archisobasedir=arch archisolabel={label} console=ttyS0 cow_spacesize=2G",
# sin ip=dhcp: el hook net buscaría eth0 (ver docs/INSTALL.md §11)
])
vm = pexpect.spawn(cmd[0], cmd[1:], encoding=None, timeout=300)
@@ -144,38 +146,71 @@ def phase1_install(token):
vm.expect(rb"FILES_OK", timeout=60)
vm.expect(rb"@archiso.{0,80}#")
- # Ajustes del entorno live para red slirp lenta (no forman parte del repo)
- vm.sendline(b"sed -i 's/--sort rate/--sort rate --download-timeout 30/' "
- b"/root/neubat/scripts/20-archinstall.sh && "
- b"sed -i 's/^ParallelDownloads.*/ParallelDownloads = 1/' /etc/pacman.conf "
- b"&& echo TWEAKS_OK")
+ # Ajustes del entorno live para red slirp lenta (no forman parte del repo).
+ # Usamos un caché nginx local si está disponible en el host (puerto 8090).
+ cache_url = f"http://{GATEWAY}:8090/$repo/os/$arch"
+ tweaks_cmd = (
+ "systemctl stop reflector.service reflector.timer 2>/dev/null; "
+ "rm -f /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist.orig; "
+ f"echo 'Server = {cache_url}' > /etc/pacman.d/mirrorlist && "
+ "cat /etc/pacman.d/mirrorlist && "
+ "grep -q '^DisableDownloadTimeout' /etc/pacman.conf || "
+ "echo 'DisableDownloadTimeout' >> /etc/pacman.conf && "
+ f"sed -i 's|^reflector|#reflector|' /root/neubat/scripts/20-archinstall.sh && "
+ "sed -i 's/^ParallelDownloads.*/ParallelDownloads = 5/' /etc/pacman.conf && "
+ "echo TWEAKS_OK"
+ )
+ vm.sendline(tweaks_cmd.encode())
vm.expect(rb"TWEAKS_OK", timeout=30)
vm.expect(rb"@archiso.{0,80}#")
log("Lanzando neubat-install.sh (esto tarda: pacstrap ~1 GiB)")
- install = (f"export NEUBAT_PORTAL_URL=http://{GATEWAY}:{PORTAL_PORT} NEUBAT_ASSUME_YES=true; "
- f"bash /root/neubat/scripts/neubat-install.sh {token} {PROFILE} 2>&1 | "
- f"tee /root/install.log; echo INSTALL_EXIT=${{PIPESTATUS[0]}}")
+ hmac_secret = os.environ.get("NEUBAT_HMAC_SECRET", "")
+ hmac_export = f"NEUBAT_HMAC_SECRET='{hmac_secret}' " if hmac_secret else ""
+ # El shell del live puede no ser bash; envolvemos en bash -c con pipefail
+ # para capturar correctamente el exit code del instalador.
+ inner = (
+ f"set -o pipefail; "
+ f"export {hmac_export}NEUBAT_PORTAL_URL=http://{GATEWAY}:{PORTAL_PORT} NEUBAT_ASSUME_YES=true; "
+ f"bash /root/neubat/scripts/neubat-install.sh {token} {PROFILE} 2>&1 | tee /root/install.log; "
+ f"echo INSTALL_EXIT=$?"
+ )
+ install = f"bash -c {shlex.quote(inner)}"
vm.sendline(install.encode())
# NOTA: el marcador usa patrones ASCII puros; «Ó» UTF-8 son 2 bytes y
# rompería un patrón con un solo comodín.
deadline = time.time() + 9600
ok = False
+ install_failed_marker = None
while time.time() < deadline:
+ # Timeout largo: pacstrap/npm pueden estar minutos sin emitir novedades
i = vm.expect([rb"NEUBAT COMPLETADA", rb"INSTALL_EXIT=\d+",
- rb"\[ERROR\]", pexpect.TIMEOUT], timeout=120)
+ rb"\[ERROR\]", pexpect.TIMEOUT], timeout=300)
if i == 0:
ok = True
break
if i == 1:
ok = b"INSTALL_EXIT=0" in vm.after
+ install_failed_marker = vm.after
break
if i == 2:
log("ERROR en la salida del instalador")
+ install_failed_marker = b"ERROR"
break
log("...instalación en curso...")
+ if not ok and install_failed_marker is not None:
+ log("Volcando logs del instalador para diagnóstico...")
+ for log_cmd in (
+ b"echo '--- /root/install.log ---'",
+ b"cat /root/install.log 2>/dev/null || echo 'NO /root/install.log'",
+ b"echo '--- /var/log/neubat-install.log ---'",
+ b"cat /var/log/neubat-install.log 2>/dev/null || echo 'NO /var/log/neubat-install.log'",
+ ):
+ vm.sendline(log_cmd)
+ vm.expect(rb"@archiso.{0,80}#", timeout=60)
+
time.sleep(12) # margen para el reboot del instalador
vm.close(force=True)
if not ok:
@@ -188,48 +223,78 @@ def phase2_verify():
vm = pexpect.spawn(qemu_cmd([])[0], qemu_cmd([])[1:], encoding=None, timeout=300)
vm.logfile = CONSOLE
- ssh = None
- for attempt in range(16):
- time.sleep(15)
- log(f"Intento SSH {attempt + 1}/16")
- s = pexpect.spawn("ssh", [
- "-p", str(SSH_PORT), "-o", "StrictHostKeyChecking=no",
- "-o", "UserKnownHostsFile=/dev/null", "-o", "PubkeyAuthentication=no",
- "-o", "ConnectTimeout=10", "-o", "NumberOfPasswordPrompts=1",
- "neubat@localhost"], encoding="utf-8", timeout=40)
- if s.expect(["assword:", pexpect.EOF, pexpect.TIMEOUT]) == 0:
- s.sendline("neubat")
- if s.expect(["\\$", pexpect.TIMEOUT]) == 0:
- ssh = s
- break
- s.close()
-
- if not ssh:
+ # Esperar a que el sistema arranque y SSH esté disponible. El marcador
+ # "SSH Access Available" aparece cuando sshd ha arrancado; si no, caemos
+ # al prompt de login en 3 minutos como salvaguarda.
+ log("Esperando arranque del sistema instalado...")
+ i = vm.expect([rb"SSH Access Available", rb"login:", pexpect.TIMEOUT], timeout=180)
+ if i == 2:
+ vm.close(force=True)
+ sys.exit("FALLO: la VM no arrancó en el tiempo esperado")
+ log("Sistema arrancado; intentando SSH")
+
+ ssh_ok = False
+ ssh_cmd_base = [
+ "sshpass", "-p", "neubat",
+ "ssh",
+ "-p", str(SSH_PORT),
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "PasswordAuthentication=yes",
+ "-o", "PreferredAuthentications=password",
+ "-o", "ConnectTimeout=10",
+ "neubat@localhost",
+ ]
+
+ for attempt in range(20):
+ if attempt > 0:
+ time.sleep(5)
+ log(f"Intento SSH {attempt + 1}/20")
+ result = subprocess.run(
+ ssh_cmd_base + ["echo SSH_OK"],
+ capture_output=True, text=True, timeout=30)
+ if result.returncode == 0 and "SSH_OK" in result.stdout:
+ ssh_ok = True
+ break
+ log(f" stderr: {result.stderr.strip()[:200]}")
+
+ if not ssh_ok:
vm.close(force=True)
sys.exit("FALLO: SSH no disponible en el sistema instalado")
checks = [
"cat /etc/hostname",
- "sudo cat /etc/neubat-release",
+ "echo neubat | sudo -S cat /etc/neubat-release",
"systemctl is-active neubat-portal NetworkManager sshd",
"curl -sf --max-time 5 http://localhost:3000/api/health; echo",
"cat ~/NEUBAT-URL.txt",
"df -h / | tail -1",
"lsblk -d -o NAME,SIZE,TRAN | grep nvme",
"sudo -l -U $(whoami) | grep -q NOPASSWD && echo SUDO_INSEGURO || echo SUDO_OK",
- "echo VERIFY_DONE",
+ "echo neubat | sudo -S cryptsetup status neubat_root | head -5",
+ "echo neubat | sudo -S cat /etc/crypttab",
+ "echo neubat | sudo -S snapper -c root list | head -5",
+ "echo neubat | sudo -S systemctl is-enabled snapper-timeline.timer snapper-cleanup.timer",
]
- for c in checks:
- ssh.sendline(c)
- ssh.expect("\\$", timeout=40)
- time.sleep(0.3)
- ssh.expect("VERIFY_DONE", timeout=60)
+ log("Ejecutando verificaciones por SSH")
+ script = "; ".join([f"echo '--- {c} ---'; {c}" for c in checks])
+ script += "; echo VERIFY_DONE"
+ result = subprocess.run(
+ ssh_cmd_base + [script],
+ capture_output=True, text=True, timeout=120)
+
print("\n===== SALIDA DE VERIFICACIÓN =====")
- print(ssh.before)
+ print(result.stdout)
+ if result.stderr:
+ print("----- STDERR -----")
+ print(result.stderr)
print("==================================")
- ssh.sendline("exit")
- ssh.close()
+
+ if result.returncode != 0 or "VERIFY_DONE" not in result.stdout:
+ vm.close(force=True)
+ sys.exit("FALLO: verificación SSH incompleta")
+
vm.close(force=True)
log("FASE 2 OK: sistema instalado verificado")
print("RESULT=PASS")
@@ -237,6 +302,7 @@ def phase2_verify():
def main():
global CONSOLE
+ os.makedirs(VM, exist_ok=True)
CONSOLE = open(os.path.join(VM, "console.log"), "wb")
token = create_installation()
prepare_workdir()