sawa-control-panel/backend/services/appInstaller.js

301 lines
9.2 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const vhostService = require('./vhostService');
const RECIPES_DIR = path.join(__dirname, '../recipes');
const INSTALLED_FILE = '/var/lib/sawa/installed.json';
// Ensure data directory exists
if (!fs.existsSync(path.dirname(INSTALLED_FILE))) {
try {
execFileSync('mkdir', ['-p', path.dirname(INSTALLED_FILE)]);
execFileSync('chown', ['root:root', path.dirname(INSTALLED_FILE)]);
} catch (e) {
console.error('Failed to prepare /var/lib/sawa:', e.message);
}
}
/**
* Validates identifiers for security (dbName, dbUser, recipeId).
*/
const validateIdentifier = (str) => {
if (!/^[a-zA-Z0-9_-]+$/.test(str)) {
throw new Error(`Invalid identifier: ${str}`);
}
};
/**
* Validates password to prevent issues with quoting.
*/
const validatePassword = (str) => {
// We escape single quotes now, but still good to avoid some patterns if necessary.
// For now, removing the $$ restriction since we use stdin and single quote escaping.
};
/**
* Escapes single quotes for standard SQL.
*/
const escapeSql = (str) => str.replace(/'/g, "''");
/**
* Loads all recipe.json files from the recipes directory.
*/
const loadRecipes = () => {
try {
if (!fs.existsSync(RECIPES_DIR)) return [];
const folders = fs.readdirSync(RECIPES_DIR);
return folders.map(folder => {
const recipePath = path.join(RECIPES_DIR, folder, 'recipe.json');
if (fs.existsSync(recipePath)) {
return JSON.parse(fs.readFileSync(recipePath, 'utf-8'));
}
return null;
}).filter(Boolean);
} catch (err) {
console.error('Failed to load recipes:', err);
return [];
}
};
/**
* Reads the status of installed applications.
*/
const getInstalledApps = () => {
try {
if (!fs.existsSync(INSTALLED_FILE)) return {};
return JSON.parse(fs.readFileSync(INSTALLED_FILE, 'utf-8'));
} catch (err) {
return {};
}
};
/**
* Executes installation for a specific recipe.
* Shared logic for both standard and streaming installs.
*/
const installApp = async (recipeId, options, onLog = null) => {
validateIdentifier(recipeId);
const logs = [];
const log = (msg) => {
const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
logs.push(line);
if (onLog) onLog(line);
console.log(`[Installer:${recipeId}] ${line}`);
return line;
};
try {
const recipes = loadRecipes();
const recipe = recipes.find(r => r.id === recipeId);
if (!recipe) throw new Error(`Recipe ${recipeId} not found`);
log(`Starting installation of ${recipe.name} v${recipe.version}...`);
// Clean start: remove existing installDir if any
if (recipe.installDir) {
log(`Cleaning up existing directory: ${recipe.installDir}...`);
try { execFileSync('rm', ['-rf', recipe.installDir]); } catch (e) { }
}
// 0. Dependencies
if (recipe.dependencies) {
await _stepDependencies(recipe, log);
}
// 1. Fetch
await _stepFetch(recipe, log);
// 2. Database
if (recipe.database) {
validateIdentifier(recipe.database.dbName);
validateIdentifier(recipe.database.dbUser);
await _stepDatabase(recipe.database, options.dbPassword, log);
}
// 3. Custom Steps
if (recipe.steps) {
await _stepCustom(recipe, options, options.dbPassword, log);
}
// 4. Service (PM2)
if (recipe.service) {
await _stepService(recipe.service, recipe.installDir, log);
}
// 5. VHost
if (recipe.vhost) {
await _stepVHost(recipe, options, log);
}
// Finalize
const installed = getInstalledApps();
installed[recipeId] = {
version: recipe.version,
installedAt: new Date().toISOString(),
domain: options.domain
};
fs.writeFileSync(INSTALLED_FILE, JSON.stringify(installed, null, 2));
log(`Successfully installed ${recipe.name}!`);
return { success: true, logs };
} catch (err) {
log(`FATAL ERROR: ${err.message}`);
return { success: false, logs };
}
};
/**
* SSE-friendly wrapper for installation.
*/
const streamInstall = async (recipeId, options, onLog) => {
return await installApp(recipeId, options, onLog);
};
// --- PRIVATE STEP HELPERS ---
const _stepDependencies = async (recipe, log) => {
const { dependencies } = recipe;
if (!dependencies || !Array.isArray(dependencies)) return;
log(`Installing dependencies: ${dependencies.join(', ')}...`);
for (const item of dependencies) {
log(`Adding package: ${item}...`);
execFileSync('apk', ['add', item]);
}
log('Dependencies installed.');
};
const _stepFetch = async (recipe, log) => {
const { fetch, version, installDir } = recipe;
log(`Preparing directory: ${installDir}...`);
// Panel runs as root, no sudo needed
execFileSync('mkdir', ['-p', installDir]);
execFileSync('chown', ['root:root', installDir]);
if (fetch.type === 'none') {
log('No fetch required, skipping...');
return;
}
if (fetch.type === 'binary') {
const url = fetch.url.replace(/{version}/g, version);
const dest = path.join(installDir, fetch.filename);
log(`Downloading binary...`);
execFileSync('curl', ['-L', '-o', dest, url]);
if (fetch.chmod) {
execFileSync('chmod', [fetch.chmod, dest]);
}
}
log('Fetch completed.');
};
const _stepDatabase = async (dbConfig, password, log) => {
const { type, dbName, dbUser } = dbConfig;
log(`Provisioning ${type} database: ${dbName}...`);
if (type === 'postgres') {
const escapedPassword = escapeSql(password);
// Idempotent Cleanup
const cleanupSql =
`DROP DATABASE IF EXISTS ${dbName};\n` +
`DROP USER IF EXISTS ${dbUser};\n`;
const createSql =
`CREATE USER ${dbUser} WITH PASSWORD '${escapedPassword}';\n` +
`CREATE DATABASE ${dbName} OWNER ${dbUser};\n`;
try {
log(`Cleaning up existing database/user if any...`);
execFileSync('sudo', ['-u', 'postgres', 'psql'], {
input: cleanupSql,
env: { ...process.env }
});
log(`Creating database and user...`);
execFileSync('sudo', ['-u', 'postgres', 'psql'], {
input: createSql,
env: { ...process.env }
});
} catch (e) {
log(`Database note: ${e.message.split('\n')[0]}`);
}
} else if (type === 'mariadb') {
// MariaDB idempotency
execFileSync('mysql', ['-e', `DROP DATABASE IF EXISTS ${dbName};`]);
execFileSync('mysql', ['-e', `DROP USER IF EXISTS '${dbUser}'@'localhost';`]);
execFileSync('mysql', ['-e', `CREATE DATABASE ${dbName};`]);
execFileSync('mysql', ['-e', `CREATE USER '${dbUser}'@'localhost' IDENTIFIED BY '${password}';`]);
execFileSync('mysql', ['-e', `GRANT ALL PRIVILEGES ON ${dbName}.* TO '${dbUser}'@'localhost';`]);
execFileSync('mysql', ['-e', 'FLUSH PRIVILEGES;']);
}
log('Database provisioning completed.');
};
const _stepCustom = async (recipe, options, password, log) => {
const { steps, installDir } = recipe;
for (const step of steps) {
log(`Executing: ${step.name}...`);
execFileSync('sh', ['-c', step.cmd], {
cwd: installDir,
env: {
...process.env,
SAWA_DB_PASSWORD: password,
SAWA_DOMAIN: options.domain,
SAWA_RECIPE_DIR: path.join(RECIPES_DIR, recipe.id)
}
});
}
log('Custom steps completed.');
};
const _stepService = async (serviceConfig, installDir, log) => {
const { type, name, command, user } = serviceConfig;
log(`Registering ${type} service: ${name}...`);
if (type === 'pm2') {
try { execFileSync('pm2', ['delete', name]); } catch (e) { }
const args = ['start', command, '--name', name];
if (user) {
args.push('--user', user);
}
execFileSync('pm2', args, { cwd: installDir });
execFileSync('pm2', ['save']);
}
log('Service registration completed.');
};
const _stepVHost = async (recipe, options, log) => {
const { vhost, installDir } = recipe;
const { domain } = options;
const port = options.port || vhost.proxyPort || 80;
log(`Configuring Nginx vhost for ${domain}...`);
const type = vhost.type === 'proxy' ? 'proxy' : 'static';
const rootOrUrl = type === 'proxy' ? `http://127.0.0.1:${port}` : installDir;
// createVHost returns filename like "domain.conf.disabled"
const filename = await vhostService.createVHost(domain, rootOrUrl, port, type);
// Enable and reload
await vhostService.toggleVHost(filename, true);
await vhostService.reloadNginx();
log('Nginx vhost configured, enabled and reloaded.');
};
module.exports = {
loadRecipes,
getInstalledApps,
installApp,
streamInstall
};