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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,5 @@ jobs:
script: |
cd /var/www/vps-monitor
git pull origin staging
cd /var/www
docker compose build vps-monitor
docker compose up -d vps-monitor
docker compose -f deployment/docker-compose.yml build
docker compose -f deployment/docker-compose.yml up -d
50 changes: 28 additions & 22 deletions app/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { getContainers, restartContainer, stopContainer, startContainer, streamContainerLogs } from './services/docker.js';
import { checkWebsites } from './services/http.js';
import { listEditableFiles, getFileContent, writeAndCommit } from './services/git.js';
import { testConfig, reload as reloadNginx } from './services/nginx.js';
import { testConfig, reload as reloadNginx, readConfig, writeConfig, parseApps, parseConfigMeta, addApp, removeApp } from './services/nginx.js';
import { listApps, cloneApp, updateApp, getAppStatus } from './services/deploy.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -120,44 +119,51 @@ app.post('/api/container/start', requireAuth, async (req, res) => {
}
});

// --- API Config (protégée) ---
// --- API Nginx (protégée) ---

app.get('/api/config/files', requireAuth, async (_req, res) => {
app.get('/api/nginx/apps', requireAuth, async (_req, res) => {
try {
res.json(await listEditableFiles());
const content = await readConfig();
res.json({ apps: parseApps(content), ...parseConfigMeta(content) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});

app.get('/api/config/file', requireAuth, async (req, res) => {
const { path } = req.query;
if (!path) return res.status(400).json({ error: 'path requis' });
try {
res.json({ content: await getFileContent(path) });
} catch (err) {
res.status(err.message === 'Chemin non autorisé' ? 403 : 500).json({ error: err.message });
}
});
app.post('/api/nginx/apps', requireAuth, async (req, res) => {
const { path, port } = req.body;
if (!path || !port) return res.status(400).json({ error: 'path et port requis' });
if (!/^\/[a-zA-Z0-9][a-zA-Z0-9_\-./]*\/$/.test(path)) return res.status(400).json({ error: `Chemin invalide: ${path}` });
if (!Number.isInteger(port) || port < 1 || port > 65535) return res.status(400).json({ error: `Port invalide: ${port}` });

app.post('/api/config/file', requireAuth, async (req, res) => {
const { path, content } = req.body;
if (!path || content === undefined) return res.status(400).json({ error: 'path et content requis' });
let previous;
try {
await writeAndCommit(path, content);
previous = await readConfig();
await addApp(path, port);
const test = await testConfig();
if (!test.ok) { await writeConfig(previous); return res.status(422).json({ ok: false, output: test.output }); }
await reloadNginx();
res.json({ ok: true });
} catch (err) {
res.status(err.message === 'Chemin non autorisé' ? 403 : 500).json({ error: err.message });
if (previous) try { await writeConfig(previous); } catch { /* ignore */ }
res.status(500).json({ error: err.message });
}
});

app.post('/api/config/nginx/reload', requireAuth, async (_req, res) => {
app.delete('/api/nginx/apps', requireAuth, async (req, res) => {
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'path requis' });

let previous;
try {
previous = await readConfig();
await removeApp(path);
const test = await testConfig();
if (!test.ok) return res.status(422).json({ ok: false, output: test.output });
if (!test.ok) { await writeConfig(previous); return res.status(422).json({ ok: false, output: test.output }); }
await reloadNginx();
res.json({ ok: true, output: test.output });
res.json({ ok: true });
} catch (err) {
if (previous) try { await writeConfig(previous); } catch { /* ignore */ }
res.status(500).json({ error: err.message });
}
});
Expand Down
26 changes: 21 additions & 5 deletions app/api/services/deploy.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdir, access } from 'fs/promises';
import { readFile, access } from 'fs/promises';
import { join } from 'path';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
Expand All @@ -8,13 +8,29 @@ const execFile = promisify(execFileCb);
const REPO_ROOT = process.env.VPSCONFIG_PATH || '/var/www/vps-monitor';
const APPS_ROOT = '/var/www';

async function readRegistry() {
const raw = await readFile(join(REPO_ROOT, 'apps.json'), 'utf8');
return JSON.parse(raw);
}

function safeName(name) {
if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error('Nom d\'app invalide');
return name;
}

function compose(appPath, ...args) {
return execFile('docker', ['compose', ...args], { cwd: appPath });
async function getComposeCwd(appPath) {
const deploymentPath = join(appPath, 'deployment');
try {
await access(join(deploymentPath, 'docker-compose.yml'));
return deploymentPath;
} catch {
return appPath;
}
}

async function compose(appPath, ...args) {
const cwd = await getComposeCwd(appPath);
return execFile('docker', ['compose', ...args], { cwd });
}

export async function getAppStatus(name) {
Expand All @@ -37,8 +53,8 @@ export async function getAppStatus(name) {

export async function listApps() {
try {
const entries = await readdir(join(REPO_ROOT, 'apps'));
return Promise.all(entries.map(getAppStatus));
const registry = await readRegistry();
return Promise.all(registry.map(({ name }) => getAppStatus(name)));
} catch {
return [];
}
Expand Down
31 changes: 1 addition & 30 deletions app/api/services/git.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFile, writeFile, readdir } from 'fs/promises';
import { readFile, writeFile } from 'fs/promises';
import { join, normalize } from 'path';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
Expand Down Expand Up @@ -30,32 +30,3 @@ export async function writeAndCommit(relativePath, content) {
await git('push');
}

export async function listEditableFiles() {
const files = [];

try {
const entries = await readdir(join(REPO_ROOT, 'nginx', 'sites-enabled'));
for (const entry of entries) {
files.push(`nginx/sites-enabled/${entry}`);
}
} catch {
// dossier absent
}

try {
const apps = await readdir(join(REPO_ROOT, 'apps'));
for (const app of apps) {
const rel = `apps/${app}/docker-compose.yml`;
try {
await readFile(join(REPO_ROOT, rel));
files.push(rel);
} catch {
// pas de compose pour cette app
}
}
} catch {
// dossier absent
}

return files;
}
58 changes: 55 additions & 3 deletions app/api/services/nginx.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,69 @@
import { readFile, writeFile } from 'fs/promises';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';

const execFile = promisify(execFileCb);

const NGINX_CONFIG = process.env.NGINX_CONFIG || '/etc/nginx/sites-enabled/vps';

export async function testConfig() {
try {
const { stdout, stderr } = await execFile('sudo', ['/usr/sbin/nginx', '-t']);
const { stdout, stderr } = await execFile('/usr/sbin/nginx', ['-t']);
return { ok: true, output: stdout + stderr };
} catch (err) {
return { ok: false, output: err.stdout + err.stderr };
return { ok: false, output: (err.stdout ?? '') + (err.stderr ?? '') };
}
}

export async function reload() {
await execFile('sudo', ['/bin/systemctl', 'reload', 'nginx']);
await execFile('/usr/sbin/nginx', ['-s', 'reload']);
}

export async function readConfig() {
return readFile(NGINX_CONFIG, 'utf8');
}

export async function writeConfig(content) {
await writeFile(NGINX_CONFIG, content, 'utf8');
}

export function parseApps(content) {
const apps = [];
const blockRegex = /location\s+(\/[^\s{]+)\s*\{([^}]+)\}/g;
for (const match of content.matchAll(blockRegex)) {
const path = match[1].trim();
if (path === '/') continue;
const portMatch = match[2].match(/proxy_pass\s+http:\/\/127\.0\.0\.1:(\d+)/);
if (portMatch) apps.push({ path, port: parseInt(portMatch[1], 10) });
}
return apps;
}

export function parseConfigMeta(content) {
const serverName = content.match(/server_name\s+([^;]+);/)?.[1].trim() ?? '127.0.0.1';
const rootBlock = content.match(/location\s+\/\s*\{([^}]+)\}/)?.[1] ?? '';
const rootPort = parseInt(
rootBlock.match(/proxy_pass\s+http:\/\/127\.0\.0\.1:(\d+)/)?.[1] ?? '3020',
10,
);
return { serverName, rootPort };
}

export async function addApp(path, port) {
const content = await readConfig();
const block = `
location ${path} {
proxy_pass http://127.0.0.1:${port}/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}`;
await writeConfig(content.trimEnd().replace(/\n\}$/, `\n${block}\n}`) + '\n');
}

export async function removeApp(path) {
const content = await readConfig();
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const blockRegex = new RegExp(`\\n[ \\t]*location\\s+${escaped}\\s*\\{[\\s\\S]*?\\}`, 'g');
await writeConfig(content.replace(blockRegex, ''));
}
2 changes: 1 addition & 1 deletion app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default [
},
},
rules: {
'no-unused-vars': ['warn', { varsIgnorePattern: '^(containerAction|logout|showLogs|closeLogs|showTab|openConfig|closeConfig|saveConfig|nginxReload|updateDeployApp|promptClone|cloneNewApp)$' }],
'no-unused-vars': ['warn', { varsIgnorePattern: '^(containerAction|logout|showLogs|closeLogs|showTab|nginxAddApp|nginxRemoveApp|updateDeployApp|promptClone|cloneNewApp)$' }],
},
},
];
109 changes: 66 additions & 43 deletions app/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,65 +90,88 @@ function showTab(id) {
if (id === 'deploy') loadDeploy();
}

// --- Configs ---
// --- Nginx ---

let currentConfigPath = null;
let nginxApps = [];

async function loadConfigs() {
const res = await fetch('/api/config/files');
const res = await fetch('/api/nginx/apps');
if (res.status === 401) { window.location.href = '/login.html'; return; }
const files = await res.json();
const el = document.getElementById('config-files');
if (!files.length) {
el.innerHTML = '<li class="file-empty">Aucun fichier trouvé (dossiers nginx/ et apps/ absents)</li>';
const { apps, serverName } = await res.json();
nginxApps = apps;
document.getElementById('nginx-server-info').textContent = `Serveur : ${serverName}`;
renderNginxApps();
}

function renderNginxApps() {
const el = document.getElementById('nginx-apps');
if (!nginxApps.length) {
el.innerHTML = '<div class="file-empty">Aucune application configurée</div>';
return;
}
el.innerHTML = files.map((f) => `<li class="file-item" onclick="openConfig('${f}')">${f}</li>`).join('');
el.innerHTML = nginxApps.map((a) => `
<div class="card">
<div class="card-header">
<span class="card-name">${a.path}</span>
<button class="card-delete" onclick="nginxRemoveApp('${a.path}', this)">✕</button>
</div>
<div class="card-meta">
<div>Port : <strong>${a.port}</strong></div>
<div>→ http://127.0.0.1:${a.port}/</div>
</div>
</div>
`).join('');
}

async function openConfig(path) {
currentConfigPath = path;
document.getElementById('config-title').textContent = path;
document.getElementById('config-save-status').textContent = '';
const isNginx = path.startsWith('nginx/');
document.getElementById('nginx-reload-btn').classList.toggle('hidden', !isNginx);

const modal = document.getElementById('config-modal');
const textarea = document.getElementById('config-content');
textarea.value = 'Chargement…';
modal.classList.remove('hidden');

const res = await fetch(`/api/config/file?path=${encodeURIComponent(path)}`);
if (!res.ok) { textarea.value = `Erreur: ${(await res.json()).error}`; return; }
textarea.value = (await res.json()).content;
async function nginxRemoveApp(path, btn) {
btn.disabled = true;
btn.textContent = '…';
const statusEl = document.getElementById('nginx-status');
const res = await fetch('/api/nginx/apps', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (res.ok) {
statusEl.textContent = '';
await loadConfigs();
} else {
statusEl.textContent = `❌ ${data.output || data.error}`;
btn.disabled = false;
btn.textContent = '✕';
}
}

function closeConfig() {
document.getElementById('config-modal').classList.add('hidden');
currentConfigPath = null;
}
async function nginxAddApp() {
const path = document.getElementById('nginx-add-path').value.trim();
const port = parseInt(document.getElementById('nginx-add-port').value, 10);
const statusEl = document.getElementById('nginx-status');

async function saveConfig() {
if (!currentConfigPath) return;
const content = document.getElementById('config-content').value;
const statusEl = document.getElementById('config-save-status');
statusEl.textContent = 'Sauvegarde…';
if (!path.startsWith('/') || !path.endsWith('/')) {
statusEl.textContent = '❌ Le chemin doit commencer et finir par /';
return;
}
if (!port || port < 1 || port > 65535) {
statusEl.textContent = '❌ Port invalide';
return;
}

const res = await fetch('/api/config/file', {
statusEl.textContent = 'Ajout en cours…';
const res = await fetch('/api/nginx/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentConfigPath, content }),
body: JSON.stringify({ path, port }),
});
const data = await res.json();
statusEl.textContent = res.ok ? '✅ Sauvegardé' : `❌ ${data.error}`;
}

async function nginxReload() {
const statusEl = document.getElementById('config-save-status');
statusEl.textContent = 'Test + rechargement…';
const res = await fetch('/api/config/nginx/reload', { method: 'POST' });
const data = await res.json();
statusEl.textContent = res.ok ? '✅ Nginx rechargé' : `❌ Config invalide:\n${data.output}`;
if (res.ok) {
document.getElementById('nginx-add-path').value = '';
document.getElementById('nginx-add-port').value = '';
statusEl.textContent = '✅ App ajoutée';
await loadConfigs();
} else {
statusEl.textContent = `❌ ${data.output || data.error}`;
}
}

// --- Deploy ---
Expand Down
Loading
Loading