streamflow/backend/routes/vpn-configs.js

739 lines
24 KiB
JavaScript
Raw Normal View History

const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
const { db } = require('../database/db');
const { spawn } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const { modifyLimiter, readLimiter } = require('../middleware/rateLimiter');
const encryption = require('../utils/encryption');
// Use centralized encryption module for CWE-311 compliance
const encrypt = (data) => encryption.encryptVPN(data);
const decrypt = (data) => encryption.decryptVPN(data);
// Parse WireGuard config
function parseWireGuardConfig(content) {
// Security: Limit content size
if (content.length > 100000) {
throw new Error('Configuration file too large');
}
// Security: Check for script injection attempts
if (content.includes('<script') || content.includes('${') || content.includes('eval(')) {
throw new Error('Invalid configuration content detected');
}
const lines = content.split('\n');
const parsed = {
type: 'wireguard',
interface: {},
peer: {}
};
let section = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '[Interface]') {
section = 'interface';
} else if (trimmed === '[Peer]') {
section = 'peer';
} else if (trimmed && !trimmed.startsWith('#') && section) {
const [key, ...valueParts] = trimmed.split('=');
if (key && valueParts.length > 0) {
const value = valueParts.join('=').trim();
// Security: Sanitize values, no shell commands
if (value.includes('$(') || value.includes('`') || value.includes(';')) {
throw new Error('Invalid configuration value detected');
}
parsed[section][key.trim()] = value;
}
}
}
// Validate required fields
console.log('[VPN-CONFIG] Parsed interface:', Object.keys(parsed.interface));
console.log('[VPN-CONFIG] Parsed peer:', Object.keys(parsed.peer));
if (!parsed.interface.PrivateKey || !parsed.peer.PublicKey || !parsed.peer.Endpoint) {
console.log('[VPN-CONFIG] Missing fields - PrivateKey:', !!parsed.interface.PrivateKey, 'PublicKey:', !!parsed.peer.PublicKey, 'Endpoint:', !!parsed.peer.Endpoint);
throw new Error('Missing required WireGuard configuration fields');
}
// Extract metadata
const endpoint = parsed.peer.Endpoint || '';
const country = extractCountryFromEndpoint(endpoint);
const serverName = endpoint.split(':')[0];
return {
type: 'wireguard',
country,
serverName,
endpoint,
data: parsed
};
}
// Parse OpenVPN config
function parseOpenVPNConfig(content) {
// Security: Limit content size
if (content.length > 100000) {
throw new Error('Configuration file too large');
}
// Security: Check for script injection attempts
if (content.includes('<script') || content.includes('${') || content.includes('eval(')) {
throw new Error('Invalid configuration content detected');
}
// Security: Check for dangerous directives
const dangerousDirectives = ['script-security 3', 'up /bin/sh', 'down /bin/sh', 'route-up /bin/sh'];
for (const directive of dangerousDirectives) {
if (content.includes(directive)) {
throw new Error('Configuration contains potentially dangerous directives');
}
}
const lines = content.split('\n');
const parsed = {
type: 'openvpn',
remote: null,
port: null,
proto: null
};
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('remote ')) {
const parts = trimmed.split(/\s+/);
parsed.remote = parts[1];
parsed.port = parts[2] || '1194';
parsed.proto = parts[3] || 'udp';
}
}
// Validate required fields
if (!parsed.remote) {
throw new Error('Missing required OpenVPN remote server');
}
// Extract metadata
const country = extractCountryFromEndpoint(parsed.remote);
const serverName = parsed.remote || 'Unknown';
return {
type: 'openvpn',
country,
serverName,
endpoint: `${parsed.remote}:${parsed.port}`,
proto: parsed.proto,
data: { config: content }
};
}
// Extract country code from endpoint/hostname
function extractCountryFromEndpoint(endpoint) {
if (!endpoint) return null;
// Common patterns: us-01.server.com, node-us-01, 185.107.57.98 (Romania)
const countryMatch = endpoint.match(/[-_]([a-z]{2})[-_\d]/i);
if (countryMatch) {
return countryMatch[1].toUpperCase();
}
// Known IP ranges (basic lookup)
const ipRanges = {
'185.107.57': 'RO',
'185.163.110': 'RO',
'169.150': 'US',
'103.107': 'JP'
};
for (const [range, country] of Object.entries(ipRanges)) {
if (endpoint.startsWith(range)) {
return country;
}
}
return null;
}
// Get all VPN configs for user
router.get('/configs', authenticate, readLimiter, async (req, res) => {
try {
const configs = await new Promise((resolve, reject) => {
db.all(
`SELECT id, name, config_type, country, server_name, endpoint, is_active, created_at
FROM vpn_configs
WHERE user_id = ?
ORDER BY is_active DESC, created_at DESC`,
[req.user.userId],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
// Verify actual VPN connection status for active configs
for (const config of configs) {
if (config.is_active) {
const actuallyConnected = await checkVPNConnection(config.config_type, req.user.userId);
if (!actuallyConnected) {
// Update database to reflect actual state
await new Promise((resolve, reject) => {
db.run(
'UPDATE vpn_configs SET is_active = 0 WHERE id = ?',
[config.id],
(err) => (err ? reject(err) : resolve())
);
});
config.is_active = 0;
console.log(`[VPN-CONFIG] Reset stale active state for config ${config.id}`);
}
}
}
res.json({ configs });
} catch (err) {
console.error('Error fetching VPN configs:', err);
res.status(500).json({ error: 'Failed to fetch VPN configurations' });
}
});
// Get specific config
router.get('/configs/:id', authenticate, readLimiter, (req, res) => {
db.get(
`SELECT id, name, config_type, config_data, country, server_name, endpoint, is_active, created_at
FROM vpn_configs
WHERE id = ? AND user_id = ?`,
[req.params.id, req.user.userId],
(err, config) => {
if (err) {
console.error('Error fetching VPN config:', err);
return res.status(500).json({ error: 'Failed to fetch configuration' });
}
if (!config) {
return res.status(404).json({ error: 'Configuration not found' });
}
// Decrypt config data
try {
config.config_data = JSON.stringify(decrypt(config.config_data));
} catch (error) {
console.error('Error decrypting config:', error);
return res.status(500).json({ error: 'Failed to decrypt configuration' });
}
res.json({ config });
}
);
});
// Upload VPN config file
router.post('/configs/upload', authenticate, modifyLimiter, async (req, res) => {
try {
console.log('[VPN-CONFIG] Upload request received');
console.log('[VPN-CONFIG] Files:', req.files ? Object.keys(req.files) : 'none');
// CWE-532: Do not log request body - may contain sensitive VPN credentials
if (!req.files || !req.files.config) {
return res.status(400).json({ error: 'No file uploaded' });
}
const uploadedFile = req.files.config;
// Validate file size (1MB max)
if (uploadedFile.size > 1024 * 1024) {
return res.status(400).json({ error: 'File too large (max 1MB)' });
}
// Validate file extension
const ext = path.extname(uploadedFile.name).toLowerCase();
if (ext !== '.conf' && ext !== '.ovpn') {
return res.status(400).json({ error: 'Only .conf and .ovpn files are allowed' });
}
const { name } = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Configuration name is required' });
}
// Validate name
if (!/^[a-zA-Z0-9\s\-_.()]+$/.test(name)) {
return res.status(400).json({ error: 'Invalid configuration name. Use only letters, numbers, spaces, and common punctuation.' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'Configuration name too long (max 100 characters)' });
}
// Read file content from temp file
const content = await fs.readFile(uploadedFile.tempFilePath, 'utf8');
console.log('[VPN-CONFIG] File extension:', ext);
console.log('[VPN-CONFIG] Content length:', content.length);
console.log('[VPN-CONFIG] First 200 chars:', content.substring(0, 200));
// Parse based on file type
let parsed;
if (ext === '.conf') {
parsed = parseWireGuardConfig(content);
} else if (ext === '.ovpn') {
parsed = parseOpenVPNConfig(content);
} else {
return res.status(400).json({ error: 'Unsupported file format' });
}
// Encrypt config data using centralized encryption (CWE-311)
const encryptedData = encrypt(parsed.data);
// Save to database
db.run(
`INSERT INTO vpn_configs (user_id, name, config_type, config_data, country, server_name, endpoint, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)`,
[req.user.userId, name.trim(), parsed.type, encryptedData, parsed.country, parsed.serverName, parsed.endpoint],
function(err) {
if (err) {
console.error('Error saving VPN config:', err);
return res.status(500).json({ error: 'Failed to save configuration' });
}
res.json({
message: 'Configuration uploaded successfully',
config: {
id: this.lastID,
name: name.trim(),
type: parsed.type,
country: parsed.country,
serverName: parsed.serverName,
endpoint: parsed.endpoint
}
});
}
);
} catch (error) {
console.error('[VPN-CONFIG] Error processing config upload:', error);
console.error('[VPN-CONFIG] Error stack:', error.stack);
res.status(500).json({ error: error.message || 'Failed to process configuration file' });
}
});
// Delete config
router.delete('/configs/:id', authenticate, modifyLimiter, (req, res) => {
// First check if config is active
db.get(
'SELECT is_active FROM vpn_configs WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
(err, config) => {
if (err) {
console.error('Error checking config:', err);
return res.status(500).json({ error: 'Failed to delete configuration' });
}
if (!config) {
return res.status(404).json({ error: 'Configuration not found' });
}
if (config.is_active) {
return res.status(400).json({ error: 'Cannot delete active configuration. Disconnect first.' });
}
// Delete the config
db.run(
'DELETE FROM vpn_configs WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
function(err) {
if (err) {
console.error('Error deleting config:', err);
return res.status(500).json({ error: 'Failed to delete configuration' });
}
res.json({ message: 'Configuration deleted successfully' });
}
);
}
);
});
// Set active config
router.post('/configs/:id/activate', authenticate, modifyLimiter, (req, res) => {
db.serialize(() => {
// Deactivate all configs for user
db.run(
'UPDATE vpn_configs SET is_active = 0 WHERE user_id = ?',
[req.user.userId],
(err) => {
if (err) {
console.error('Error deactivating configs:', err);
return res.status(500).json({ error: 'Failed to activate configuration' });
}
// Activate the selected config
db.run(
'UPDATE vpn_configs SET is_active = 1 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
function(err) {
if (err) {
console.error('Error activating config:', err);
return res.status(500).json({ error: 'Failed to activate configuration' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Configuration not found' });
}
res.json({ message: 'Configuration activated successfully' });
}
);
}
);
});
});
// Connect using config
router.post('/configs/:id/connect', authenticate, modifyLimiter, async (req, res) => {
try {
// Get config
const config = await new Promise((resolve, reject) => {
db.get(
'SELECT config_type, config_data FROM vpn_configs WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!config) {
return res.status(404).json({ error: 'Configuration not found' });
}
// Decrypt config
const decryptedData = decrypt(config.config_data);
console.log(`[VPN-CONFIG] Connecting config ${req.params.id} for user ${req.user.userId}`);
// Connect based on type
if (config.config_type === 'wireguard') {
await connectWireGuard(decryptedData, req.user.userId);
} else if (config.config_type === 'openvpn') {
await connectOpenVPN(decryptedData, req.user.userId);
}
console.log(`[VPN-CONFIG] Successfully connected, updating database`);
// Deactivate all other configs for this user first
await new Promise((resolve, reject) => {
db.run(
'UPDATE vpn_configs SET is_active = 0 WHERE user_id = ?',
[req.user.userId],
(err) => (err ? reject(err) : resolve())
);
});
// Mark this config as active
await new Promise((resolve, reject) => {
db.run(
'UPDATE vpn_configs SET is_active = 1 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
(err) => (err ? reject(err) : resolve())
);
});
// CWE-532: Logged without exposing sensitive config ID
console.log(`[VPN-CONFIG] Configuration marked as active for user ${req.user.userId}`);
res.json({ message: 'Connected to VPN successfully', success: true });
} catch (error) {
console.error('Error connecting to VPN:', error);
res.status(500).json({ error: error.message || 'Failed to connect to VPN' });
}
});
// Disconnect VPN
router.post('/configs/:id/disconnect', authenticate, modifyLimiter, async (req, res) => {
try {
// Get config
const config = await new Promise((resolve, reject) => {
db.get(
'SELECT config_type FROM vpn_configs WHERE id = ? AND user_id = ? AND is_active = 1',
[req.params.id, req.user.userId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!config) {
// No active config found, but still try to clean up any interfaces
console.log('[VPN] No active config found, attempting cleanup anyway');
try {
await disconnectWireGuard(req.user.userId);
} catch (e) {
// Ignore errors during cleanup
}
// Mark as inactive regardless
await new Promise((resolve, reject) => {
db.run(
'UPDATE vpn_configs SET is_active = 0 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
(err) => (err ? reject(err) : resolve())
);
});
return res.json({ message: 'VPN state cleaned up' });
}
// Disconnect based on type
if (config.config_type === 'wireguard') {
await disconnectWireGuard(req.user.userId);
} else if (config.config_type === 'openvpn') {
await disconnectOpenVPN(req.user.userId);
}
// Mark as inactive
await new Promise((resolve, reject) => {
db.run(
'UPDATE vpn_configs SET is_active = 0 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId],
(err) => (err ? reject(err) : resolve())
);
});
res.json({ message: 'Disconnected from VPN successfully' });
} catch (error) {
console.error('Error disconnecting from VPN:', error);
res.status(500).json({ error: error.message || 'Failed to disconnect from VPN' });
}
});
// Helper: Connect WireGuard
async function connectWireGuard(config, userId) {
const interfaceName = `wg${userId}`;
const confPath = `/etc/wireguard/${interfaceName}.conf`;
// Build WireGuard config with split-tunnel for local network access
// Note: DNS is not set in config to avoid conflicts with Docker's DNS
// Extract VPN endpoint IP to exclude from tunnel (prevent routing loop)
const vpnEndpointIP = config.peer.Endpoint ? config.peer.Endpoint.split(':')[0] : null;
// Use DNS from config (VPN provider's DNS) with public DNS fallbacks
const dnsServers = config.interface.DNS || '1.1.1.1';
const primaryDNS = dnsServers.split(',')[0].trim();
const wgConfig = `[Interface]
PrivateKey = ${config.interface.PrivateKey}
Address = ${config.interface.Address}
Table = off
FwMark = 0xca6c
PostUp = ip route add default dev %i table 51820${vpnEndpointIP ? `
PostUp = ip route add ${vpnEndpointIP}/32 via 172.20.0.1 dev eth0` : ''}
PostUp = ip rule add to 172.20.0.0/16 table main priority 50
PostUp = ip rule add to 192.168.0.0/16 table main priority 51
PostUp = ip rule add not fwmark 0xca6c table 51820 priority 100
PostUp = ip route replace default via 172.20.0.1 dev eth0 metric 200
PostUp = ip route add default dev %i metric 50
PostUp = cp /etc/resolv.conf /etc/resolv.conf.vpn-backup
PostUp = echo 'nameserver ${primaryDNS}' > /etc/resolv.conf
PostUp = echo 'nameserver 1.1.1.1' >> /etc/resolv.conf
PostUp = echo 'nameserver 8.8.8.8' >> /etc/resolv.conf
PreDown = ip route del default dev %i metric 50
PreDown = ip route replace default via 172.20.0.1 dev eth0 metric 0
PreDown = ip rule del to 172.20.0.0/16 table main priority 50
PreDown = ip rule del to 192.168.0.0/16 table main priority 51
PreDown = ip rule del to 10.0.0.0/8 table main priority 52
PreDown = ip rule del not fwmark 0xca6c table 51820 priority 100
PreDown = ip route del default dev %i table 51820${vpnEndpointIP ? `
PreDown = ip route del ${vpnEndpointIP}/32 via 172.20.0.1 dev eth0` : ''}
PreDown = mv /etc/resolv.conf.vpn-backup /etc/resolv.conf 2>/dev/null || true
[Peer]
PublicKey = ${config.peer.PublicKey}
AllowedIPs = ${config.peer.AllowedIPs || '0.0.0.0/0, ::/0'}
Endpoint = ${config.peer.Endpoint}
${config.peer.PersistentKeepalive ? `PersistentKeepalive = ${config.peer.PersistentKeepalive}` : ''}
`;
console.log('[WireGuard] Creating config file at', confPath);
console.log('[WireGuard] Current user:', process.getuid ? process.getuid() : 'unknown');
// Write config file (this will work as root or with proper permissions)
try {
await fs.writeFile(confPath, wgConfig, { mode: 0o600 });
console.log('[WireGuard] Config file created successfully');
} catch (err) {
console.error('[WireGuard] Failed to create config file:', err.message);
throw new Error(`Failed to create WireGuard config: ${err.message}`);
}
return new Promise((resolve, reject) => {
// Use shell to ensure root context
const wg = spawn('wg-quick', ['up', interfaceName], {
uid: 0, // Run as root
gid: 0
});
let output = '';
wg.stdout.on('data', (data) => {
output += data;
console.log('[WireGuard]', data.toString().trim());
});
wg.stderr.on('data', (data) => {
output += data;
console.log('[WireGuard]', data.toString().trim());
});
wg.on('close', (code) => {
if (code === 0) {
console.log('[WireGuard] Connected successfully to', config.peer.Endpoint);
resolve();
} else {
console.error('[WireGuard] Connection failed (code', code, '):', output);
// Check for Docker networking limitation
if (output.includes('Nexthop has invalid gateway') || output.includes('Error: Nexthop')) {
reject(new Error('VPN connection requires host network mode. Docker containers have limited network access. Please use the desktop app for VPN connections.'));
} else {
reject(new Error('WireGuard connection failed'));
}
}
});
wg.on('error', (err) => {
console.error('[WireGuard] Spawn error:', err.message);
reject(new Error(`Failed to start wg-quick: ${err.message}`));
});
});
}
// Helper: Connect OpenVPN
async function connectOpenVPN(config, userId) {
const confPath = `/tmp/ovpn_${userId}.conf`;
await fs.writeFile(confPath, config.config);
return new Promise((resolve, reject) => {
const ovpn = spawn('openvpn', [
'--config', confPath,
'--daemon',
'--log', `/tmp/ovpn_${userId}.log`
]);
ovpn.on('close', (code) => {
if (code === 0) {
console.log('[OpenVPN] Connected successfully');
resolve();
} else {
reject(new Error('OpenVPN connection failed. VPN requires host network mode. Please use the desktop app for VPN connections.'));
}
});
});
}
// Helper: Disconnect WireGuard
async function disconnectWireGuard(userId) {
const interfaceName = `wg${userId}`;
console.log('[WireGuard] Disconnecting interface:', interfaceName);
return new Promise((resolve, reject) => {
const wg = spawn('wg-quick', ['down', interfaceName], {
uid: 0,
gid: 0
});
let output = '';
wg.stdout.on('data', (data) => {
output += data;
console.log('[WireGuard]', data.toString().trim());
});
wg.stderr.on('data', (data) => {
output += data;
console.log('[WireGuard]', data.toString().trim());
});
wg.on('close', (code) => {
if (code === 0) {
console.log('[WireGuard] Disconnected successfully');
resolve();
} else if (output.includes('is not a WireGuard interface') || output.includes('does not exist')) {
// Interface doesn't exist - already disconnected
console.log('[WireGuard] Interface already disconnected or config file missing');
resolve();
} else {
console.error('[WireGuard] Disconnect failed (code', code, '):', output);
reject(new Error('WireGuard disconnect failed'));
}
});
wg.on('error', (err) => {
console.error('[WireGuard] Spawn error:', err.message);
reject(new Error(`Failed to stop wg-quick: ${err.message}`));
});
});
}
// Helper: Disconnect OpenVPN
async function disconnectOpenVPN(userId) {
console.log('[OpenVPN] Disconnecting...');
return new Promise((resolve, reject) => {
// Kill OpenVPN process
const pkill = spawn('pkill', ['-f', `openvpn.*ovpn_${userId}`]);
pkill.on('close', (code) => {
// pkill returns 0 if processes were killed, 1 if none were found
if (code === 0 || code === 1) {
console.log('[OpenVPN] Disconnected successfully');
resolve();
} else {
console.error('[OpenVPN] Disconnect failed with code:', code);
reject(new Error('OpenVPN disconnect failed'));
}
});
pkill.on('error', (err) => {
console.error('[OpenVPN] Spawn error:', err.message);
reject(new Error(`Failed to stop OpenVPN: ${err.message}`));
});
});
}
// Helper: Check if VPN is actually connected
async function checkVPNConnection(configType, userId) {
try {
if (configType === 'wireguard') {
const interfaceName = `wg${userId}`;
return new Promise((resolve) => {
const wg = spawn('wg', ['show', interfaceName]);
let hasOutput = false;
wg.stdout.on('data', () => { hasOutput = true; });
wg.on('close', (code) => {
resolve(code === 0 && hasOutput);
});
wg.on('error', () => resolve(false));
});
} else if (configType === 'openvpn') {
return new Promise((resolve) => {
const pgrep = spawn('pgrep', ['-f', `openvpn.*ovpn_${userId}`]);
let hasOutput = false;
pgrep.stdout.on('data', () => { hasOutput = true; });
pgrep.on('close', (code) => {
resolve(code === 0 && hasOutput);
});
pgrep.on('error', () => resolve(false));
});
}
return false;
} catch (error) {
return false;
}
}
module.exports = router;