feat: découverte dynamique apps - portail dynamique - fix URLs

This commit is contained in:
Manus Deploy
2026-07-24 13:05:23 +00:00
parent a65806125a
commit 765ef94fbd
42 changed files with 7150 additions and 14 deletions

30
backend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "manus-dashboard-backend",
"version": "1.0.0",
"description": "Backend API for Manus Dashboard",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"express": "^4.21.0",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"axios": "^1.7.0",
"dockerode": "^4.0.2",
"ws": "^8.18.0",
"node-cron": "^3.0.3",
"express-rate-limit": "^7.4.0",
"cookie-parser": "^1.4.6",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"adm-zip": "^0.5.10",
"ssh2": "^1.16.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

710
backend/src/app-creator.js Normal file
View File

@@ -0,0 +1,710 @@
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { exec, execSync } = require('child_process');
const config = require('./config');
const { createRepo, giteaClient } = require('./gitea');
// ============ TEMPLATES DOCKERFILE ============
const DOCKERFILE_TEMPLATES = {
nodejs: (port) => `# ── Stage 1: Build ──────────────────────────────────────────────
FROM node:22-slim AS builder
WORKDIR /app
# Copy lockfiles for better layer caching
COPY package.json pnpm-lock.yaml* yarn.lock* ./
# Copy patches directory if it exists (needed by pnpm before install)
COPY patches/ ./patches/
# Detect package manager and install ALL dependencies (including devDependencies for build)
RUN if [ -f pnpm-lock.yaml ]; then \\
corepack enable && corepack prepare pnpm@latest --activate && \\
pnpm install --no-frozen-lockfile; \\
elif [ -f yarn.lock ]; then \\
yarn install; \\
else \\
npm install --legacy-peer-deps; \\
fi
# Copy source code
COPY . .
# Build the application (frontend + backend if applicable)
RUN if [ -f package.json ] && grep -q '"build"' package.json; then \\
if [ -f pnpm-lock.yaml ]; then \\
pnpm run build; \\
elif [ -f yarn.lock ]; then \\
yarn build; \\
else \\
npm run build; \\
fi; \\
fi
# ── Stage 2: Production ─────────────────────────────────────────
FROM node:22-slim
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml* yarn.lock* ./
# Copy patches directory if it exists (needed by pnpm for install)
COPY patches/ ./patches/
# Install ALL dependencies (some projects import dev deps at runtime like vite)
RUN if [ -f pnpm-lock.yaml ]; then \\
corepack enable && corepack prepare pnpm@latest --activate && \\
pnpm install --no-frozen-lockfile; \\
elif [ -f yarn.lock ]; then \\
yarn install; \\
else \\
npm install --legacy-peer-deps; \\
fi
# Copy built artifacts from builder
COPY --from=builder /app/dist ./dist
# Copy drizzle migrations if they exist
COPY drizzle/ ./drizzle/
COPY drizzle.config.ts* ./
ENV NODE_ENV=production
ENV PORT=\${port}
EXPOSE \${port}
# Start the application
CMD if grep -q '"start"' package.json; then \\
npm start; \\
elif [ -f dist/index.js ]; then \\
node dist/index.js; \\
else \\
node index.js; \\
fi
`,
'nodejs-next': (port) => `# ── Stage 1: Build ──────────────────────────────────────────────
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* yarn.lock* ./
RUN if [ -f pnpm-lock.yaml ]; then \\
corepack enable && corepack prepare pnpm@latest --activate && \\
pnpm install --no-frozen-lockfile; \\
elif [ -f yarn.lock ]; then \\
yarn install; \\
else \\
npm install --legacy-peer-deps; \\
fi
COPY . .
RUN if [ -f pnpm-lock.yaml ]; then pnpm run build; \\
elif [ -f yarn.lock ]; then yarn build; \\
else npm run build; fi
# ── Stage 2: Production ─────────────────────────────────────────
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
ENV NODE_ENV=production
EXPOSE \${port}
CMD ["npm", "start"]
`,
python: (port) => `FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE \${port}
CMD ["python", "app.py"]
`,
'python-django': (port) => `FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
EXPOSE \${port}
CMD ["gunicorn", "--bind", "0.0.0.0:\${port}", "config.wsgi:application"]
`,
'python-flask': (port) => `FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install gunicorn
COPY . .
EXPOSE \${port}
CMD ["gunicorn", "--bind", "0.0.0.0:\${port}", "app:app"]
`,
php: (port) => `FROM php:8.3-apache
RUN a2enmod rewrite
RUN docker-php-ext-install pdo pdo_mysql mysqli
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80
CMD ["apache2-foreground"]
`,
'php-laravel': (port) => `FROM php:8.3-fpm
RUN apt-get update && apt-get install -y \\
git curl zip unzip libpng-dev libonig-dev libxml2-dev \\
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY . .
RUN composer install --no-dev --optimize-autoloader
RUN php artisan config:cache
EXPOSE \${port}
CMD ["php-fpm"]
`,
static: () => `FROM nginx:alpine
COPY . /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`,
'static-react': () => `FROM node:22-slim AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* yarn.lock* ./
RUN if [ -f pnpm-lock.yaml ]; then \\
corepack enable && corepack prepare pnpm@latest --activate && \\
pnpm install --no-frozen-lockfile; \\
elif [ -f yarn.lock ]; then \\
yarn install; \\
else \\
npm install --legacy-peer-deps; \\
fi
COPY . .
RUN if [ -f pnpm-lock.yaml ]; then pnpm run build; \\
elif [ -f yarn.lock ]; then yarn build; \\
else npm run build; fi
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`,
};
// ============ TEMPLATES DOCKER-COMPOSE ============
function generateDockerCompose(appConfig) {
const { appId, subdomain, stack, port, needsDb, dbType } = appConfig;
const domain = `${subdomain}.recette.santinova-soft.org`;
const containerPort = getContainerPort(stack, port);
let compose = `services:
app:
build:
context: ./src
dockerfile: Dockerfile
container_name: ${appId}
restart: unless-stopped
networks:
- web`;
// Ajouter le réseau interne si base de données
if (needsDb) {
compose += `
- ${appId}-internal`;
}
// Variables d'environnement
compose += `
environment:
- NODE_ENV=production`;
if (needsDb) {
compose += `
- DATABASE_HOST=${appId}-db
- DATABASE_PORT=${dbType === 'postgres' ? '5432' : '3306'}
- DATABASE_NAME=${appId.replace(/-/g, '_')}
- DATABASE_USER=app_user
- DATABASE_PASSWORD=AppPassword2026!
- DB_HOST=${appId}-db
- DB_PORT=${dbType === 'postgres' ? '5432' : '3306'}
- DB_NAME=${appId.replace(/-/g, '_')}
- DB_USER=app_user
- DB_PASSWORD=AppPassword2026!
- MYSQL_HOST=${appId}-db
- MYSQL_DATABASE=${appId.replace(/-/g, '_')}
- MYSQL_USER=app_user
- MYSQL_PASSWORD=AppPassword2026!`;
}
compose += `
labels:
- "traefik.enable=true"
- "traefik.http.routers.${appId}.rule=Host(\`${domain}\`)"
- "traefik.http.routers.${appId}.entrypoints=websecure"
- "traefik.http.routers.${appId}.tls=true"
- "traefik.http.routers.${appId}.tls.certresolver=letsencrypt"
- "traefik.http.routers.${appId}.service=${appId}-svc"
- "traefik.http.services.${appId}-svc.loadbalancer.server.port=${containerPort}"
- "traefik.docker.network=web"`;
// Ajouter la base de données si nécessaire
if (needsDb) {
const dbName = appId.replace(/-/g, '_');
if (dbType === 'postgres') {
compose += `
db:
image: postgres:16-alpine
container_name: ${appId}-db
restart: unless-stopped
environment:
- POSTGRES_DB=${dbName}
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=AppPassword2026!
volumes:
- ${appId}-db-data:/var/lib/postgresql/data
networks:
- ${appId}-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d ${dbName}"]
interval: 10s
timeout: 5s
retries: 5`;
} else {
compose += `
db:
image: mysql:8.0
container_name: ${appId}-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=RootPassword2026!
- MYSQL_DATABASE=${dbName}
- MYSQL_USER=app_user
- MYSQL_PASSWORD=AppPassword2026!
volumes:
- ${appId}-db-data:/var/lib/mysql
networks:
- ${appId}-internal
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pRootPassword2026!"]
interval: 10s
timeout: 5s
retries: 5`;
}
}
// Dépendance app -> db
if (needsDb) {
// Insert depends_on before labels
const appSection = compose.indexOf(' labels:');
const dependsOn = ` depends_on:
db:
condition: service_healthy
`;
compose = compose.slice(0, appSection) + dependsOn + compose.slice(appSection);
}
// Réseaux
compose += `
networks:
web:
external: true`;
if (needsDb) {
compose += `
${appId}-internal:
driver: bridge`;
}
// Volumes
if (needsDb) {
compose += `
volumes:
${appId}-db-data:`;
}
compose += '\n';
return compose;
}
function getContainerPort(stack, customPort) {
if (customPort) return customPort;
const defaults = {
nodejs: 3000,
'nodejs-next': 3000,
python: 5000,
'python-django': 8000,
'python-flask': 5000,
php: 80,
'php-laravel': 9000,
static: 80,
'static-react': 80,
};
return defaults[stack] || 3000;
}
// ============ APPS PERSISTENCE ============
const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json');
function loadDynamicApps() {
try {
if (fs.existsSync(APPS_FILE)) {
const data = fs.readFileSync(APPS_FILE, 'utf-8');
return JSON.parse(data);
}
} catch (err) {
console.error('Erreur chargement apps dynamiques:', err.message);
}
return [];
}
function saveDynamicApps(apps) {
try {
fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8');
} catch (err) {
console.error('Erreur sauvegarde apps dynamiques:', err.message);
}
}
function addDynamicApp(appDef) {
const apps = loadDynamicApps();
// Éviter les doublons
const existing = apps.findIndex((a) => a.id === appDef.id);
if (existing >= 0) {
apps[existing] = appDef;
} else {
apps.push(appDef);
}
saveDynamicApps(apps);
// Ajouter aussi à config.apps en mémoire
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
if (existingInConfig >= 0) {
config.apps[existingInConfig] = appDef;
} else {
config.apps.push(appDef);
}
}
function initDynamicApps() {
const dynamicApps = loadDynamicApps();
for (const app of dynamicApps) {
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
if (existingInConfig < 0) {
config.apps.push(app);
}
}
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`);
}
// ============ CREATION D'APPLICATION ============
async function createApplication(params, logCallback) {
const {
name,
description,
subdomain,
stack,
port,
needsDb,
dbType,
sourceType, // 'zip' or 'gitea'
giteaRepoUrl, // URL du repo Gitea existant
zipFilePath, // Chemin du fichier ZIP uploadé
} = params;
const appId = subdomain;
const domain = `${subdomain}.recette.santinova-soft.org`;
const appDir = path.join(config.appsBasePath, appId);
const srcDir = path.join(appDir, 'src');
const log = (msg) => {
const line = `[${new Date().toISOString()}] ${msg}`;
console.log(line);
if (logCallback) logCallback(line);
};
try {
// 1. Créer le répertoire de l'application
log(`Création du répertoire ${appDir}...`);
await fsp.mkdir(appDir, { recursive: true });
await fsp.mkdir(srcDir, { recursive: true });
let giteaRepoName = appId;
let giteaOwner = config.gitea.username;
if (sourceType === 'gitea') {
// 2a. Cloner depuis un dépôt Gitea existant
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
const cloneUrl = giteaRepoUrl.replace(
'https://',
`https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
);
await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
log('Clonage terminé.');
// Extraire le nom du repo depuis l'URL
const urlParts = giteaRepoUrl.replace(/\.git$/, '').split('/');
giteaRepoName = urlParts[urlParts.length - 1];
if (urlParts.length >= 2) {
giteaOwner = urlParts[urlParts.length - 2];
}
} else if (sourceType === 'zip') {
// 2b. Extraire le ZIP et créer un dépôt Gitea
log(`Extraction du fichier ZIP...`);
await execCommand(`cd ${srcDir} && unzip -o ${zipFilePath}`, 60000);
// Vérifier si les fichiers sont dans un sous-répertoire
const entries = await fsp.readdir(srcDir);
const nonHidden = entries.filter((e) => !e.startsWith('.') && e !== '__MACOSX');
if (nonHidden.length === 1) {
const subDir = path.join(srcDir, nonHidden[0]);
const stat = await fsp.stat(subDir);
if (stat.isDirectory()) {
log(`Déplacement des fichiers depuis le sous-répertoire ${nonHidden[0]}...`);
await execCommand(`cd ${subDir} && mv * ${srcDir}/ 2>/dev/null; mv .* ${srcDir}/ 2>/dev/null; cd ${srcDir} && rmdir "${nonHidden[0]}" 2>/dev/null || true`);
}
}
// Supprimer le ZIP temporaire
try { await fsp.unlink(zipFilePath); } catch (e) { /* ignore */ }
// Créer le dépôt sur Gitea
log(`Création du dépôt Gitea: ${appId}...`);
try {
await createRepo(appId, description || `Application ${name}`, true);
log('Dépôt Gitea créé.');
} catch (err) {
if (err.response && err.response.status === 409) {
log('Le dépôt Gitea existe déjà, utilisation du dépôt existant.');
} else {
throw err;
}
}
// Initialiser git et pousser le code
log('Initialisation Git et push vers Gitea...');
const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`;
await execCommand(
`cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`,
120000
);
log('Code poussé sur Gitea.');
}
// 3. Générer le Dockerfile si nécessaire
const dockerfilePath = path.join(srcDir, 'Dockerfile');
const hasDockerfile = fs.existsSync(dockerfilePath);
if (!hasDockerfile) {
log(`Génération du Dockerfile pour la stack ${stack}...`);
const containerPort = getContainerPort(stack, port);
const templateFn = DOCKERFILE_TEMPLATES[stack] || DOCKERFILE_TEMPLATES['nodejs'];
let dockerfileContent = templateFn(containerPort);
// If the project does NOT have a patches/ directory, remove COPY patches/ lines
const hasPatchesDir = fs.existsSync(path.join(srcDir, 'patches'));
if (!hasPatchesDir) {
dockerfileContent = dockerfileContent
.split('\n')
.filter(line => !line.includes('COPY patches/'))
.join('\n');
log('Pas de dossier patches/ détecté, lignes COPY patches/ retirées du Dockerfile.');
}
// If the project does NOT have a drizzle/ directory, remove COPY drizzle lines
const hasDrizzleDir = fs.existsSync(path.join(srcDir, 'drizzle'));
if (!hasDrizzleDir) {
dockerfileContent = dockerfileContent
.split('\n')
.filter(line => !line.includes('COPY drizzle'))
.join('\n');
log('Pas de dossier drizzle/ détecté, lignes COPY drizzle retirées du Dockerfile.');
}
await fsp.writeFile(dockerfilePath, dockerfileContent, 'utf-8');
log('Dockerfile généré.');
// Commit le Dockerfile
if (sourceType === 'zip') {
await execCommand(
`cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`,
60000
);
log('Dockerfile commité et poussé sur Gitea.');
}
} else {
log('Dockerfile existant détecté, utilisation du Dockerfile du projet.');
}
// 4. Générer le docker-compose.yml
log('Génération du docker-compose.yml...');
const containerPort = getContainerPort(stack, port);
const composeContent = generateDockerCompose({
appId,
subdomain,
stack,
port: containerPort,
needsDb: needsDb || false,
dbType: dbType || 'mysql',
});
await fsp.writeFile(path.join(appDir, 'docker-compose.yml'), composeContent, 'utf-8');
log('docker-compose.yml généré.');
// 5. Lancer le build et le déploiement Docker
log('Lancement du build Docker (cela peut prendre quelques minutes)...');
const buildResult = await execCommand(
`cd ${appDir} && docker compose build --no-cache 2>&1`,
600000
);
log('Build Docker terminé.');
log('Démarrage des conteneurs...');
await execCommand(`cd ${appDir} && docker compose up -d 2>&1`, 120000);
log('Conteneurs démarrés.');
// 6. Créer un utilisateur admin dans la base de données si nécessaire
if (needsDb) {
log('Attente du démarrage de la base de données (15s)...');
await new Promise((resolve) => setTimeout(resolve, 15000));
try {
log('Tentative de création de l\'utilisateur admin dans la base de données...');
if (dbType === 'postgres') {
await execCommand(
`docker exec ${appId}-db psql -U app_user -d ${appId.replace(/-/g, '_')} -c "CREATE TABLE IF NOT EXISTS admin_users (id SERIAL PRIMARY KEY, email VARCHAR(255), password VARCHAR(255), role VARCHAR(50), created_at TIMESTAMP DEFAULT NOW()); INSERT INTO admin_users (email, password, role) VALUES ('adminItinova@santinova-soft.org', 'Itinova69!', 'admin') ON CONFLICT DO NOTHING;" 2>&1`,
30000
);
} else {
await execCommand(
`docker exec ${appId}-db mysql -uapp_user -pAppPassword2026! ${appId.replace(/-/g, '_')} -e "CREATE TABLE IF NOT EXISTS admin_users (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255), password VARCHAR(255), role VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); INSERT IGNORE INTO admin_users (email, password, role) VALUES ('adminItinova@santinova-soft.org', 'Itinova69!', 'admin');" 2>&1`,
30000
);
}
log('Utilisateur admin créé dans la base de données.');
} catch (dbErr) {
log(`Note: Création de l'utilisateur admin en BDD non critique: ${dbErr.message}`);
}
}
// 7. Enregistrer l'application dans la configuration dynamique
const appDef = {
id: appId,
name: name,
description: description || `Application ${name}`,
directory: appId,
giteaRepo: giteaRepoName,
giteaOwner: giteaOwner,
urls: {
recette: `https://${domain}`,
prod: null,
},
containerName: appId,
healthCheckUrl: `https://${domain}`,
port: containerPort,
stack: stack,
needsDb: needsDb || false,
dbType: dbType || null,
createdAt: new Date().toISOString(),
};
addDynamicApp(appDef);
log(`Application ${name} enregistrée dans le dashboard.`);
log(`Accessible sur: https://${domain}`);
return {
success: true,
app: appDef,
message: `Application ${name} créée et déployée avec succès`,
url: `https://${domain}`,
};
} catch (err) {
log(`ERREUR: ${err.message}`);
throw err;
}
}
function execCommand(cmd, timeout = 60000) {
return new Promise((resolve, reject) => {
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
} else {
resolve(stdout + stderr);
}
});
});
}
// ============ STACK DETECTION ============
function getAvailableStacks() {
return [
{ id: 'nodejs', name: 'Node.js', icon: 'nodejs', description: 'Application Node.js (Express, Fastify, etc.)' },
{ id: 'nodejs-next', name: 'Node.js (Next.js)', icon: 'nodejs', description: 'Application Next.js avec SSR' },
{ id: 'python', name: 'Python', icon: 'python', description: 'Application Python générique' },
{ id: 'python-flask', name: 'Python (Flask)', icon: 'python', description: 'Application Flask' },
{ id: 'python-django', name: 'Python (Django)', icon: 'python', description: 'Application Django' },
{ id: 'php', name: 'PHP', icon: 'php', description: 'Application PHP avec Apache' },
{ id: 'php-laravel', name: 'PHP (Laravel)', icon: 'php', description: 'Application Laravel' },
{ id: 'static', name: 'Site statique', icon: 'static', description: 'HTML/CSS/JS statique (Nginx)' },
{ id: 'static-react', name: 'React (SPA)', icon: 'react', description: 'Application React avec build (Vite/CRA)' },
];
}
module.exports = {
createApplication,
generateDockerCompose,
getAvailableStacks,
initDynamicApps,
loadDynamicApps,
addDynamicApp,
DOCKERFILE_TEMPLATES,
};

61
backend/src/auth.js Normal file
View File

@@ -0,0 +1,61 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const config = require('./config');
// Hash du mot de passe au démarrage
let passwordHash = null;
async function initAuth() {
passwordHash = await bcrypt.hash(config.auth.password, 10);
}
async function authenticate(username, password) {
if (username !== config.auth.username) {
return null;
}
// Comparer directement car on stocke le mot de passe en clair dans la config
if (password !== config.auth.password) {
return null;
}
const token = jwt.sign(
{ username, role: 'admin' },
config.jwtSecret,
{ expiresIn: config.jwtExpiry }
);
return token;
}
function verifyToken(token) {
try {
return jwt.verify(token, config.jwtSecret);
} catch (err) {
return null;
}
}
function authMiddleware(req, res, next) {
// Check Authorization header
let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
}
// Check cookie
if (!token && req.cookies) {
token = req.cookies.dashboard_token;
}
if (!token) {
return res.status(401).json({ error: 'Non authentifié' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ error: 'Token invalide ou expiré' });
}
req.user = decoded;
next();
}
module.exports = { initAuth, authenticate, verifyToken, authMiddleware };

230
backend/src/config.js Normal file
View File

@@ -0,0 +1,230 @@
module.exports = {
port: process.env.PORT || 3001,
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
jwtExpiry: '24h',
// Authentification
auth: {
username: process.env.ADMIN_USERNAME || 'adminItinova',
password: process.env.ADMIN_PASSWORD || 'Itinova69!',
},
// Gitea
gitea: {
url: process.env.GITEA_URL || 'http://gitea:3000',
username: process.env.GITEA_USERNAME || 'manus-admin',
password: process.env.GITEA_PASSWORD || 'Itinova69!',
token: process.env.GITEA_TOKEN || null,
},
// Applications config
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
// Health check interval (ms)
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
// Apps configuration
apps: [
{
id: 'itinova-contacts',
name: 'Itinova Contacts',
description: 'Application de gestion des contacts Itinova',
directory: 'itinova-contacts',
giteaRepo: 'itinova-contacts',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org',
},
containerName: 'itinova-contacts',
healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'itinova-podcasts',
name: 'Itinova Podcasts',
description: 'Gestionnaire de podcasts pour les établissements Itinova',
directory: 'itinova-podcasts',
giteaRepo: 'itinova-podcasts',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://podcasts.recette.santinova-soft.org',
prod: 'https://podcasts.santinova-soft.org',
},
containerName: 'itinova-podcasts',
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'veille-reglementaire',
name: 'Veille Réglementaire',
description: 'Application de veille réglementaire et appels à projets',
directory: 'veille-reglementaire',
giteaRepo: 'veille-reglementaire',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://veille.recette.santinova-soft.org',
prod: 'https://veille.santinova-soft.org',
},
containerName: 'veille-reglementaire-recette',
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte',
description: 'Bourse de véhicules Itinova — Partage et échange de véhicules entre établissements',
directory: 'itinova-vehicle-exchange',
giteaRepo: 'itinova-vehicle-exchange',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://flotte.recette.santinova-soft.org',
prod: 'https://flotte.santinova-soft.org',
},
containerName: 'itinova-vehicle-exchange',
healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'pilotage-masse-salariale',
name: 'Pilotage Masse Salariale',
description: 'Application de pilotage de la masse salariale Itinova',
directory: 'pilotage-masse-salariale',
giteaRepo: 'pilotage-masse-salariale',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://pilotage-ms.recette.santinova-soft.org',
prod: 'https://pilotage-ms.santinova-soft.org',
},
containerName: 'pilotage-masse-salariale-app',
healthCheckUrl: 'https://pilotage-ms.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'recette',
},
{
id: 'sonum',
name: 'SONUM',
description: 'Cartographie des Solutions Numériques FEHAP',
directory: 'sonum',
giteaRepo: 'sonum',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://sonum.recette.santinova-soft.org',
prod: 'https://sonum.santinova-soft.org',
},
containerName: 'sonum',
healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'production',
},
{
id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI',
description: 'Dématérialisation des factures DSI — Import email/dossier, analyse IA, export SharePoint/PDF, validation BAP',
directory: 'demat-facturation-dsi',
giteaRepo: 'demat-facturation-dsi',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://demat-facturation.recette.santinova-soft.org',
prod: 'https://demat-facturation.santinova-soft.org',
},
containerName: 'demat-facturation-app',
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
port: 3000,
image: 'images/demat-facturation-dsi.jpg',
category: 'SANTINOVA',
status: 'production',
},
{
id: 'facturation-santinova',
name: 'Facturation Santinova',
description: 'Application de gestion de la facturation Santinova',
directory: 'facturation-santinova',
giteaRepo: 'facturation-santinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://facturation.recette.santinova-soft.org',
prod: 'https://facturation.santinova-soft.org',
},
containerName: 'facturation-santinova-app',
healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
port: 3001,
category: 'SANTINOVA',
status: 'recette',
},
{
id: 'formation-manager-itinova',
name: 'Formation Manager',
description: 'Gestion des Formations Itinova',
directory: 'formation-manager-itinova',
giteaRepo: 'formation-manager-itinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://formations.recette.santinova-soft.org',
prod: 'https://formations.itinova.org',
},
containerName: 'formation-manager-itinova',
healthCheckUrl: 'https://formations.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'portail-santinova',
name: 'Portail Applicatif',
description: 'Portail applicatif Santinova — accès centralisé aux applications',
directory: 'portail-santinova',
giteaRepo: 'portail-santinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://portail.recette.santinova-soft.org',
prod: 'https://portail.santinova-soft.org',
},
containerName: 'portail-santinova',
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
port: 3000,
category: 'INFRA',
status: 'production',
},
{
id: 'manus-dashboard',
name: 'Dashboard Recette',
description: 'Dashboard de gestion de l\'infrastructure Manus',
directory: 'manus-dashboard',
giteaRepo: 'manus-dashboard',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://dashboard.recette.santinova-soft.org',
prod: 'https://dashboard.santinova-soft.org',
},
containerName: 'manus-dashboard',
healthCheckUrl: 'https://dashboard.recette.santinova-soft.org',
port: 3001,
category: 'INFRA',
status: 'production',
},
{
id: 'itinova-budget-si',
name: 'Gestion Budget Informatique',
description: 'Application de gestion du budget informatique DSI Itinova',
directory: 'itinova-budget-si',
giteaRepo: 'itinova-budget-si',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://budget-si.recette.santinova-soft.org',
prod: 'https://budget-si.santinova-soft.org',
},
containerName: 'itinova-budget-si-app',
healthCheckUrl: 'https://budget-si.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'recette',
},
],
};

411
backend/src/docker.js Normal file
View File

@@ -0,0 +1,411 @@
const Docker = require('dockerode');
const { exec } = require('child_process');
const path = require('path');
const config = require('./config');
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
/**
* Récupérer les informations d'un conteneur Docker
*/
async function getContainerInfo(containerName) {
try {
const container = docker.getContainer(containerName);
const info = await container.inspect();
return {
id: info.Id.substring(0, 12),
name: info.Name.replace('/', ''),
state: info.State.Status,
running: info.State.Running,
startedAt: info.State.StartedAt,
image: info.Config.Image,
created: info.Created,
};
} catch (err) {
return null;
}
}
/**
* Récupérer les logs d'un conteneur
*/
async function getContainerLogs(containerName, tail = 100) {
try {
const container = docker.getContainer(containerName);
const logs = await container.logs({
stdout: true,
stderr: true,
tail: tail,
timestamps: true,
});
// Parse buffer to string
return logs.toString('utf8');
} catch (err) {
return `Erreur lors de la récupération des logs: ${err.message}`;
}
}
/**
* Lister tous les conteneurs
*/
async function listContainers() {
try {
const containers = await docker.listContainers({ all: true });
return containers.map((c) => ({
id: c.Id.substring(0, 12),
names: c.Names.map((n) => n.replace('/', '')),
state: c.State,
status: c.Status,
image: c.Image,
created: new Date(c.Created * 1000).toISOString(),
}));
} catch (err) {
return [];
}
}
/**
* Redéployer une application (docker compose build + up)
*/
function redeployApp(appConfig) {
return new Promise((resolve, reject) => {
const appDir = path.join(config.appsBasePath, appConfig.directory);
const cmd = `cd ${appDir} && docker compose build --no-cache && docker compose up -d`;
const startTime = Date.now();
const logLines = [];
logLines.push(`[${new Date().toISOString()}] Début du redéploiement de ${appConfig.name}`);
logLines.push(`[${new Date().toISOString()}] Répertoire: ${appDir}`);
logLines.push(`[${new Date().toISOString()}] Commande: ${cmd}`);
const process = exec(cmd, {
maxBuffer: 1024 * 1024 * 10, // 10MB
timeout: 300000, // 5 minutes
});
process.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(Boolean);
lines.forEach((line) => {
logLines.push(`[${new Date().toISOString()}] ${line}`);
});
});
process.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(Boolean);
lines.forEach((line) => {
logLines.push(`[${new Date().toISOString()}] ${line}`);
});
});
process.on('close', (code) => {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
logLines.push(`[${new Date().toISOString()}] Redéploiement terminé avec succès en ${duration}s`);
resolve({ success: true, logs: logLines.join('\n'), duration });
} else {
logLines.push(`[${new Date().toISOString()}] Redéploiement échoué (code: ${code}) en ${duration}s`);
resolve({ success: false, logs: logLines.join('\n'), duration, exitCode: code });
}
});
process.on('error', (err) => {
logLines.push(`[${new Date().toISOString()}] Erreur: ${err.message}`);
resolve({ success: false, logs: logLines.join('\n'), error: err.message });
});
});
}
/**
* Pull le dernier code depuis Gitea
*/
function gitPull(appConfig) {
return new Promise((resolve, reject) => {
const srcDir = path.join(config.appsBasePath, appConfig.directory, 'src');
const cmd = `cd ${srcDir} && git pull origin main 2>&1 || git pull origin master 2>&1`;
exec(cmd, { timeout: 60000 }, (error, stdout, stderr) => {
if (error) {
resolve({ success: false, output: stderr || error.message });
} else {
resolve({ success: true, output: stdout });
}
});
});
}
/**
* Démarrer un conteneur Docker
*/
async function startContainer(containerName) {
try {
const container = docker.getContainer(containerName);
await container.start();
return { success: true, message: `Conteneur ${containerName} démarré` };
} catch (err) {
return { success: false, message: err.message };
}
}
/**
* Arrêter un conteneur Docker
*/
async function stopContainer(containerName) {
try {
const container = docker.getContainer(containerName);
await container.stop({ t: 10 });
return { success: true, message: `Conteneur ${containerName} arrêté` };
} catch (err) {
return { success: false, message: err.message };
}
}
/**
* Redémarrer un conteneur Docker
*/
async function restartContainer(containerName) {
try {
const container = docker.getContainer(containerName);
await container.restart({ t: 10 });
return { success: true, message: `Conteneur ${containerName} redémarré` };
} catch (err) {
return { success: false, message: err.message };
}
}
/**
* Récupérer les métriques système du serveur (CPU, RAM, disque, réseau)
*/
function getServerMetrics() {
return new Promise((resolve) => {
const { execSync } = require("child_process");
const fs = require("fs");
try {
// ===== Méthode 1 : Lire host-metrics.json généré par le script système de l'hôte =====
// Ce fichier est mis à jour toutes les 30s par /opt/manus-deploy/host-metrics.sh
// Il contient les vraies métriques de l'hôte LXC (pas du nœud physique)
const hostMetricsPath = '/opt/manus-deploy/host-metrics.json';
if (fs.existsSync(hostMetricsPath)) {
try {
const raw = fs.readFileSync(hostMetricsPath, 'utf8');
const hostMetrics = JSON.parse(raw);
// Vérifier que le fichier est récent (moins de 2 minutes)
const fileAge = Date.now() - new Date(hostMetrics.timestamp).getTime();
if (fileAge < 120000 && hostMetrics.memory && hostMetrics.memory.total > 0) {
return resolve({
...hostMetrics,
timestamp: new Date().toISOString()
});
}
} catch(e) {}
}
// ===== Méthode 2 : Lecture directe de /proc (fallback) =====
// CPU : mesure via cgroup v2 usage_usec (méthode précise pour conteneur LXC)
// Fallback sur /proc/stat normalisé par nproc si cgroup v2 non disponible
let cpuUsage = 0;
try {
const ncpu = parseInt(execSync('nproc').toString().trim()) || 1;
const cgroupPath = '/sys/fs/cgroup/cpu.stat';
const readUsageUsec = () => {
const stat = fs.readFileSync(cgroupPath, 'utf8');
const m = stat.match(/^usage_usec\s+(\d+)/m);
return m ? parseInt(m[1]) : null;
};
const u1 = readUsageUsec();
if (u1 !== null) {
// Méthode cgroup v2 : mesure la consommation CPU réelle du conteneur
execSync('sleep 0.5');
const u2 = readUsageUsec();
// delta en microsecondes / (500ms * ncpu) = % CPU du conteneur
const deltaUs = u2 - u1;
cpuUsage = Math.min(100, Math.round(deltaUs / (500000 * ncpu) * 100));
} else {
// Fallback : /proc/stat normalisé par nproc
const parseCpuLine = (line) => line.trim().split(/\s+/).slice(1).map(Number);
const s1 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
execSync('sleep 0.3');
const s2 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
const idle1 = s1[3], total1 = s1.reduce((a,b)=>a+b,0);
const idle2 = s2[3], total2 = s2.reduce((a,b)=>a+b,0);
const dIdle = idle2 - idle1, dTotal = total2 - total1;
const rawCpu = dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0;
// Normaliser par nproc pour éviter l'effet nœud physique sur LXC
cpuUsage = Math.round(rawCpu / ncpu);
}
} catch(e) {}
// RAM : lecture robuste multi-source
// Priorité 1 : /host/proc/meminfo (hôte LXC monté via volume docker-compose)
// Priorité 2 : /proc/meminfo si MemTotal <= 64 Go (lxcfs ou VPS direct)
// Priorité 3 : Docker API MemTotal + ratio /proc/meminfo du nœud physique
let memTotal = 0;
let memFree = 0, memAvailable = 0, memUsed = 0, memBuffers = 0, memCached = 0;
// Fonction de lecture de meminfo depuis un chemin donné
const readMeminfo = (procPath) => {
const raw = fs.readFileSync(procPath + '/meminfo', 'utf8');
const map = {};
raw.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
const key = line.substring(0, idx).trim();
const rest = line.substring(idx + 1).trim();
const val = parseInt(rest.split(' ')[0]);
if (Number.isFinite(val)) map[key] = val * 1024;
}
});
return map;
};
try {
let memMap = null;
let sourceLabel = '';
// Priorité 1 : /host/proc monté depuis l'hôte LXC
if (fs.existsSync('/host/proc/meminfo')) {
try {
const hostMap = readMeminfo('/host/proc');
if (hostMap['MemTotal'] > 0 && hostMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
memMap = hostMap;
sourceLabel = 'host/proc';
}
} catch(e) {}
}
// Priorité 2 : /proc direct si MemTotal <= 64 Go
if (!memMap) {
try {
const procMap = readMeminfo('/proc');
if (procMap['MemTotal'] > 0 && procMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
memMap = procMap;
sourceLabel = '/proc direct';
}
} catch(e) {}
}
// Priorité 3 : Docker API MemTotal + ratio du nœud physique
if (!memMap) {
try {
const dockerInfoRaw = execSync(
'curl -s --unix-socket /var/run/docker.sock http://localhost/info',
{ timeout: 3000 }
).toString();
const dockerInfo = JSON.parse(dockerInfoRaw);
const dockerMemTotal = dockerInfo.MemTotal || 0;
if (dockerMemTotal > 0) {
// Lire le ratio d'utilisation depuis /proc du nœud physique
const nodeMap = readMeminfo('/proc');
const nodeTotal = nodeMap['MemTotal'] || 1;
const nodeAvail = nodeMap['MemAvailable'] || 0;
const usageRatio = (nodeTotal - nodeAvail) / nodeTotal;
// Appliquer ce ratio à la vraie RAM du VPS
memTotal = dockerMemTotal;
memUsed = Math.round(memTotal * usageRatio);
memFree = memTotal - memUsed;
memAvailable = memFree;
sourceLabel = 'docker-api+ratio';
}
} catch(e) {}
}
// Calculer les valeurs finales si memMap disponible
if (memMap) {
memTotal = memMap['MemTotal'] || 0;
memFree = memMap['MemFree'] || 0;
memAvailable = memMap['MemAvailable'] || 0;
memBuffers = memMap['Buffers'] || 0;
const sReclaimable = memMap['SReclaimable'] || 0;
const shmem = memMap['Shmem'] || 0;
memCached = (memMap['Cached'] || 0) + (sReclaimable > shmem ? sReclaimable - shmem : 0);
// Utiliser MemAvailable comme référence car elle inclut les caches libérables
// Identique à ce que `free` affiche dans la colonne 'disponible'
memUsed = memTotal - memAvailable;
if (memUsed < 0) memUsed = 0;
}
} catch(e) {
// Fallback ultime
try {
const m = readMeminfo('/proc');
memTotal = m['MemTotal'] || 0;
memFree = m['MemFree'] || 0;
memAvailable = m['MemAvailable'] || 0;
memUsed = memTotal - memAvailable;
} catch(e2) {}
}
// Disque : df sur /opt/manus-deploy (point de montage de l'hôte LXC)
// Utiliser /opt/manus-deploy car c'est un volume monté depuis l'hôte
let diskTotal = 0, diskUsed = 0, diskFree = 0;
try {
const diskPath = fs.existsSync('/opt/manus-deploy') ? '/opt/manus-deploy' : '/';
const diskRaw = execSync('df -B1 ' + diskPath + ' | tail -1').toString().trim().split(/\s+/);
diskTotal = parseInt(diskRaw[1]);
diskUsed = parseInt(diskRaw[2]);
diskFree = parseInt(diskRaw[3]);
} catch(e) {}
// Uptime et load : utiliser /host/proc si disponible (hôte LXC réel)
const procBase = fs.existsSync('/host/proc/uptime') ? '/host/proc' : '/proc';
const uptimeRaw = fs.readFileSync(procBase + '/uptime', 'utf8').trim().split(' ');
const uptimeSeconds = parseFloat(uptimeRaw[0]);
const loadRaw = fs.readFileSync(procBase + '/loadavg', 'utf8').trim().split(' ');
const load1 = parseFloat(loadRaw[0]);
const load5 = parseFloat(loadRaw[1]);
const load15 = parseFloat(loadRaw[2]);
// Réseau
let netRx = 0, netTx = 0;
try {
const netDevPath = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev';
const netDev = fs.readFileSync(netDevPath, 'utf8');
const netLine = netDev.split('\n').find(l => /eth0|ens|enp/.test(l));
if (netLine) {
const parts = netLine.trim().split(/\s+/);
netRx = parseInt(parts[1]) || 0;
netTx = parseInt(parts[9]) || 0;
}
} catch(e) {}
resolve({
cpu: { usage: cpuUsage },
memory: {
total: memTotal,
used: memUsed,
free: memFree,
available: memAvailable,
buffers: memBuffers,
cached: memCached,
// usagePercent = RAM applicative réelle (hors cache disque, identique à `free`)
usagePercent: Math.round((memUsed / memTotal) * 100)
},
disk: {
total: diskTotal,
used: diskUsed,
free: diskFree,
usagePercent: Math.round((diskUsed / diskTotal) * 100)
},
uptime: uptimeSeconds,
load: { load1, load5, load15 },
network: { rx: netRx, tx: netTx },
timestamp: new Date().toISOString()
});
} catch (err) {
resolve({ error: err.message });
}
});
}
module.exports = {
docker,
getContainerInfo,
getContainerLogs,
listContainers,
redeployApp,
gitPull,
startContainer,
stopContainer,
restartContainer,
getServerMetrics,
};

119
backend/src/gitea.js Normal file
View File

@@ -0,0 +1,119 @@
const axios = require('axios');
const https = require('https');
const config = require('./config');
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
// Utiliser le token API si disponible, sinon auth basique
const giteaClientHeaders = config.gitea.token
? { 'Authorization': `token ${config.gitea.token}` }
: {};
const giteaClient = axios.create({
baseURL: `${config.gitea.url}/api/v1`,
...(config.gitea.token
? { headers: giteaClientHeaders }
: { auth: { username: config.gitea.username, password: config.gitea.password } }
),
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
timeout: 10000,
});
/**
* Récupérer les informations d'un dépôt
*/
async function getRepo(owner, repo) {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}`);
return response.data;
} catch (err) {
console.error(`Erreur Gitea getRepo ${owner}/${repo}:`, err.message);
return null;
}
}
/**
* Récupérer les derniers commits d'un dépôt
*/
async function getCommits(owner, repo, limit = 10) {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}/commits`, {
params: { limit, page: 1 },
});
return response.data.map((c) => ({
sha: c.sha,
shortSha: c.sha.substring(0, 7),
message: c.commit.message,
author: c.commit.author.name,
date: c.commit.author.date,
url: c.html_url,
}));
} catch (err) {
console.error(`Erreur Gitea getCommits ${owner}/${repo}:`, err.message);
return [];
}
}
/**
* Récupérer les branches d'un dépôt
*/
async function getBranches(owner, repo) {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}/branches`);
return response.data.map((b) => ({
name: b.name,
commit: b.commit.id.substring(0, 7),
commitMessage: b.commit.message,
}));
} catch (err) {
console.error(`Erreur Gitea getBranches ${owner}/${repo}:`, err.message);
return [];
}
}
/**
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
*/
async function listRepos(retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await giteaClient.get('/repos/search', {
params: { limit: 50 },
});
return response.data.data || [];
} catch (err) {
console.error(`Erreur Gitea listRepos (tentative ${i + 1}/${retries}):`, err.message);
if (i < retries - 1) {
await new Promise((r) => setTimeout(r, 2000));
}
}
}
return [];
}
/**
* Créer un nouveau dépôt
*/
async function createRepo(name, description, isPrivate = true) {
try {
const response = await giteaClient.post('/user/repos', {
name,
description,
private: isPrivate,
auto_init: true,
default_branch: 'main',
});
return response.data;
} catch (err) {
console.error(`Erreur Gitea createRepo ${name}:`, err.message);
throw err;
}
}
module.exports = {
getRepo,
getCommits,
getBranches,
listRepos,
createRepo,
giteaClient,
};

188
backend/src/healthcheck.js Normal file
View File

@@ -0,0 +1,188 @@
const axios = require('axios');
const https = require('https');
const config = require('./config');
const { getContainerInfo } = require('./docker');
// Store pour les statuts des apps
const appStatuses = new Map();
// Store pour les logs de déploiement
const deploymentLogs = [];
const MAX_DEPLOYMENT_LOGS = 50;
// Store pour les commits Gitea
const giteaCommits = new Map();
// Agent HTTPS qui ignore les certificats auto-signés
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed"
// (ils ont été interrompus lors du dernier redémarrage du Dashboard)
function cleanupOrphanedDeployments() {
const orphans = deploymentLogs.filter((l) => l.status === 'running');
if (orphans.length > 0) {
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`);
orphans.forEach((l) => {
l.status = 'failed';
l.message = 'Déploiement interrompu (redémarrage du Dashboard)';
l.endedAt = new Date().toISOString();
});
}
}
/**
* Vérifier la santé d'une URL
*/
async function checkUrl(url) {
try {
const start = Date.now();
const response = await axios.get(url, {
timeout: 10000,
httpsAgent,
validateStatus: (status) => status < 500,
});
const responseTime = Date.now() - start;
return {
online: true,
statusCode: response.status,
responseTime,
};
} catch (err) {
return {
online: false,
statusCode: null,
responseTime: null,
error: err.message,
};
}
}
/**
* Vérifier la santé d'une application
*/
async function checkApp(appConfig) {
const result = {
id: appConfig.id,
name: appConfig.name,
description: appConfig.description,
urls: appConfig.urls,
containerName: appConfig.containerName,
lastCheck: new Date().toISOString(),
status: 'unknown',
container: null,
health: {
recette: null,
prod: null,
},
};
// Vérifier le conteneur Docker
const containerInfo = await getContainerInfo(appConfig.containerName);
result.container = containerInfo;
// Health check recette
if (appConfig.urls.recette) {
result.health.recette = await checkUrl(appConfig.urls.recette);
}
// Health check prod
if (appConfig.urls.prod) {
result.health.prod = await checkUrl(appConfig.urls.prod);
}
// Déterminer le statut global
if (!containerInfo || !containerInfo.running) {
result.status = 'offline';
} else if (result.health.recette && result.health.recette.online) {
result.status = 'online';
} else if (containerInfo.running) {
result.status = 'error';
} else {
result.status = 'offline';
}
// Stocker le résultat
appStatuses.set(appConfig.id, result);
return result;
}
/**
* Lancer les health checks pour toutes les apps
*/
async function checkAllApps() {
const results = [];
for (const app of config.apps) {
const result = await checkApp(app);
results.push(result);
}
return results;
}
/**
* Ajouter un log de déploiement (retourne l'entrée créée avec son id)
*/
function addDeploymentLog(appId, log) {
const entry = {
id: Date.now().toString(),
appId,
timestamp: new Date().toISOString(),
...log,
};
deploymentLogs.unshift(entry);
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) {
deploymentLogs.pop();
}
return entry;
}
/**
* Mettre à jour un log de déploiement existant par son id
* Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée
*/
function updateDeploymentLog(entryId, updates) {
const entry = deploymentLogs.find((l) => l.id === entryId);
if (entry) {
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
return entry;
}
return null;
}
/**
* Récupérer les logs de déploiement
*/
function getDeploymentLogs(appId = null) {
if (appId) {
return deploymentLogs.filter((l) => l.appId === appId);
}
return deploymentLogs;
}
/**
* Récupérer le statut d'une app
*/
function getAppStatus(appId) {
return appStatuses.get(appId) || null;
}
/**
* Récupérer tous les statuts
*/
function getAllStatuses() {
return Array.from(appStatuses.values());
}
module.exports = {
checkUrl,
checkApp,
checkAllApps,
addDeploymentLog,
updateDeploymentLog,
getDeploymentLogs,
getAppStatus,
getAllStatuses,
cleanupOrphanedDeployments,
appStatuses,
giteaCommits,
};

136
backend/src/index.js Normal file
View File

@@ -0,0 +1,136 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const path = require('path');
const cron = require('node-cron');
const http = require('http');
const WebSocket = require('ws');
const config = require('./config');
const { initAuth } = require('./auth');
const routes = require('./routes');
const webhookRoutes = require('./webhook');
const { checkAllApps, getAllStatuses } = require('./healthcheck');
const { getCommits } = require('./gitea');
const { initDynamicApps } = require('./app-creator');
const { initSSHWebSocket } = require('./ssh');
const app = express();
const server = http.createServer(app);
// WebSocket server pour les mises à jour en temps réel
const wss = new WebSocket.Server({ server, path: '/ws' });
initSSHWebSocket(server);
// Middleware
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}));
app.use(cors({
origin: true,
credentials: true,
}));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// Route webhook montée en premier avec raw body pour la vérification HMAC
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => {
if (req.body && Buffer.isBuffer(req.body)) {
req.rawBody = req.body;
try { req.body = JSON.parse(req.body.toString()); } catch(e) { req.body = {}; }
}
next();
}, webhookRoutes);
// API routes
app.use('/api', routes);
// Servir le frontend en production
const frontendPath = path.join(__dirname, '../../frontend/dist');
app.use(express.static(frontendPath));
// SPA fallback
app.get('*', (req, res) => {
if (!req.path.startsWith('/api') && !req.path.startsWith('/ws')) {
res.sendFile(path.join(frontendPath, 'index.html'));
}
});
// Broadcast WebSocket message
global.wsBroadcast = broadcast;
function broadcast(data) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// Health check périodique
async function runHealthChecks() {
try {
const results = await checkAllApps();
broadcast({ type: 'health_update', data: results });
// Vérifier les nouveaux commits Gitea
for (const appConfig of config.apps) {
try {
const commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 5);
if (commits.length > 0) {
broadcast({
type: 'gitea_commits',
data: { appId: appConfig.id, commits },
});
}
} catch (e) {
// Ignorer les erreurs Gitea
}
}
} catch (err) {
console.error('Erreur health check:', err.message);
}
}
// Démarrage
async function start() {
await initAuth();
// Charger les applications dynamiques
initDynamicApps();
// Premier health check
setTimeout(runHealthChecks, 2000);
// Health check toutes les 30 secondes
setInterval(runHealthChecks, config.healthCheckInterval);
server.listen(config.port, '0.0.0.0', () => {
console.log(`Manus Dashboard Backend démarré sur le port ${config.port}`);
console.log(`Frontend servi depuis: ${frontendPath}`);
});
}
// WebSocket connection handler
wss.on('connection', (ws) => {
console.log('Nouvelle connexion WebSocket');
// Envoyer les statuts actuels
const statuses = getAllStatuses();
if (statuses.length > 0) {
ws.send(JSON.stringify({ type: 'health_update', data: statuses }));
}
ws.on('close', () => {
console.log('Connexion WebSocket fermée');
});
});
start().catch((err) => {
console.error('Erreur au démarrage:', err);
process.exit(1);
});

601
backend/src/routes.js Normal file
View File

@@ -0,0 +1,601 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { authenticate, authMiddleware } = require('./auth');
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
const config = require('./config');
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
cleanupOrphanedDeployments();
const router = express.Router();
// Configuration multer pour l'upload de fichiers ZIP
const uploadDir = '/tmp/dashboard-uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const upload = multer({
dest: uploadDir,
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/zip' ||
file.mimetype === 'application/x-zip-compressed' ||
file.mimetype === 'application/octet-stream' ||
file.originalname.endsWith('.zip')) {
cb(null, true);
} else {
cb(new Error('Seuls les fichiers ZIP sont acceptés'), false);
}
},
});
// ============ AUTH ROUTES ============
router.post('/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' });
}
const token = await authenticate(username, password);
if (!token) {
return res.status(401).json({ error: 'Identifiants invalides' });
}
// Set cookie
res.cookie('dashboard_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 24h
});
res.json({ token, username });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/auth/logout', (req, res) => {
res.clearCookie('dashboard_token');
res.json({ message: 'Déconnecté' });
});
router.get('/auth/me', authMiddleware, (req, res) => {
res.json({ username: req.user.username, role: req.user.role });
});
// ============ APPS ROUTES ============
// Liste des applications avec leur statut
router.get('/apps', authMiddleware, async (req, res) => {
try {
const statuses = getAllStatuses();
if (statuses.length === 0) {
// Première requête, lancer un check
const results = await checkAllApps();
return res.json(results);
}
res.json(statuses);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Détail d'une application
router.get('/apps/:id', authMiddleware, async (req, res) => {
try {
const appConfig = config.apps.find((a) => a.id === req.params.id);
if (!appConfig) {
return res.status(404).json({ error: 'Application non trouvée' });
}
const status = getAppStatus(appConfig.id);
const containerInfo = await getContainerInfo(appConfig.containerName);
// Récupérer les infos Gitea
let giteaInfo = null;
let commits = [];
try {
giteaInfo = await getRepo(appConfig.giteaOwner, appConfig.giteaRepo);
commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 10);
} catch (e) {
// Gitea peut ne pas être disponible
}
res.json({
...status,
container: containerInfo,
gitea: giteaInfo
? {
fullName: giteaInfo.full_name,
description: giteaInfo.description,
defaultBranch: giteaInfo.default_branch,
updatedAt: giteaInfo.updated_at,
cloneUrl: giteaInfo.clone_url,
htmlUrl: giteaInfo.html_url,
}
: null,
commits,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Forcer un health check
router.post('/apps/:id/check', authMiddleware, async (req, res) => {
try {
const results = await checkAllApps();
const result = results.find((r) => r.id === req.params.id);
res.json(result || { error: 'Application non trouvée' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Verrou anti-concurrence par application
const deployLocks = new Map();
const DEPLOY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max
// Nettoyage automatique des verrous bloqués toutes les minutes
setInterval(() => {
const now = Date.now();
for (const [appId, lockTime] of deployLocks.entries()) {
if (now - lockTime > DEPLOY_TIMEOUT_MS) {
deployLocks.delete(appId);
const logs = getDeploymentLogs(appId);
logs.filter(l => l.status === 'running').forEach(l => {
l.status = 'failed';
l.message = 'Déploiement interrompu automatiquement (timeout 10 min)';
});
}
}
}, 60 * 1000);
// Redéployer une application
router.post('/apps/:id/deploy', authMiddleware, async (req, res) => {
try {
const appConfig = config.apps.find((a) => a.id === req.params.id);
if (!appConfig) {
return res.status(404).json({ error: 'Application non trouvée' });
}
// Vérifier si un déploiement est déjà en cours pour cette app
if (deployLocks.has(appConfig.id)) {
const lockAge = Math.round((Date.now() - deployLocks.get(appConfig.id)) / 1000);
return res.status(409).json({
error: `Un déploiement est déjà en cours pour ${appConfig.name} (depuis ${lockAge}s)`,
status: 'running'
});
}
// Poser le verrou
deployLocks.set(appConfig.id, Date.now());
// Ajouter un log de début et récupérer son id pour le mettre à jour plus tard
const deployEntry = addDeploymentLog(appConfig.id, {
status: 'running',
message: `Déploiement de ${appConfig.name} en cours...`,
logs: '',
user: req.user.username,
});
// Lancer le redéploiement en arrière-plan
res.json({ message: `Redéploiement de ${appConfig.name} lancé`, status: 'running' });
// Timeout de sécurité : si le déploiement dépasse 10 min, on le marque failed
const timeoutHandle = setTimeout(() => {
if (deployLocks.has(appConfig.id)) {
deployLocks.delete(appConfig.id);
updateDeploymentLog(deployEntry.id, {
status: 'failed',
message: `Déploiement de ${appConfig.name} interrompu (timeout 10 min)`,
});
}
}, DEPLOY_TIMEOUT_MS);
try {
// Exécuter le redéploiement
const result = await redeployApp(appConfig);
clearTimeout(timeoutHandle);
// Mettre à jour l'entrée existante (pas de doublon "running" + "success")
updateDeploymentLog(deployEntry.id, {
status: result.success ? 'success' : 'failed',
message: result.success
? `Redéploiement de ${appConfig.name} réussi en ${result.duration}s`
: `Redéploiement de ${appConfig.name} échoué`,
logs: result.logs,
duration: result.duration,
});
// Relancer un health check après le déploiement
setTimeout(() => checkAllApps(), 5000);
} finally {
// Toujours libérer le verrou
deployLocks.delete(appConfig.id);
}
} catch (err) {
deployLocks.delete(req.params.id);
addDeploymentLog(req.params.id, {
status: 'failed',
message: `Erreur: ${err.message}`,
logs: '',
user: req.user.username,
});
}
});
// Logs d'un conteneur
router.get('/apps/:id/logs', authMiddleware, async (req, res) => {
try {
const appConfig = config.apps.find((a) => a.id === req.params.id);
if (!appConfig) {
return res.status(404).json({ error: 'Application non trouvée' });
}
const tail = parseInt(req.query.tail) || 100;
const logs = await getContainerLogs(appConfig.containerName, tail);
res.json({ logs });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ CREATE APP ROUTE ============
// Récupérer les stacks disponibles
router.get('/apps-config/stacks', authMiddleware, (req, res) => {
res.json(getAvailableStacks());
});
// Créer une nouvelle application
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
try {
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
// Validation
if (!name || !subdomain || !stack || !sourceType) {
return res.status(400).json({
error: 'Champs requis manquants: name, subdomain, stack, sourceType',
});
}
// Vérifier que le subdomain est valide
const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
if (!subdomainRegex.test(subdomain)) {
return res.status(400).json({
error: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets',
});
}
// Vérifier que l'app n'existe pas déjà
const existingApp = config.apps.find((a) => a.id === subdomain);
if (existingApp) {
return res.status(409).json({
error: `Une application avec l'identifiant "${subdomain}" existe déjà`,
});
}
// Vérifier le fichier ZIP si sourceType === 'zip'
if (sourceType === 'zip' && !req.file) {
return res.status(400).json({
error: 'Un fichier ZIP est requis pour le type de source "zip"',
});
}
// Vérifier l'URL Gitea si sourceType === 'gitea'
if (sourceType === 'gitea' && !giteaRepoUrl) {
return res.status(400).json({
error: 'L\'URL du dépôt Gitea est requise pour le type de source "gitea"',
});
}
// Log de début de création et récupérer l'id pour mise à jour ultérieure
const logLines = [];
const createEntry = addDeploymentLog(subdomain, {
status: 'running',
message: `Création de l'application ${name} en cours...`,
logs: '',
user: req.user.username,
});
// Répondre immédiatement
res.json({
message: `Création de l'application ${name} lancée`,
status: 'running',
appId: subdomain,
});
// Lancer la création en arrière-plan
try {
const result = await createApplication(
{
name,
description,
subdomain,
stack,
port: port ? parseInt(port) : null,
needsDb: needsDb === 'true' || needsDb === true,
dbType: dbType || 'mysql',
sourceType,
giteaRepoUrl,
zipFilePath: req.file ? req.file.path : null,
},
(line) => logLines.push(line)
);
updateDeploymentLog(createEntry.id, {
status: 'success',
message: `Application ${name} créée et déployée avec succès`,
logs: logLines.join('\n'),
});
// Relancer un health check
setTimeout(() => checkAllApps(), 10000);
} catch (createErr) {
updateDeploymentLog(createEntry.id, {
status: 'failed',
message: `Création de ${name} échouée: ${createErr.message}`,
logs: logLines.join('\n'),
});
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ DEPLOYMENT LOGS ============
router.get('/deployments', authMiddleware, (req, res) => {
const appId = req.query.appId || null;
res.json(getDeploymentLogs(appId));
});
// ============ GITEA ROUTES ============
router.get('/gitea/repos', authMiddleware, async (req, res) => {
try {
const repos = await listRepos();
res.json(
repos.map((r) => ({
id: r.id,
name: r.name,
fullName: r.full_name,
description: r.description,
private: r.private,
htmlUrl: r.html_url,
cloneUrl: r.clone_url,
updatedAt: r.updated_at,
language: r.language,
size: r.size,
}))
);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/gitea/repos/:owner/:repo/commits', authMiddleware, async (req, res) => {
try {
const commits = await getCommits(req.params.owner, req.params.repo, 20);
res.json(commits);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res) => {
try {
const branches = await getBranches(req.params.owner, req.params.repo);
res.json(branches);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
router.get('/public/status', async (req, res) => {
try {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
const statuses = getAllStatuses();
const publicStatus = statuses.map((app) => ({
id: app.id,
name: app.name,
status: app.status, // 'online' | 'offline' | 'error' | 'unknown'
containerRunning: app.container ? app.container.running : false,
}));
res.json(publicStatus);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ PUBLIC APPS ROUTE (sans authentification) ============
// Utilisé par le portail applicatif pour construire les tuiles dynamiquement
// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles
router.get('/public/apps', (req, res) => {
try {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué
// On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse
const statuses = getAllStatuses();
const publicApps = config.apps
.filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard)
.map((a) => {
const status = statuses.find((s) => s.id === a.id);
return {
id: a.id,
name: a.name,
category: a.category || 'SANTINOVA',
image: a.image || ('images/' + a.id + '.png'),
urls: a.urls || {},
status: status ? status.status : 'unknown',
containerRunning: status && status.container ? status.container.running : false,
};
});
res.json(publicApps);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ DOCKER ROUTES ============
router.get('/docker/containers', authMiddleware, async (req, res) => {
try {
const containers = await listContainers();
res.json(containers);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ SYSTEM INFO ============
router.get('/system/info', authMiddleware, async (req, res) => {
try {
const containers = await listContainers();
res.json({
totalApps: config.apps.length,
totalContainers: containers.length,
runningContainers: containers.filter((c) => c.state === 'running').length,
giteaUrl: config.gitea.url,
serverTime: new Date().toISOString(),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ CONTAINER CONTROL (start/stop/restart) ============
router.post('/apps/:id/start', authMiddleware, async (req, res) => {
try {
const app = config.apps.find((a) => a.id === req.params.id);
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
const containerName = app.containerName || app.id;
const result = await startContainer(containerName);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/apps/:id/stop', authMiddleware, async (req, res) => {
try {
const app = config.apps.find((a) => a.id === req.params.id);
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
const containerName = app.containerName || app.id;
const result = await stopContainer(containerName);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/apps/:id/restart', authMiddleware, async (req, res) => {
try {
const app = config.apps.find((a) => a.id === req.params.id);
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
const containerName = app.containerName || app.id;
const result = await restartContainer(containerName);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ SERVER METRICS ============
router.get('/system/metrics', authMiddleware, async (req, res) => {
try {
const metrics = await getServerMetrics();
res.json(metrics);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
// ===== INVENTAIRE DES APPLICATIONS =====
const { exec } = require('child_process');
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
function getInventoryApps() {
return config.apps.map((a) => ({
id: a.id,
name: a.name,
repoName: a.giteaRepo || a.id,
}));
}
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
return new Promise((resolve) => {
const authHeader = token
? `-H "Authorization: token ${token}"`
: `-u "${GITEA_USER_INV}:${pass}"`;
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
exec(cmd, { timeout: 7000 }, (err, stdout) => {
if (err || !stdout) return resolve({ present: false, url: null, version: null });
try {
const data = JSON.parse(stdout);
if (!data.id) return resolve({ present: false, url: null, version: null });
// Récupérer le dernier commit
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
let version = null;
try {
const commits = JSON.parse(stdout2 || '[]');
if (commits.length > 0) {
const c = commits[0];
const sha = c.sha ? c.sha.substring(0, 7) : null;
const date = c.commit && c.commit.author && c.commit.author.date
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
version = sha && date ? `${sha} (${date})` : sha;
}
} catch(e) {}
const displayUrl = publicBaseUrl || baseUrl;
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
});
} catch(e) {
resolve({ present: false, url: null, version: null });
}
});
});
}
router.get('/inventory', authMiddleware, async (req, res) => {
try {
const owner = GITEA_USER_INV;
const INVENTORY_APPS = getInventoryApps();
const results = await Promise.all(
INVENTORY_APPS.map(async (app) => {
const [repoRecette, repoProd] = await Promise.all([
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
]);
return { ...app, repoRecette, repoProd };
})
);
res.json(results);
} catch (err) {
console.error('Erreur /inventory:', err.message);
res.status(500).json({ error: err.message });
}
});
;

179
backend/src/ssh.js Normal file
View File

@@ -0,0 +1,179 @@
/**
* Module SSH Terminal - Gestion des connexions SSH via WebSocket
* Utilise la bibliothèque ssh2 pour établir des connexions SSH
*/
const { Client } = require('ssh2');
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const config = require('./config');
/**
* Initialise le serveur WebSocket SSH sur /ws-ssh
* @param {http.Server} server - Le serveur HTTP Express
*/
function initSSHWebSocket(server) {
const wssSsh = new WebSocket.Server({ server, path: '/ws-ssh' });
wssSsh.on('connection', (ws, req) => {
// Vérifier l'authentification via le token dans l'URL
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
if (!token) {
ws.send(JSON.stringify({ type: 'error', message: 'Token manquant' }));
ws.close();
return;
}
try {
jwt.verify(token, config.jwtSecret);
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: 'Token invalide' }));
ws.close();
return;
}
console.log('Nouvelle connexion WebSocket SSH');
let sshClient = null;
let sshStream = null;
let connected = false;
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
switch (msg.type) {
case 'connect':
// Établir la connexion SSH
handleConnect(ws, msg, (client, stream) => {
sshClient = client;
sshStream = stream;
connected = true;
});
break;
case 'input':
// Envoyer des données au terminal SSH
if (sshStream && connected) {
sshStream.write(msg.data);
}
break;
case 'resize':
// Redimensionner le terminal
if (sshStream && connected) {
sshStream.setWindow(msg.rows, msg.cols, 0, 0);
}
break;
case 'disconnect':
// Fermer la connexion SSH
if (sshClient) {
sshClient.end();
}
break;
default:
console.warn('Type de message SSH inconnu:', msg.type);
}
} catch (err) {
console.error('Erreur parsing message SSH:', err.message);
}
});
ws.on('close', () => {
console.log('Connexion WebSocket SSH fermée');
if (sshClient) {
sshClient.end();
}
});
ws.on('error', (err) => {
console.error('Erreur WebSocket SSH:', err.message);
if (sshClient) {
sshClient.end();
}
});
});
console.log('Serveur WebSocket SSH initialisé sur /ws-ssh');
return wssSsh;
}
/**
* Gère la connexion SSH
*/
function handleConnect(ws, msg, onConnected) {
const { host, port, username, password, privateKey } = msg;
if (!host || !username) {
ws.send(JSON.stringify({ type: 'error', message: 'Hôte et utilisateur requis' }));
return;
}
const sshClient = new Client();
const connConfig = {
host: host,
port: port || 22,
username: username,
readyTimeout: 15000,
keepaliveInterval: 10000,
};
if (privateKey) {
connConfig.privateKey = privateKey;
} else if (password) {
connConfig.password = password;
} else {
ws.send(JSON.stringify({ type: 'error', message: 'Mot de passe ou clé privée requis' }));
return;
}
ws.send(JSON.stringify({ type: 'status', message: `Connexion à ${username}@${host}:${port || 22}...` }));
sshClient.on('ready', () => {
ws.send(JSON.stringify({ type: 'connected', message: `Connecté à ${host}` }));
sshClient.shell({ term: 'xterm-256color', rows: 24, cols: 80 }, (err, stream) => {
if (err) {
ws.send(JSON.stringify({ type: 'error', message: `Erreur shell: ${err.message}` }));
sshClient.end();
return;
}
onConnected(sshClient, stream);
// Transmettre les données du terminal SSH vers le WebSocket
stream.on('data', (data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
}
});
stream.stderr.on('data', (data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
}
});
stream.on('close', () => {
ws.send(JSON.stringify({ type: 'disconnected', message: 'Session SSH terminée' }));
sshClient.end();
});
});
});
sshClient.on('error', (err) => {
ws.send(JSON.stringify({ type: 'error', message: `Erreur SSH: ${err.message}` }));
});
sshClient.on('end', () => {
ws.send(JSON.stringify({ type: 'disconnected', message: 'Connexion SSH fermée' }));
});
sshClient.connect(connConfig);
}
module.exports = { initSSHWebSocket };

143
backend/src/webhook.js Normal file
View File

@@ -0,0 +1,143 @@
/**
* Manus Dashboard - Webhook Handler (CI/CD Gitea)
* Route : POST /api/webhook/gitea
* Écoute les événements push de Gitea et déclenche le déploiement automatique.
*/
const express = require('express');
const crypto = require('crypto');
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');
const router = express.Router();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy';
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
const REPO_TO_APP_MAP = {
'itinova-contacts': 'itinova-contacts',
'itinova-podcasts': 'itinova-podcasts',
'veille-reglementaire': 'veille-reglementaire',
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
'manus-dashboard': 'manus-dashboard',
'itinova-budget-si': 'itinova-budget-si',
};
// Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set();
function verifyGiteaSignature(req) {
if (!WEBHOOK_SECRET) {
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
return true;
}
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || '';
console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers)));
console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT');
console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0);
// Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser
const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
const expected = `sha256=${hmac}`;
const received = signature.startsWith('sha256=') ? signature : `sha256=${signature}`;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
} catch {
return false;
}
}
function runDeploy(appName, branch, commitHash, committer, broadcast) {
if (deployingApps.has(appName)) {
console.log(`[Webhook] Déploiement déjà en cours pour ${appName}. Ignoré.`);
return;
}
deployingApps.add(appName);
console.log(`[Webhook] Déclenchement déploiement: ${appName} (branch: ${branch}, commit: ${commitHash})`);
if (broadcast) {
broadcast({ type: 'deploy_started', data: { app: appName, commit: commitHash, branch, committer } });
}
const env = { ...process.env, APPS_BASE_PATH };
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
let output = '';
const child = exec(cmd, { env, timeout: 600000 });
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
child.on('close', (code) => {
deployingApps.delete(appName);
const status = code === 0 ? 'success' : 'failed';
console.log(`[Webhook] Déploiement ${status} pour ${appName} (exit: ${code})`);
if (broadcast) {
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
}
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
timestamp: new Date().toISOString(), output: output.slice(-3000),
}, null, 2));
} catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); }
});
}
// POST /api/webhook/gitea
router.post('/gitea', (req, res) => {
if (!verifyGiteaSignature(req)) {
console.warn('[Webhook] Signature invalide. Requête rejetée.');
return res.status(401).json({ error: 'Signature invalide' });
}
const event = req.headers['x-gitea-event'] || 'unknown';
if (event !== 'push') {
return res.status(200).json({ message: `Événement ${event} ignoré` });
}
const payload = req.body;
const repoName = payload?.repository?.name || '';
const branch = (payload?.ref || '').replace('refs/heads/', '');
const commitHash = (payload?.after || '').slice(0, 8) || 'unknown';
const committer = payload?.pusher?.login || payload?.pusher?.name || 'unknown';
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
const appName = REPO_TO_APP_MAP[repoName];
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
// Récupérer la fonction broadcast si disponible globalement
const broadcastFn = global.wsBroadcast || null;
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
});
// GET /api/webhook/status/:appName
router.get('/status/:appName', (req, res) => {
const { appName } = req.params;
const statusFile = path.join(LOG_DIR, `${appName}-last-deploy.json`);
if (!fs.existsSync(statusFile)) return res.status(404).json({ error: 'Aucun déploiement enregistré' });
try {
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
res.json({ ...status, isDeploying: deployingApps.has(appName) });
} catch { res.status(500).json({ error: 'Erreur lecture statut' }); }
});
// GET /api/webhook/status
router.get('/status', (req, res) => {
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
const files = fs.readdirSync(LOG_DIR).filter(f => f.endsWith('-last-deploy.json'));
const statuses = files.map(f => {
try { const d = JSON.parse(fs.readFileSync(path.join(LOG_DIR, f), 'utf8')); return { ...d, isDeploying: deployingApps.has(d.app) }; }
catch { return null; }
}).filter(Boolean);
res.json({ deployments: statuses, currentlyDeploying: [...deployingApps] });
} catch { res.status(500).json({ error: 'Erreur lecture statuts' }); }
});
module.exports = router;