Files
vpngen/backend/server.js
2026-04-07 17:41:25 +02:00

197 lines
5.5 KiB
JavaScript

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}`);
});