diff --git a/create-a-container/bin/deploy-traefik-job.js b/create-a-container/bin/deploy-traefik-job.js new file mode 100644 index 00000000..e9bf7481 --- /dev/null +++ b/create-a-container/bin/deploy-traefik-job.js @@ -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 + */ + +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 '); + 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} + */ +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(); diff --git a/create-a-container/middlewares/index.js b/create-a-container/middlewares/index.js index cd9f5bac..8f9955c0 100644 --- a/create-a-container/middlewares/index.js +++ b/create-a-container/middlewares/index.js @@ -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'; @@ -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 }; diff --git a/create-a-container/migrations/20260115161622-add-load-balancer-vip-to-sites.js b/create-a-container/migrations/20260115161622-add-load-balancer-vip-to-sites.js new file mode 100644 index 00000000..3ee62fef --- /dev/null +++ b/create-a-container/migrations/20260115161622-add-load-balancer-vip-to-sites.js @@ -0,0 +1,54 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + // Add loadBalancerVip column to Sites table + await queryInterface.addColumn('Sites', 'loadBalancerVip', { + type: Sequelize.STRING, + allowNull: true, + comment: 'Virtual IP address for the load balancer. Recommended to be outside the DHCP range.' + }); + + // Set default values based on existing dhcpRange + // dhcpRange format is "192.168.1.100-192.168.1.200" or "192.168.1.100,192.168.1.200" + const sitesTable = queryInterface.quoteIdentifier('Sites'); + const idField = queryInterface.quoteIdentifier('id'); + const dhcpRangeField = queryInterface.quoteIdentifier('dhcpRange'); + const loadBalancerVipField = queryInterface.quoteIdentifier('loadBalancerVip'); + + const [sites] = await queryInterface.sequelize.query( + `SELECT ${idField}, ${dhcpRangeField} FROM ${sitesTable} WHERE ${dhcpRangeField} IS NOT NULL` + ); + + for (const site of sites) { + const dhcpRange = site.dhcpRange; + if (!dhcpRange) continue; + + // Parse the DHCP range to extract the minimum IP + const rangeParts = dhcpRange.split(/[-,]/); + if (rangeParts.length >= 2) { + const minIp = rangeParts[0].trim(); + const ipParts = minIp.split('.'); + + if (ipParts.length === 4) { + // Decrement the last octet by 1 for the VIP + const lastOctet = parseInt(ipParts[3], 10) - 1; + + if (lastOctet >= 1) { + const vip = `${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.${lastOctet}`; + + await queryInterface.sequelize.query( + `UPDATE ${sitesTable} SET ${loadBalancerVipField} = ? WHERE ${idField} = ?`, + { replacements: [vip, site.id] } + ); + } + } + } + } + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn('Sites', 'loadBalancerVip'); + } +}; diff --git a/create-a-container/models/site.js b/create-a-container/models/site.js index 0296a904..9c2f487b 100644 --- a/create-a-container/models/site.js +++ b/create-a-container/models/site.js @@ -29,7 +29,8 @@ module.exports = (sequelize, DataTypes) => { dhcpRange: DataTypes.STRING, subnetMask: DataTypes.STRING, gateway: DataTypes.STRING, - dnsForwarders: DataTypes.STRING + dnsForwarders: DataTypes.STRING, + loadBalancerVip: DataTypes.STRING }, { sequelize, modelName: 'Site', diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 08b2e22e..596b0888 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -22,7 +22,8 @@ "pg": "^8.16.3", "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "uuidv7": "^1.1.0" }, "devDependencies": { "nodemon": "^3.1.10" @@ -1203,7 +1204,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2979,7 +2979,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -4217,6 +4216,14 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uuidv7": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.1.0.tgz", + "integrity": "sha512-2VNnOC0+XQlwogChUDzy6pe8GQEys9QFZBGOh54l6qVfwoCUwwRvk7rDTgaIsRgsF5GFa5oiNg8LqXE3jofBBg==", + "bin": { + "uuidv7": "cli.js" + } + }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", diff --git a/create-a-container/package.json b/create-a-container/package.json index 358e6333..ff354212 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -27,7 +27,8 @@ "pg": "^8.16.3", "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "uuidv7": "^1.1.0" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index a03006c5..0b127cc0 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -5,6 +5,7 @@ const dns = require('dns').promises; const { Container, Service, HTTPService, TransportService, DnsService, Node, Site, ExternalDomain, Sequelize, sequelize } = require('../models'); const { requireAuth } = require('../middlewares'); const ProxmoxApi = require('../utils/proxmox-api'); +const { createTraefikDeploymentJob } = require('../utils/traefik'); const serviceMap = require('../data/services.json'); // GET /sites/:siteId/containers/new - Display form for creating a new container @@ -281,6 +282,8 @@ router.post('/', async (req, res) => { }); // Create services if provided + let hasTransportServices = false; + if (services && typeof services === 'object') { for (const key in services) { const service = services[key]; @@ -301,6 +304,7 @@ router.post('/', async (req, res) => { // tcp or udp serviceType = 'transport'; protocol = type; + hasTransportServices = true; } const serviceData = { @@ -354,6 +358,17 @@ router.post('/', async (req, res) => { } } + // Queue Traefik deployment job once if any transport services were created + if (hasTransportServices) { + try { + const username = req.session && req.session.user ? req.session.user : 'system'; + await createTraefikDeploymentJob(siteId, username); + } catch (jobErr) { + console.error('Failed to create Traefik deployment job:', jobErr); + req.flash('warning', 'Container created successfully, but failed to queue Traefik deployment job: ' + jobErr.message); + } + } + return res.redirect(`/sites/${siteId}/containers`); } catch (err) { console.error('Error creating container:', err); @@ -411,6 +426,8 @@ router.put('/:id', requireAuth, async (req, res) => { const { services } = req.body; + let hasNewTransportServices = false; + // Wrap all database operations in a transaction await sequelize.transaction(async (t) => { // Process services in two phases: delete first, then create new @@ -451,6 +468,7 @@ router.put('/:id', requireAuth, async (req, res) => { // tcp or udp serviceType = 'transport'; protocol = type; + hasNewTransportServices = true; } const serviceData = { @@ -503,6 +521,17 @@ router.put('/:id', requireAuth, async (req, res) => { } }); + // Queue Traefik deployment job once if any new transport services were created + if (hasNewTransportServices) { + try { + const username = req.session && req.session.user ? req.session.user : 'system'; + await createTraefikDeploymentJob(siteId, username); + } catch (jobErr) { + console.error('Failed to create Traefik deployment job:', jobErr); + req.flash('warning', 'Container updated successfully, but failed to queue Traefik deployment job: ' + jobErr.message); + } + } + req.flash('success', 'Container services updated successfully'); return res.redirect(`/sites/${siteId}/containers`); } catch (err) { diff --git a/create-a-container/routers/sites.js b/create-a-container/routers/sites.js index e0137267..f1b4eccf 100644 --- a/create-a-container/routers/sites.js +++ b/create-a-container/routers/sites.js @@ -4,7 +4,8 @@ const path = require('path') const express = require('express'); const stringify = require('dotenv-stringify'); const { Site, Node, Container, Service, HTTPService, TransportService, DnsService, ExternalDomain, sequelize } = require('../models'); -const { requireAuth, requireAdmin, requireLocalhost, setCurrentSite } = require('../middlewares'); +const { requireAuth, requireAdmin, requireLocalhost, requireLocalSubnet, setCurrentSite } = require('../middlewares'); +const { buildDynamicConfig } = require('../utils/traefik'); const router = express.Router(); @@ -97,6 +98,73 @@ router.get('/:siteId/nginx.conf', requireLocalhost, async (req, res) => { return res.render('nginx-conf', { httpServices, streamServices, externalDomains: site?.externalDomains || [] }); }); +// GET /sites/:siteId/traefik.json - Local subnet endpoint for traefik dynamic configuration +router.get('/:siteId/traefik.json', requireLocalSubnet, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + + // Fetch services for the specific site + 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) { + return res.status(404).json({ error: 'Site not found' }); + } + + // Flatten services from site→nodes→containers→services + const httpServices = []; + const transportServices = []; + + site.nodes.forEach(node => { + node.containers.forEach(container => { + container.services.forEach(service => { + service.Container = container; // Add 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 + } + }); + }); + }); + + // Build dynamic configuration + const config = buildDynamicConfig(site, httpServices, transportServices); + + res.set('Content-Type', 'application/json'); + return res.json(config); +}); + // GET /sites/:siteId/ldap.conf - Public endpoint for LDAP configuration router.get('/:siteId/ldap.conf', requireLocalhost, async (req, res) => { const siteId = parseInt(req.params.siteId, 10); @@ -249,7 +317,7 @@ router.get('/:id/edit', requireAdmin, async (req, res) => { // POST /sites - Create a new site (admin only) router.post('/', requireAdmin, async (req, res) => { try { - const { name, internalDomain, dhcpRange, subnetMask, gateway, dnsForwarders } = req.body; + const { name, internalDomain, dhcpRange, subnetMask, gateway, dnsForwarders, loadBalancerVip } = req.body; await Site.create({ name, @@ -257,7 +325,8 @@ router.post('/', requireAdmin, async (req, res) => { dhcpRange, subnetMask, gateway, - dnsForwarders + dnsForwarders, + loadBalancerVip }); req.flash('success', `Site ${name} created successfully`); @@ -279,7 +348,7 @@ router.put('/:id', requireAdmin, async (req, res) => { return res.redirect('/sites'); } - const { name, internalDomain, dhcpRange, subnetMask, gateway, dnsForwarders } = req.body; + const { name, internalDomain, dhcpRange, subnetMask, gateway, dnsForwarders, loadBalancerVip } = req.body; await site.update({ name, @@ -287,7 +356,8 @@ router.put('/:id', requireAdmin, async (req, res) => { dhcpRange, subnetMask, gateway, - dnsForwarders + dnsForwarders, + loadBalancerVip }); req.flash('success', `Site ${name} updated successfully`); diff --git a/create-a-container/utils/traefik.js b/create-a-container/utils/traefik.js new file mode 100644 index 00000000..627144d4 --- /dev/null +++ b/create-a-container/utils/traefik.js @@ -0,0 +1,215 @@ +const os = require('os'); +const path = require('path'); +const { uuidv7 } = require('uuidv7'); +const { Job } = require('../models'); + +/** + * Build Traefik static configuration environment variables + * @param {object} site - Site model instance with externalDomains association + * @param {Array} transportServices - Array of transport services needing entrypoints + * @returns {object} - Environment variables for Traefik static configuration + */ +function buildConfig(site, transportServices) { + const env = { + TRAEFIK_API: 'false', + TRAEFIK_LOG_LEVEL: 'INFO', + TRAEFIK_ACCESSLOG: 'true', + TRAEFIK_ENTRYPOINTS_WEB_ADDRESS: ':80', + TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_TO: 'websecure', + TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME: 'https', + TRAEFIK_ENTRYPOINTS_WEBSECURE_ADDRESS: ':443', + TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS: 'true', + TRAEFIK_PROVIDERS_HTTP_ENDPOINT: `http://${os.hostname()}.${site.internalDomain}:3000/sites/${site.id}/traefik.json`, + TRAEFIK_PROVIDERS_HTTP_POLLINTERVAL: '10s', + }; + + // External domains for certificate configuration + const externalDomains = site.externalDomains || []; + + externalDomains.forEach((domain, index) => { + if (!domain.acmeEmail || !domain.cloudflareApiEmail || !domain.cloudflareApiKey) { + return; // Skip domains without complete ACME configuration + } + + const certResolverName = `resolver${index}`; + + // ACME certificate resolver + env[`TRAEFIK_CERTIFICATESRESOLVERS_${certResolverName.toUpperCase()}_ACME_EMAIL`] = domain.acmeEmail; + env[`TRAEFIK_CERTIFICATESRESOLVERS_${certResolverName.toUpperCase()}_ACME_CASERVER`] = domain.acmeDirectoryUrl; + env[`TRAEFIK_CERTIFICATESRESOLVERS_${certResolverName.toUpperCase()}_ACME_STORAGE`] = `/letsencrypt/${domain.name}/acme.json`; + env[`TRAEFIK_CERTIFICATESRESOLVERS_${certResolverName.toUpperCase()}_ACME_DNSCHALLENGE_PROVIDER`] = 'cloudflare'; + env[`TRAEFIK_CERTIFICATESRESOLVERS_${certResolverName.toUpperCase()}_ACME_DNSCHALLENGE_RESOLVERS`] = '1.1.1.1:53,8.8.8.8:53'; + + // Cloudflare credentials for DNS challenge + env.CF_API_EMAIL = domain.cloudflareApiEmail; + env.CF_DNS_API_TOKEN = domain.cloudflareApiKey; + }); + + // Create entrypoints for each unique transport service port + transportServices.forEach(service => { + const entrypointName = `${service.protocol}${service.externalPort}`; + env[`TRAEFIK_ENTRYPOINTS_${entrypointName.toUpperCase()}_ADDRESS`] = `:${service.externalPort}/${service.protocol}`; + }); + + return env; +} + +/** + * Build dynamic configuration for Traefik + * This generates the file-based configuration that Traefik will watch + * @param {object} site - Site model instance + * @param {Array} httpServices - Array of HTTP services + * @param {Array} transportServices - Array of transport (TCP/UDP) services + * @returns {object} - Traefik dynamic configuration + */ +function buildDynamicConfig(site, httpServices, transportServices) { + const config = { + http: { + routers: {}, + services: {}, + middlewares: {} + }, + tcp: { + routers: {}, + services: {} + }, + udp: { + routers: {}, + services: {} + } + }; + + // HTTP services on websecure entrypoint + httpServices.forEach((service, index) => { + const routerName = `http-${service.id}`; + const serviceName = `http-svc-${service.id}`; + const fqdn = `${service.httpService.externalHostname}.${service.httpService.externalDomain.name}`; + + config.http.routers[routerName] = { + rule: `Host(\`${fqdn}\`)`, + service: serviceName, + entryPoints: ['websecure'], + tls: { + certResolver: `resolver0` // Use first cert resolver for now + } + }; + + config.http.services[serviceName] = { + loadBalancer: { + servers: [ + { url: `http://${service.Container.ipv4Address}:${service.internalPort}` } + ] + } + }; + }); + + // TCP services + const tcpServices = transportServices.filter(s => s.protocol === 'tcp'); + tcpServices.forEach((service, index) => { + const routerName = `tcp-${service.id}`; + const serviceName = `tcp-svc-${service.id}`; + const entrypointName = `tcp${service.externalPort}`; + + config.tcp.routers[routerName] = { + rule: 'HostSNI(`*`)', + service: serviceName, + entryPoints: [entrypointName] + }; + + const containerIp = service.service?.Container?.ipv4Address || '127.0.0.1'; + const internalPort = service.service?.internalPort || service.externalPort; + + config.tcp.services[serviceName] = { + loadBalancer: { + servers: [ + { address: `${containerIp}:${internalPort}` } + ] + } + }; + }); + + // UDP services + const udpServices = transportServices.filter(s => s.protocol === 'udp'); + udpServices.forEach((service, index) => { + const routerName = `udp-${service.id}`; + const serviceName = `udp-svc-${service.id}`; + const entrypointName = `udp${service.externalPort}`; + + config.udp.routers[routerName] = { + service: serviceName, + entryPoints: [entrypointName] + }; + + const containerIp = service.service?.Container?.ipv4Address || '127.0.0.1'; + const internalPort = service.service?.internalPort || service.externalPort; + + config.udp.services[serviceName] = { + loadBalancer: { + servers: [ + { address: `${containerIp}:${internalPort}` } + ] + } + }; + }); + + return config; +} + +/** + * Build Proxmox API configuration for creating a Traefik container + * @param {object} site - Site model instance + * @param {object} node - Node to create the container on + * @param {number} vmid - Container VMID + * @param {object} env - Environment variables from buildConfig() + * @returns {object} - Proxmox LXC container configuration + */ +function buildProxmoxConfig(site, node, vmid, env) { + // Convert env object to array format expected by Proxmox + const envArray = Object.entries(env).map(([key, value]) => `${key}=${value}`); + + const config = { + vmid, + // TODO: Pull storage location from node.defaultStorage once implemented + ostemplate: 'containers:vztmpl/traefik_latest.tar', + hostname: `traefik-${site.id}-${uuidv7()}`, + description: `Traefik load balancer for site ${site.name}`, + memory: 4096, + swap: 4096, + cores: 4, + rootfs: 'local-lvm:50', + unprivileged: 1, + features: 'nesting=1', + net0: `name=eth0,bridge=vmbr0,firewall=1,ip=dhcp,type=veth`, + onboot: 1, + start: 1, + env: envArray + }; + + return config; +} + +/** + * Create a Traefik deployment job + * @param {number} siteId - Site ID + * @param {string} createdBy - Username of job creator + * @returns {Promise} + */ +async function createTraefikDeploymentJob(siteId, createdBy) { + const jobScriptPath = path.join(__dirname, '..', 'bin', 'deploy-traefik-job.js'); + const command = `node ${jobScriptPath} ${siteId}`; + + const job = await Job.create({ + command, + createdBy, + status: 'pending' + }); + + return job; +} + +module.exports = { + buildConfig, + buildDynamicConfig, + buildProxmoxConfig, + createTraefikDeploymentJob +}; diff --git a/create-a-container/views/dnsmasq-conf.ejs b/create-a-container/views/dnsmasq-conf.ejs index 9e3f0db6..15793e36 100644 --- a/create-a-container/views/dnsmasq-conf.ejs +++ b/create-a-container/views/dnsmasq-conf.ejs @@ -24,6 +24,11 @@ host-record=<%= node.name %>.<%= site.internalDomain %>,<%= node.ipv4Address %> <%_ } _%> <%_ }) _%> +# Static address for load balancer VIP +<%_ if (site.loadBalancerVip) { _%> +host-record=lb.<%= site.internalDomain %>,<%= site.loadBalancerVip %> +<%_ } _%> + # Static DHCP leases for existing containers <%_ site.nodes.forEach((node) => { _%> <%_ node.containers.forEach((container) => { _%> diff --git a/create-a-container/views/sites/form.ejs b/create-a-container/views/sites/form.ejs index a39b2f08..b76af834 100644 --- a/create-a-container/views/sites/form.ejs +++ b/create-a-container/views/sites/form.ejs @@ -98,6 +98,19 @@
Comma-separated list of DNS server IPs
+
+ + +
Virtual IP address for the load balancer. Can be any IP in the site's subnet, but recommended to be outside the DHCP range.
+
+
Cancel