Skip to content

Commit 10f1f73

Browse files
committed
feat: add traefik routing
1 parent 3421e6e commit 10f1f73

12 files changed

Lines changed: 750 additions & 4 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env node
2+
/**
3+
* configure-traefik.js
4+
*
5+
* Background job script that generates Traefik static configuration and
6+
* manages the Traefik container lifecycle for a site.
7+
*
8+
* Usage: node bin/configure-traefik.js --site-id=<id>
9+
*
10+
* The script will:
11+
* 1. Load site configuration including external domains and transport services
12+
* 2. Generate Traefik CLI flags for static configuration
13+
* 3. Create or update the Traefik container:
14+
* - If container doesn't exist: create it and queue a create-container job
15+
* - If container exists: update entrypoint and queue a reconfigure-container job
16+
*
17+
* All output is logged to STDOUT for capture by the job-runner.
18+
* Exit code 0 = success, non-zero = failure.
19+
*/
20+
21+
const path = require('path');
22+
23+
// Load models from parent directory
24+
const db = require(path.join(__dirname, '..', 'models'));
25+
const { Site, Node, Container, Service, TransportService, ExternalDomain, Job } = db;
26+
27+
// Load utilities
28+
const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli'));
29+
const {
30+
getBaseUrl,
31+
getSystemContainerOwner,
32+
buildTraefikCliFlags
33+
} = require(path.join(__dirname, '..', 'utils', 'traefik'));
34+
35+
const TRAEFIK_HOSTNAME = 'traefik';
36+
const TRAEFIK_IMAGE = 'docker.io/library/traefik:v3.0';
37+
38+
/**
39+
* Main function
40+
*/
41+
async function main() {
42+
const args = parseArgs();
43+
44+
if (!args['site-id']) {
45+
console.error('Usage: node configure-traefik.js --site-id=<id>');
46+
process.exit(1);
47+
}
48+
49+
const siteId = parseInt(args['site-id'], 10);
50+
console.log(`Starting Traefik configuration for site ID: ${siteId}`);
51+
52+
// Load site with all necessary associations
53+
const site = await Site.findByPk(siteId, {
54+
include: [
55+
{
56+
model: ExternalDomain,
57+
as: 'externalDomains'
58+
},
59+
{
60+
model: Node,
61+
as: 'nodes',
62+
include: [{
63+
model: Container,
64+
as: 'containers',
65+
include: [{
66+
model: Service,
67+
as: 'services',
68+
include: [{
69+
model: TransportService,
70+
as: 'transportService'
71+
}]
72+
}]
73+
}]
74+
}
75+
]
76+
});
77+
78+
if (!site) {
79+
console.error(`Site with ID ${siteId} not found`);
80+
process.exit(1);
81+
}
82+
83+
console.log(`Site: ${site.name} (${site.internalDomain})`);
84+
console.log(`External domains: ${site.externalDomains?.length || 0}`);
85+
86+
// Get base URL for HTTP provider
87+
const baseUrl = await getBaseUrl();
88+
console.log(`Base URL: ${baseUrl}`);
89+
90+
// Build Traefik CLI flags
91+
const cliFlags = await buildTraefikCliFlags(siteId, site, baseUrl);
92+
console.log(`Generated ${cliFlags.length} CLI flags`);
93+
94+
// Build entrypoint command
95+
const entrypoint = `traefik ${cliFlags.join(' ')}`;
96+
console.log(`Entrypoint: ${entrypoint.substring(0, 100)}...`);
97+
98+
// Build environment variables for Cloudflare DNS challenge
99+
const envVars = {};
100+
for (const domain of site.externalDomains || []) {
101+
if (domain.cloudflareApiEmail && domain.cloudflareApiKey) {
102+
envVars['CF_API_EMAIL'] = domain.cloudflareApiEmail;
103+
envVars['CF_API_KEY'] = domain.cloudflareApiKey;
104+
break; // Traefik uses global env vars for Cloudflare
105+
}
106+
}
107+
108+
// Find existing Traefik container for this site
109+
let traefikContainer = null;
110+
for (const node of site.nodes || []) {
111+
const existing = node.containers?.find(c => c.hostname === TRAEFIK_HOSTNAME);
112+
if (existing) {
113+
traefikContainer = existing;
114+
break;
115+
}
116+
}
117+
118+
if (traefikContainer) {
119+
console.log(`Found existing Traefik container (ID: ${traefikContainer.id}, Node: ${traefikContainer.nodeId})`);
120+
121+
// Update the container's entrypoint and environment variables
122+
await traefikContainer.update({
123+
entrypoint,
124+
environmentVars: Object.keys(envVars).length > 0 ? JSON.stringify(envVars) : null
125+
});
126+
console.log('Updated container configuration');
127+
128+
// Queue a reconfigure job to restart the container
129+
const reconfigureJob = await Job.create({
130+
command: `node bin/reconfigure-container.js --container-id=${traefikContainer.id}`,
131+
createdBy: 'system',
132+
serialGroup: `traefik-config-${siteId}`
133+
});
134+
console.log(`Queued reconfigure job ${reconfigureJob.id}`);
135+
136+
} else {
137+
console.log('No existing Traefik container found, creating new one');
138+
139+
// Find a node in this site to run the container
140+
const availableNode = site.nodes?.[0];
141+
if (!availableNode) {
142+
console.error('No nodes available in this site');
143+
process.exit(1);
144+
}
145+
console.log(`Selected node: ${availableNode.name} (ID: ${availableNode.id})`);
146+
147+
// Get owner for the container
148+
const owner = await getSystemContainerOwner();
149+
if (!owner) {
150+
console.error('No admin users found to assign as container owner');
151+
process.exit(1);
152+
}
153+
console.log(`Container owner: ${owner}`);
154+
155+
// Create the container record
156+
const newContainer = await Container.create({
157+
hostname: TRAEFIK_HOSTNAME,
158+
username: owner,
159+
status: 'pending',
160+
template: TRAEFIK_IMAGE,
161+
nodeId: availableNode.id,
162+
entrypoint,
163+
environmentVars: Object.keys(envVars).length > 0 ? JSON.stringify(envVars) : null
164+
});
165+
console.log(`Created container record (ID: ${newContainer.id})`);
166+
167+
// Queue a create-container job
168+
const createJob = await Job.create({
169+
command: `node bin/create-container.js --container-id=${newContainer.id}`,
170+
createdBy: 'system',
171+
serialGroup: `traefik-config-${siteId}`
172+
});
173+
console.log(`Queued create-container job ${createJob.id}`);
174+
175+
// Link the creation job to the container
176+
await newContainer.update({ creationJobId: createJob.id });
177+
}
178+
179+
console.log('Traefik configuration completed successfully!');
180+
process.exit(0);
181+
}
182+
183+
// Run the main function
184+
main().catch(err => {
185+
console.error('Unhandled error:', err);
186+
process.exit(1);
187+
});

create-a-container/job-runner.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ async function claimPendingJob() {
3030

3131
if (!job) return null;
3232

33+
// If job has a serialGroup, check if another job in that group is running (in database)
34+
if (job.serialGroup) {
35+
const runningInGroup = await db.Job.findOne({
36+
where: {
37+
serialGroup: job.serialGroup,
38+
status: 'running'
39+
},
40+
transaction: t,
41+
});
42+
43+
if (runningInGroup) {
44+
// Another job in this group is running, skip this one for now
45+
return null;
46+
}
47+
}
48+
3349
await job.update({ status: 'running' }, { transaction: t });
3450
return job;
3551
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.addColumn('Jobs', 'serialGroup', {
7+
type: Sequelize.STRING(255),
8+
allowNull: true,
9+
defaultValue: null
10+
});
11+
12+
await queryInterface.addIndex('Jobs', ['serialGroup', 'status'], {
13+
name: 'jobs_serial_group_status_idx'
14+
});
15+
},
16+
17+
async down(queryInterface, Sequelize) {
18+
await queryInterface.removeIndex('Jobs', 'jobs_serial_group_status_idx');
19+
await queryInterface.removeColumn('Jobs', 'serialGroup');
20+
}
21+
};

create-a-container/models/job.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ module.exports = (sequelize, DataTypes) => {
1919
type: DataTypes.ENUM('pending','running','success','failure','cancelled'),
2020
allowNull: false,
2121
defaultValue: 'pending'
22+
},
23+
serialGroup: {
24+
type: DataTypes.STRING(255),
25+
allowNull: true,
26+
defaultValue: null
2227
}
2328
}, {
2429
sequelize,

create-a-container/routers/containers.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const dns = require('dns').promises;
55
const { Container, Service, HTTPService, TransportService, DnsService, Node, Site, ExternalDomain, Job, Sequelize, sequelize } = require('../models');
66
const { requireAuth } = require('../middlewares');
77
const ProxmoxApi = require('../utils/proxmox-api');
8+
const { queueTraefikConfigJob } = require('../utils/traefik');
89
const serviceMap = require('../data/services.json');
910

1011
/**
@@ -338,6 +339,7 @@ router.post('/', async (req, res) => {
338339
}, { transaction: t });
339340

340341
// Create services if provided (validate within transaction)
342+
let hasTransportServices = false;
341343
if (services && typeof services === 'object') {
342344
for (const key in services) {
343345
const service = services[key];
@@ -358,6 +360,7 @@ router.post('/', async (req, res) => {
358360
// tcp or udp
359361
serviceType = 'transport';
360362
protocol = type;
363+
hasTransportServices = true;
361364
}
362365

363366
const serviceData = {
@@ -422,6 +425,11 @@ router.post('/', async (req, res) => {
422425
// Commit the transaction
423426
await t.commit();
424427

428+
// Queue Traefik config job if transport services were created
429+
if (hasTransportServices) {
430+
await queueTraefikConfigJob(siteId, req.session.user);
431+
}
432+
425433
await req.flash('success', `Container "${hostname}" is being created. Check back shortly for status updates.`);
426434
return res.redirect(`/jobs/${job.id}`);
427435
} catch (err) {
@@ -511,6 +519,7 @@ router.put('/:id', requireAuth, async (req, res) => {
511519

512520
// Wrap all database operations in a transaction
513521
let restartJob = null;
522+
let transportServicesChanged = false;
514523
await sequelize.transaction(async (t) => {
515524
// Update environment variables and entrypoint if changed
516525
if (envChanged || entrypointChanged) {
@@ -538,9 +547,13 @@ router.put('/:id', requireAuth, async (req, res) => {
538547
// Phase 1: Delete marked services
539548
for (const key in services) {
540549
const service = services[key];
541-
const { id, deleted } = service;
550+
const { id, deleted, type } = service;
542551

543552
if (deleted === 'true' && id) {
553+
// Check if this is a transport service being deleted
554+
if (type === 'tcp' || type === 'udp') {
555+
transportServicesChanged = true;
556+
}
544557
await Service.destroy({
545558
where: { id: parseInt(id, 10), containerId: container.id },
546559
transaction: t
@@ -571,6 +584,7 @@ router.put('/:id', requireAuth, async (req, res) => {
571584
// tcp or udp
572585
serviceType = 'transport';
573586
protocol = type;
587+
transportServicesChanged = true;
574588
}
575589

576590
const serviceData = {
@@ -623,6 +637,11 @@ router.put('/:id', requireAuth, async (req, res) => {
623637
}
624638
});
625639

640+
// Queue Traefik config job if transport services changed
641+
if (transportServicesChanged) {
642+
await queueTraefikConfigJob(siteId, req.session.user);
643+
}
644+
626645
if (restartJob) {
627646
await req.flash('success', 'Container configuration updated. Restarting container...');
628647
return res.redirect(`/jobs/${restartJob.id}`);

create-a-container/routers/external-domains.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { ExternalDomain, Site, Sequelize } = require('../models');
44
const { requireAuth, requireAdmin } = require('../middlewares');
55
const path = require('path');
66
const { run } = require('../utils');
7+
const { queueTraefikConfigJob } = require('../utils/traefik');
78
const axios = require('axios');
89

910
// All routes require authentication
@@ -168,6 +169,9 @@ router.post('/', requireAdmin, async (req, res) => {
168169
await req.flash('success', `External domain ${name} created successfully (certificate provisioning skipped - missing required fields)`);
169170
}
170171

172+
// Queue Traefik config regeneration job
173+
await queueTraefikConfigJob(siteId, req.session?.user?.uid);
174+
171175
return res.redirect(`/sites/${siteId}/external-domains`);
172176
} catch (error) {
173177
console.error('Error creating external domain:', error);
@@ -213,6 +217,9 @@ router.put('/:id', requireAdmin, async (req, res) => {
213217

214218
await externalDomain.update(updateData);
215219

220+
// Queue Traefik config regeneration job
221+
await queueTraefikConfigJob(siteId, req.session?.user?.uid);
222+
216223
await req.flash('success', `External domain ${name} updated successfully`);
217224
return res.redirect(`/sites/${siteId}/external-domains`);
218225
} catch (error) {
@@ -246,6 +253,9 @@ router.delete('/:id', requireAdmin, async (req, res) => {
246253
const domainName = externalDomain.name;
247254
await externalDomain.destroy();
248255

256+
// Queue Traefik config regeneration job
257+
await queueTraefikConfigJob(siteId, req.session?.user?.uid);
258+
249259
await req.flash('success', `External domain ${domainName} deleted successfully`);
250260
return res.redirect(`/sites/${siteId}/external-domains`);
251261
} catch (error) {

create-a-container/routers/settings.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@ router.get('/', async (req, res) => {
1111
'push_notification_url',
1212
'push_notification_enabled',
1313
'smtp_url',
14-
'smtp_noreply_address'
14+
'smtp_noreply_address',
15+
'base_url'
1516
]);
1617

1718
res.render('settings/index', {
1819
pushNotificationUrl: settings.push_notification_url || '',
1920
pushNotificationEnabled: settings.push_notification_enabled === 'true',
2021
smtpUrl: settings.smtp_url || '',
2122
smtpNoreplyAddress: settings.smtp_noreply_address || '',
23+
baseUrl: settings.base_url || '',
2224
req
2325
});
2426
});
@@ -28,7 +30,8 @@ router.post('/', async (req, res) => {
2830
push_notification_url,
2931
push_notification_enabled,
3032
smtp_url,
31-
smtp_noreply_address
33+
smtp_noreply_address,
34+
base_url
3235
} = req.body;
3336

3437
const enabled = push_notification_enabled === 'on';
@@ -42,6 +45,7 @@ router.post('/', async (req, res) => {
4245
await Setting.set('push_notification_enabled', enabled ? 'true' : 'false');
4346
await Setting.set('smtp_url', smtp_url || '');
4447
await Setting.set('smtp_noreply_address', smtp_noreply_address || '');
48+
await Setting.set('base_url', base_url || '');
4549

4650
await req.flash('success', 'Settings saved successfully');
4751
return res.redirect('/settings');

0 commit comments

Comments
 (0)