197 lines
5.5 KiB
JavaScript
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}`);
|
|
});
|