feat: terminal SSH, inventaire synchronisation, token Gitea, pilotage-masse-salariale
- Ajout terminal SSH intégré (xterm.js + WebSocket + ssh2) - Colonne Synchronisation dans l inventaire (recette vs prod) - Compteurs statistiques déplacés en haut de l inventaire - Authentification Gitea par token API (plus d auth basique) - URL Gitea interne stable (http://gitea:3000) - Ajout pilotage-masse-salariale dans config.js et inventaire - Correction lien git.recette dans GiteaPage - Mise à jour docker-compose.yml (GITEA_TOKEN, GITEA_RECETTE_URL stable)
This commit is contained in:
@@ -21,7 +21,8 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"adm-zip": "^0.5.10"
|
||||
"adm-zip": "^0.5.10",
|
||||
"ssh2": "^1.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
|
||||
@@ -12,6 +12,7 @@ module.exports = {
|
||||
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',
|
||||
@@ -65,7 +66,7 @@ module.exports = {
|
||||
recette: 'https://veille.recette.santinova-soft.org',
|
||||
prod: 'https://veille.santinova-soft.org',
|
||||
},
|
||||
containerName: 'veille-reglementaire',
|
||||
containerName: 'veille-reglementaire-recette',
|
||||
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
@@ -88,6 +89,23 @@ module.exports = {
|
||||
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',
|
||||
@@ -190,5 +208,22 @@ module.exports = {
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,12 +3,17 @@ 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`,
|
||||
auth: {
|
||||
username: config.gitea.username,
|
||||
password: config.gitea.password,
|
||||
},
|
||||
...(config.gitea.token
|
||||
? { headers: giteaClientHeaders }
|
||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
||||
),
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
@@ -15,12 +15,14 @@ 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({
|
||||
|
||||
@@ -503,6 +503,7 @@ 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_TOKEN || null;
|
||||
|
||||
const INVENTORY_APPS = [
|
||||
{ id: 'itinova-contacts', name: 'Itinova Contacts', repoName: 'itinova-contacts' },
|
||||
@@ -513,21 +514,26 @@ const INVENTORY_APPS = [
|
||||
{ id: 'demat-facturation-dsi', name: 'Démat. Facturation DSI', repoName: 'demat-facturation-dsi' },
|
||||
{ id: 'facturation-santinova', name: 'Facturation Santinova', repoName: 'facturation-santinova' },
|
||||
{ id: 'formation-manager-itinova', name: 'Formation Manager', repoName: 'formation-manager-itinova' },
|
||||
{ id: 'pilotage-masse-salariale', name: 'Pilotage Masse Salariale', repoName: 'pilotage-masse-salariale' },
|
||||
{ id: 'itinova-budget-si', name: 'Gestion Budget Informatique', repoName: 'itinova-budget-si' },
|
||||
{ id: 'falc-generator', name: 'FALC Generator', repoName: 'falc-generator' },
|
||||
{ id: 'portail-santinova', name: 'Portail Applicatif', repoName: 'portail-santinova' },
|
||||
{ id: 'manus-dashboard', name: 'Dashboard Manus', repoName: 'manus-dashboard' },
|
||||
];
|
||||
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl) {
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
||||
return new Promise((resolve) => {
|
||||
const auth = `${GITEA_USER_INV}:${pass}`;
|
||||
const cmd = `curl -sk --max-time 6 -u "${auth}" "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
||||
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 -u "${auth}" "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
||||
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 {
|
||||
@@ -556,7 +562,7 @@ router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
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),
|
||||
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 };
|
||||
|
||||
179
src/backend/src/ssh.js
Normal file
179
src/backend/src/ssh.js
Normal 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 };
|
||||
@@ -23,6 +23,7 @@ const REPO_TO_APP_MAP = {
|
||||
'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)
|
||||
|
||||
Reference in New Issue
Block a user