initial commit
This commit is contained in:
27
backend/backend.Dockerfile
Normal file
27
backend/backend.Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# Multi-stage build for Go + Node.js Backend
|
||||
# Stage 1: Build Go binary
|
||||
FROM golang:1.24-alpine AS builder
|
||||
# Use latest stable go that works (docker tag might not have 1.25.6 yet, alpine is fine)
|
||||
# Since the go.mod says 1.25.6, we'll let it use the toolchain if 1.24 is the base, or we just rely on standard go compilation.
|
||||
RUN apk add --no-cache git make gcc musl-dev
|
||||
|
||||
WORKDIR /src
|
||||
COPY protonvpn-wg-confgen/ ./
|
||||
ENV GOTOOLCHAIN=auto
|
||||
RUN go build -o /bin/protonvpn-wg-confgen cmd/protonvpn-wg/main.go
|
||||
|
||||
# Stage 2: Node.js Backend Production setup
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Install CA certs needed for Proton API calls
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy the compiled Go binary from Stage 1
|
||||
COPY --from=builder /bin/protonvpn-wg-confgen /usr/local/bin/protonvpn-wg-confgen
|
||||
|
||||
# Setup the Node JS backend
|
||||
COPY backend/package.json ./
|
||||
RUN npm install
|
||||
COPY backend/ ./
|
||||
CMD ["npm", "run", "dev"]
|
||||
14
backend/package.json
Normal file
14
backend/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "protonvpn-webui-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0"
|
||||
}
|
||||
}
|
||||
196
backend/server.js
Normal file
196
backend/server.js
Normal file
@@ -0,0 +1,196 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user