Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions create-a-container/bin/deploy-traefik-job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
#!/usr/bin/env node
/**
* deploy-traefik-job.js
* Job script for deploying Traefik load balancer for a site
*
* Usage: node deploy-traefik-job.js <siteId>
*/

const https = require('https');
const { buildConfig, buildProxmoxConfig } = require('../utils/traefik');

const siteId = parseInt(process.argv[2], 10);

if (!siteId || isNaN(siteId)) {
console.error('Usage: node deploy-traefik-job.js <siteId>');
process.exit(1);
}

/**
* Deploy a new Traefik container and manage VIP migration
* This is the main orchestration function for Traefik deployment
* @param {number} siteId - Site ID
* @returns {Promise<void>}
*/
async function deployTraefik(siteId) {
const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, sequelize } = require('../models');
const ProxmoxApi = require('../utils/proxmox-api');

console.log('Starting Traefik deployment...');

// Load site with all necessary associations
const site = await Site.findByPk(siteId, {
include: [
{
model: Node,
as: 'nodes',
include: [{
model: Container,
as: 'containers',
include: [{
model: Service,
as: 'services',
include: [
{ model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] },
{ model: TransportService, as: 'transportService' }
]
}]
}]
},
{ model: ExternalDomain, as: 'externalDomains' }
]
});

if (!site) {
throw new Error(`Site ${siteId} not found`);
}

console.log(`Site: ${site.name}\n`);

// Collect all services
const httpServices = [];
const transportServices = [];

site.nodes.forEach(node => {
node.containers.forEach(container => {
container.services.forEach(service => {
service.Container = container; // Attach container reference

if (service.type === 'http' && service.httpService) {
httpServices.push(service);
} else if (service.type === 'transport' && service.transportService) {
transportServices.push(service.transportService);
service.transportService.service = service; // Add back-reference
}
});
});
});

console.log(`Found ${httpServices.length} HTTP services and ${transportServices.length} transport services\n`);

// Build Traefik configuration
const env = buildConfig(site, transportServices);
console.log('Built Traefik static configuration');

// Select a node for deployment (first available node with API access)
const availableNode = site.nodes.find(n => n.apiUrl && n.tokenId && n.secret);

if (!availableNode) {
throw new Error('No nodes available with API credentials');
}

console.log(`Deploying to node: ${availableNode.name}\n`);

// Create Proxmox API client
const client = new ProxmoxApi(availableNode.apiUrl, availableNode.tokenId, availableNode.secret, {
httpsAgent: new https.Agent({
rejectUnauthorized: availableNode.tlsVerify !== false
})
});

// Get next available VMID
const vmid = await client.nextId();
console.log(`Allocated VMID: ${vmid}\n`);

// Find existing Traefik container (if any)
const existingTraefik = await Container.findOne({
where: {
hostname: { [sequelize.Sequelize.Op.like]: `traefik-%` }
},
include: [{
model: Node,
as: 'node',
where: { siteId }
}]
});

// Create the new Traefik container
console.log('Creating new Traefik container...');

const proxmoxConfig = buildProxmoxConfig(site, availableNode, vmid, env);
const upid = await client.createLxc(availableNode.name, proxmoxConfig);

console.log(`Container creation task: ${upid}\n`);

// Wait for container creation to complete
console.log('Waiting for container creation...');
let taskComplete = false;

while (!taskComplete) {
await new Promise(resolve => setTimeout(resolve, 2000));
const status = await client.taskStatus(availableNode.name, upid);

if (status.status === 'stopped') {
taskComplete = true;

if (status.exitstatus !== '0') {
throw new Error(`Container creation failed: ${status.exitstatus}`);
}
}
}

console.log('Container created successfully and started automatically');

// Get container configuration to find MAC and IP
const lxcConfig = await client.lxcConfig(availableNode.name, vmid);
const macAddress = lxcConfig.net0?.match(/hwaddr=([0-9A-Fa-f:]+)/)?.[1] || null;

console.log(`Container MAC: ${macAddress}`);

// Wait for DHCP to assign IP (check container status)
console.log('Waiting for IP address assignment...');
let ipAddress = null;
let attempts = 0;
const maxAttempts = 30;

while (!ipAddress && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000));
const status = await client.lxcConfig(availableNode.name, vmid);
const statusCurrent = await client.taskStatus(availableNode.name, `UPID:${availableNode.name}:00000000:00000000:00000000:vzinfo:${vmid}:root@pam:`);

// Try to extract IP from status
const statusStr = JSON.stringify(statusCurrent);
const ipMatch = statusStr.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);

if (ipMatch) {
ipAddress = ipMatch[1];
}

attempts++;
}

if (!ipAddress) {
throw new Error('Failed to get IP address for new Traefik container');
}

console.log(`Traefik container IP: ${ipAddress}\n`);

// Store container in database
await Container.create({
hostname: proxmoxConfig.hostname,
nodeId: availableNode.id,
containerId: vmid,
macAddress,
ipv4Address: ipAddress,
status: 'running'
});

console.log('Container registered in database');

// Move VIP address to new container
if (site.loadBalancerVip) {
console.log(`Moving VIP ${site.loadBalancerVip} to new container...\n`);

// Remove VIP from old container (if exists)
if (existingTraefik && existingTraefik.node) {
try {
const oldClient = new ProxmoxApi(existingTraefik.node.apiUrl, existingTraefik.node.tokenId, existingTraefik.node.secret, {
httpsAgent: new https.Agent({
rejectUnauthorized: existingTraefik.node.tlsVerify !== false
})
});

console.log(`Removing VIP from old container ${existingTraefik.containerId}...\n`);

// Remove IP alias from old container
const oldConfig = await oldClient.lxcConfig(existingTraefik.node.name, existingTraefik.containerId);
const oldNet0 = oldConfig.net0 || '';

// Remove IP alias if present
const newNet0 = oldNet0.replace(/,ip=.*?(,|$)/, '$1');

await oldClient.updateLxcConfig(existingTraefik.node.name, existingTraefik.containerId, {
net0: newNet0
});

console.log('VIP removed from old container');
} catch (err) {
console.log(`Warning: Failed to remove VIP from old container: ${err.message}\n`);
}
}

// Add VIP to new container
console.log('Adding VIP to new container...');
const newConfig = await client.lxcConfig(availableNode.name, vmid);
let newNet0 = newConfig.net0 || '';

// Add IP alias
if (!newNet0.includes(',ip=')) {
newNet0 += `,ip=${site.loadBalancerVip}/32`;
}

await client.updateLxcConfig(availableNode.name, vmid, {
net0: newNet0
});

console.log('VIP configured on new container');
}

// Delete old Traefik container
if (existingTraefik && existingTraefik.node) {
console.log(`Deleting old Traefik container ${existingTraefik.containerId}...\n`);

try {
const oldClient = new ProxmoxApi(existingTraefik.node.apiUrl, existingTraefik.node.tokenId, existingTraefik.node.secret, {
httpsAgent: new https.Agent({
rejectUnauthorized: existingTraefik.node.tlsVerify !== false
})
});

await oldClient.deleteContainer(existingTraefik.node.name, existingTraefik.containerId, true, true);

// Remove from database
await existingTraefik.destroy();

console.log('Old container deleted');
} catch (err) {
console.log(`Warning: Failed to delete old container: ${err.message}\n`);
}
}

console.log('Traefik deployment completed successfully');
}

async function run() {
try {
await deployTraefik(siteId);
process.exit(0);
} catch (error) {
console.error(`Error deploying Traefik: ${error.message}`);
console.error(error.stack);
process.exit(1);
}
}

run();
93 changes: 92 additions & 1 deletion create-a-container/middlewares/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { Site } = require('../models');

function isApiRequest(req) {
const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json');
const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest';
Expand Down Expand Up @@ -55,6 +57,95 @@ function requireLocalhost(req, res, next) {
return next();
}

// Local subnet middleware
// Checks if request is from the site's local subnet based on DHCP range
async function requireLocalSubnet(req, res, next) {
const { Site } = require('../models');

const siteId = parseInt(req.params.siteId, 10);

if (!siteId) {
return res.status(400).send('Bad Request: Site ID required');
}

const site = await Site.findByPk(siteId);

if (!site || !site.dhcpRange) {
return res.status(500).send('Internal Server Error: Unable to determine site subnet');
}

// Helper: Convert IP string to 32-bit integer
const ipToInt = (ip) => {
const parts = ip.split('.');
return (parseInt(parts[0]) << 24) + (parseInt(parts[1]) << 16) + (parseInt(parts[2]) << 8) + parseInt(parts[3]);
};

// Helper: Check if IP is in subnet
const isInSubnet = (ip, networkInt, prefixLen) => {
const ipInt = ipToInt(ip);
const mask = (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
return (ipInt & mask) === (networkInt & mask);
};

// Parse DHCP range to derive subnet
const dhcpRange = site.dhcpRange;
const rangeParts = dhcpRange.split(/[-,]/);

if (rangeParts.length < 2) {
return res.status(500).send('Internal Server Error: Invalid DHCP range configuration');
}

const minIp = rangeParts[0].trim();
const maxIp = rangeParts[1].trim();

// Validate IP addresses
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!ipRegex.test(minIp) || !ipRegex.test(maxIp)) {
return res.status(500).send('Internal Server Error: Invalid IP address format');
}

// Convert to integers
const minInt = ipToInt(minIp);
const maxInt = ipToInt(maxIp);

// Find the smallest subnet that contains both IPs
// XOR the min and max to find differing bits
const xor = minInt ^ maxInt;

// Count leading zeros to find prefix length
let prefixLen = 32;
for (let i = 0; i < 32; i++) {
if (xor & (1 << i)) {
prefixLen = 31 - i;
break;
}
}

// Get the actual client IP (accounting for reverse proxy)
let clientIp = req.get('X-Real-IP') ||
req.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.ip;

// Handle IPv6-mapped IPv4 addresses
if (clientIp && clientIp.startsWith('::ffff:')) {
clientIp = clientIp.substring(7);
}

// Validate client IP
if (!clientIp || !ipRegex.test(clientIp)) {
return res.status(403).send('Forbidden: Unable to determine client IP address');
}

// Check if client IP is in the calculated subnet
if (!isInSubnet(clientIp, minInt, prefixLen)) {
return res.status(403).send('Forbidden: This endpoint is only accessible from the site\'s local network');
}

return next();
}

const { setCurrentSite, loadSites } = require('./currentSite');

module.exports = { requireAuth, requireAdmin, requireLocalhost, setCurrentSite, loadSites };
module.exports = { requireAuth, requireAdmin, requireLocalhost, requireLocalSubnet, setCurrentSite, loadSites };
Loading
Loading