import express from 'express'; import cors from 'cors'; import { spawn } from 'child_process'; import crypto from 'crypto'; import path from 'path'; import fs from 'fs/promises'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const port = process.env.PORT || 3000; app.use(cors()); app.use(express.json()); // In-memory process store for SSE and interactive stdin const activeTasks = new Map(); // Helper to construct command arguments function buildArgs(body, outputFile) { const args = []; const username = body.username || process.env.PROTON_USERNAME; const password = body.password || process.env.PROTON_PASSWORD; if (username) { args.push('-username', username); } if (password) { args.push('-password', password); } if (body.countries) { args.push('-countries', body.countries); // e.g., "US,NL" } args.push('-output', outputFile); if (body.p2pOnly) args.push('-p2p-only'); if (body.secureCore) args.push('-secure-core'); if (body.freeOnly) args.push('-free-only'); if (body.ipv6) args.push('-ipv6'); if (body.accelerator === false) args.push('-accelerator=false'); if (body.duration) args.push('-duration', body.duration); // We use clear-session to force fresh auth if needed, or default behavior // For web UI, maybe we want to always force clear session so it doesn't use old cached sessions // that belong to root from inside docker? Let's give user option, typical web user wants it fresh. args.push('-clear-session'); return args; } app.post('/api/generate', async (req, res) => { const taskId = crypto.randomUUID(); const outputFile = path.join(__dirname, `protonvpn-${taskId}.conf`); const args = buildArgs(req.body, outputFile); // Default to system PATH if compiled, handle local testing fallback let binPath = 'protonvpn-wg-confgen'; console.log(`Starting task ${taskId}: ${binPath} ${args.join(' ')}`); // Create process const child = spawn(binPath, args); // Store task activeTasks.set(taskId, { child, outputFile, clients: [] }); child.stdout.on('data', (data) => { const text = data.toString(); console.log(`[${taskId} stdout]`, text); broadcastToClients(taskId, 'log', { text }); // Check for interactive prompts from Go tool if (text.includes('2FA Code:')) { broadcastToClients(taskId, 'require_2fa', {}); } }); child.stderr.on('data', (data) => { const text = data.toString(); console.log(`[${taskId} stderr]`, text); broadcastToClients(taskId, 'log', { text, error: true }); }); child.on('close', async (code) => { console.log(`[${taskId} exited with code ${code}]`); let fileContent = null; let success = code === 0; if (success) { try { fileContent = await fs.readFile(outputFile, 'utf8'); // Clean up the file await fs.unlink(outputFile).catch(() => {}); } catch (err) { console.error('Failed to read configuration file', err); success = false; } } broadcastToClients(taskId, 'complete', { success, code, fileContent }); // Cleanup task memory shortly after complete setTimeout(() => { const task = activeTasks.get(taskId); if (task) { task.clients.forEach(c => c.end()); activeTasks.delete(taskId); } }, 5000); }); child.on('error', (err) => { console.error(`[${taskId} ERROR]`, err); broadcastToClients(taskId, 'log', { text: `Failed to start process: ${err.message}`, error: true }); broadcastToClients(taskId, 'complete', { success: false, code: -1 }); }); res.json({ taskId }); }); // SSE endpoint app.get('/api/stream/:taskId', (req, res) => { const { taskId } = req.params; const task = activeTasks.get(taskId); if (!task) { return res.status(404).send('Task not found'); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); task.clients.push(res); req.on('close', () => { task.clients = task.clients.filter(client => client !== res); }); }); app.post('/api/2fa', (req, res) => { const { taskId, code } = req.body; const task = activeTasks.get(taskId); if (!task) { return res.status(404).json({ error: 'Task not found' }); } if (!task.child.stdin.writable) { return res.status(400).json({ error: 'Process not accepting input' }); } // Write the code followed by newline to the process stdin task.child.stdin.write(`${code}\n`); res.json({ success: true }); }); const POPULAR_COUNTRIES = [ { code: 'US', name: 'United States' }, { code: 'NL', name: 'Netherlands' }, { code: 'CH', name: 'Switzerland' }, { code: 'JP', name: 'Japan' }, { code: 'UK', name: 'United Kingdom' }, { code: 'DE', name: 'Germany' }, { code: 'CA', name: 'Canada' }, { code: 'FR', name: 'France' }, { code: 'SE', name: 'Sweden' }, { code: 'IS', name: 'Iceland' } ]; app.get('/api/countries', (req, res) => { res.json(POPULAR_COUNTRIES); }); // Helper for SSE function broadcastToClients(taskId, eventName, data) { const task = activeTasks.get(taskId); if (!task) return; const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; task.clients.forEach(client => client.write(payload)); } app.listen(port, () => { console.log(`Backend server running on http://localhost:${port}`); });