Skip to content

Commit 7fa4e1d

Browse files
committed
WIP: Traefik integration
1 parent 135fdb6 commit 7fa4e1d

12 files changed

Lines changed: 774 additions & 11 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
#!/usr/bin/env node
2+
/**
3+
* deploy-traefik-job.js
4+
* Job script for deploying Traefik load balancer for a site
5+
*
6+
* Usage: node deploy-traefik-job.js <siteId>
7+
*/
8+
9+
const https = require('https');
10+
const { buildConfig, buildProxmoxConfig } = require('../utils/traefik');
11+
12+
const siteId = parseInt(process.argv[2], 10);
13+
14+
if (!siteId || isNaN(siteId)) {
15+
console.error('Usage: node deploy-traefik-job.js <siteId>');
16+
process.exit(1);
17+
}
18+
19+
/**
20+
* Deploy a new Traefik container and manage VIP migration
21+
* This is the main orchestration function for Traefik deployment
22+
* @param {number} siteId - Site ID
23+
* @returns {Promise<void>}
24+
*/
25+
async function deployTraefik(siteId) {
26+
const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, sequelize } = require('../models');
27+
const ProxmoxApi = require('../utils/proxmox-api');
28+
29+
console.log('Starting Traefik deployment...');
30+
31+
// Load site with all necessary associations
32+
const site = await Site.findByPk(siteId, {
33+
include: [
34+
{
35+
model: Node,
36+
as: 'nodes',
37+
include: [{
38+
model: Container,
39+
as: 'containers',
40+
include: [{
41+
model: Service,
42+
as: 'services',
43+
include: [
44+
{ model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] },
45+
{ model: TransportService, as: 'transportService' }
46+
]
47+
}]
48+
}]
49+
},
50+
{ model: ExternalDomain, as: 'externalDomains' }
51+
]
52+
});
53+
54+
if (!site) {
55+
throw new Error(`Site ${siteId} not found`);
56+
}
57+
58+
console.log(`Site: ${site.name}\n`);
59+
60+
// Collect all services
61+
const httpServices = [];
62+
const transportServices = [];
63+
64+
site.nodes.forEach(node => {
65+
node.containers.forEach(container => {
66+
container.services.forEach(service => {
67+
service.Container = container; // Attach container reference
68+
69+
if (service.type === 'http' && service.httpService) {
70+
httpServices.push(service);
71+
} else if (service.type === 'transport' && service.transportService) {
72+
transportServices.push(service.transportService);
73+
service.transportService.service = service; // Add back-reference
74+
}
75+
});
76+
});
77+
});
78+
79+
console.log(`Found ${httpServices.length} HTTP services and ${transportServices.length} transport services\n`);
80+
81+
// Build Traefik configuration
82+
const env = buildConfig(site, transportServices);
83+
console.log('Built Traefik static configuration');
84+
85+
// Select a node for deployment (first available node with API access)
86+
const availableNode = site.nodes.find(n => n.apiUrl && n.tokenId && n.secret);
87+
88+
if (!availableNode) {
89+
throw new Error('No nodes available with API credentials');
90+
}
91+
92+
console.log(`Deploying to node: ${availableNode.name}\n`);
93+
94+
// Create Proxmox API client
95+
const client = new ProxmoxApi(availableNode.apiUrl, availableNode.tokenId, availableNode.secret, {
96+
httpsAgent: new https.Agent({
97+
rejectUnauthorized: availableNode.tlsVerify !== false
98+
})
99+
});
100+
101+
// Get next available VMID
102+
const vmid = await client.nextId();
103+
console.log(`Allocated VMID: ${vmid}\n`);
104+
105+
// Find existing Traefik container (if any)
106+
const existingTraefik = await Container.findOne({
107+
where: {
108+
hostname: { [sequelize.Sequelize.Op.like]: `traefik-%` }
109+
},
110+
include: [{
111+
model: Node,
112+
as: 'node',
113+
where: { siteId }
114+
}]
115+
});
116+
117+
// Create the new Traefik container
118+
console.log('Creating new Traefik container...');
119+
120+
const proxmoxConfig = buildProxmoxConfig(site, availableNode, vmid, env);
121+
const upid = await client.createLxc(availableNode.name, proxmoxConfig);
122+
123+
console.log(`Container creation task: ${upid}\n`);
124+
125+
// Wait for container creation to complete
126+
console.log('Waiting for container creation...');
127+
let taskComplete = false;
128+
129+
while (!taskComplete) {
130+
await new Promise(resolve => setTimeout(resolve, 2000));
131+
const status = await client.taskStatus(availableNode.name, upid);
132+
133+
if (status.status === 'stopped') {
134+
taskComplete = true;
135+
136+
if (status.exitstatus !== '0') {
137+
throw new Error(`Container creation failed: ${status.exitstatus}`);
138+
}
139+
}
140+
}
141+
142+
console.log('Container created successfully and started automatically');
143+
144+
// Get container configuration to find MAC and IP
145+
const lxcConfig = await client.lxcConfig(availableNode.name, vmid);
146+
const macAddress = lxcConfig.net0?.match(/hwaddr=([0-9A-Fa-f:]+)/)?.[1] || null;
147+
148+
console.log(`Container MAC: ${macAddress}`);
149+
150+
// Wait for DHCP to assign IP (check container status)
151+
console.log('Waiting for IP address assignment...');
152+
let ipAddress = null;
153+
let attempts = 0;
154+
const maxAttempts = 30;
155+
156+
while (!ipAddress && attempts < maxAttempts) {
157+
await new Promise(resolve => setTimeout(resolve, 2000));
158+
const status = await client.lxcConfig(availableNode.name, vmid);
159+
const statusCurrent = await client.taskStatus(availableNode.name, `UPID:${availableNode.name}:00000000:00000000:00000000:vzinfo:${vmid}:root@pam:`);
160+
161+
// Try to extract IP from status
162+
const statusStr = JSON.stringify(statusCurrent);
163+
const ipMatch = statusStr.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
164+
165+
if (ipMatch) {
166+
ipAddress = ipMatch[1];
167+
}
168+
169+
attempts++;
170+
}
171+
172+
if (!ipAddress) {
173+
throw new Error('Failed to get IP address for new Traefik container');
174+
}
175+
176+
console.log(`Traefik container IP: ${ipAddress}\n`);
177+
178+
// Store container in database
179+
await Container.create({
180+
hostname: proxmoxConfig.hostname,
181+
nodeId: availableNode.id,
182+
containerId: vmid,
183+
macAddress,
184+
ipv4Address: ipAddress,
185+
status: 'running'
186+
});
187+
188+
console.log('Container registered in database');
189+
190+
// Move VIP address to new container
191+
if (site.loadBalancerVip) {
192+
console.log(`Moving VIP ${site.loadBalancerVip} to new container...\n`);
193+
194+
// Remove VIP from old container (if exists)
195+
if (existingTraefik && existingTraefik.node) {
196+
try {
197+
const oldClient = new ProxmoxApi(existingTraefik.node.apiUrl, existingTraefik.node.tokenId, existingTraefik.node.secret, {
198+
httpsAgent: new https.Agent({
199+
rejectUnauthorized: existingTraefik.node.tlsVerify !== false
200+
})
201+
});
202+
203+
console.log(`Removing VIP from old container ${existingTraefik.containerId}...\n`);
204+
205+
// Remove IP alias from old container
206+
const oldConfig = await oldClient.lxcConfig(existingTraefik.node.name, existingTraefik.containerId);
207+
const oldNet0 = oldConfig.net0 || '';
208+
209+
// Remove IP alias if present
210+
const newNet0 = oldNet0.replace(/,ip=.*?(,|$)/, '$1');
211+
212+
await oldClient.updateLxcConfig(existingTraefik.node.name, existingTraefik.containerId, {
213+
net0: newNet0
214+
});
215+
216+
console.log('VIP removed from old container');
217+
} catch (err) {
218+
console.log(`Warning: Failed to remove VIP from old container: ${err.message}\n`);
219+
}
220+
}
221+
222+
// Add VIP to new container
223+
console.log('Adding VIP to new container...');
224+
const newConfig = await client.lxcConfig(availableNode.name, vmid);
225+
let newNet0 = newConfig.net0 || '';
226+
227+
// Add IP alias
228+
if (!newNet0.includes(',ip=')) {
229+
newNet0 += `,ip=${site.loadBalancerVip}/32`;
230+
}
231+
232+
await client.updateLxcConfig(availableNode.name, vmid, {
233+
net0: newNet0
234+
});
235+
236+
console.log('VIP configured on new container');
237+
}
238+
239+
// Delete old Traefik container
240+
if (existingTraefik && existingTraefik.node) {
241+
console.log(`Deleting old Traefik container ${existingTraefik.containerId}...\n`);
242+
243+
try {
244+
const oldClient = new ProxmoxApi(existingTraefik.node.apiUrl, existingTraefik.node.tokenId, existingTraefik.node.secret, {
245+
httpsAgent: new https.Agent({
246+
rejectUnauthorized: existingTraefik.node.tlsVerify !== false
247+
})
248+
});
249+
250+
await oldClient.deleteContainer(existingTraefik.node.name, existingTraefik.containerId, true, true);
251+
252+
// Remove from database
253+
await existingTraefik.destroy();
254+
255+
console.log('Old container deleted');
256+
} catch (err) {
257+
console.log(`Warning: Failed to delete old container: ${err.message}\n`);
258+
}
259+
}
260+
261+
console.log('Traefik deployment completed successfully');
262+
}
263+
264+
async function run() {
265+
try {
266+
await deployTraefik(siteId);
267+
process.exit(0);
268+
} catch (error) {
269+
console.error(`Error deploying Traefik: ${error.message}`);
270+
console.error(error.stack);
271+
process.exit(1);
272+
}
273+
}
274+
275+
run();

create-a-container/middlewares/index.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const { Site } = require('../models');
2+
13
function isApiRequest(req) {
24
const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json');
35
const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest';
@@ -55,6 +57,95 @@ function requireLocalhost(req, res, next) {
5557
return next();
5658
}
5759

60+
// Local subnet middleware
61+
// Checks if request is from the site's local subnet based on DHCP range
62+
async function requireLocalSubnet(req, res, next) {
63+
const { Site } = require('../models');
64+
65+
const siteId = parseInt(req.params.siteId, 10);
66+
67+
if (!siteId) {
68+
return res.status(400).send('Bad Request: Site ID required');
69+
}
70+
71+
const site = await Site.findByPk(siteId);
72+
73+
if (!site || !site.dhcpRange) {
74+
return res.status(500).send('Internal Server Error: Unable to determine site subnet');
75+
}
76+
77+
// Helper: Convert IP string to 32-bit integer
78+
const ipToInt = (ip) => {
79+
const parts = ip.split('.');
80+
return (parseInt(parts[0]) << 24) + (parseInt(parts[1]) << 16) + (parseInt(parts[2]) << 8) + parseInt(parts[3]);
81+
};
82+
83+
// Helper: Check if IP is in subnet
84+
const isInSubnet = (ip, networkInt, prefixLen) => {
85+
const ipInt = ipToInt(ip);
86+
const mask = (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
87+
return (ipInt & mask) === (networkInt & mask);
88+
};
89+
90+
// Parse DHCP range to derive subnet
91+
const dhcpRange = site.dhcpRange;
92+
const rangeParts = dhcpRange.split(/[-,]/);
93+
94+
if (rangeParts.length < 2) {
95+
return res.status(500).send('Internal Server Error: Invalid DHCP range configuration');
96+
}
97+
98+
const minIp = rangeParts[0].trim();
99+
const maxIp = rangeParts[1].trim();
100+
101+
// Validate IP addresses
102+
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
103+
if (!ipRegex.test(minIp) || !ipRegex.test(maxIp)) {
104+
return res.status(500).send('Internal Server Error: Invalid IP address format');
105+
}
106+
107+
// Convert to integers
108+
const minInt = ipToInt(minIp);
109+
const maxInt = ipToInt(maxIp);
110+
111+
// Find the smallest subnet that contains both IPs
112+
// XOR the min and max to find differing bits
113+
const xor = minInt ^ maxInt;
114+
115+
// Count leading zeros to find prefix length
116+
let prefixLen = 32;
117+
for (let i = 0; i < 32; i++) {
118+
if (xor & (1 << i)) {
119+
prefixLen = 31 - i;
120+
break;
121+
}
122+
}
123+
124+
// Get the actual client IP (accounting for reverse proxy)
125+
let clientIp = req.get('X-Real-IP') ||
126+
req.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
127+
req.connection?.remoteAddress ||
128+
req.socket?.remoteAddress ||
129+
req.ip;
130+
131+
// Handle IPv6-mapped IPv4 addresses
132+
if (clientIp && clientIp.startsWith('::ffff:')) {
133+
clientIp = clientIp.substring(7);
134+
}
135+
136+
// Validate client IP
137+
if (!clientIp || !ipRegex.test(clientIp)) {
138+
return res.status(403).send('Forbidden: Unable to determine client IP address');
139+
}
140+
141+
// Check if client IP is in the calculated subnet
142+
if (!isInSubnet(clientIp, minInt, prefixLen)) {
143+
return res.status(403).send('Forbidden: This endpoint is only accessible from the site\'s local network');
144+
}
145+
146+
return next();
147+
}
148+
58149
const { setCurrentSite, loadSites } = require('./currentSite');
59150

60-
module.exports = { requireAuth, requireAdmin, requireLocalhost, setCurrentSite, loadSites };
151+
module.exports = { requireAuth, requireAdmin, requireLocalhost, requireLocalSubnet, setCurrentSite, loadSites };

0 commit comments

Comments
 (0)