From 10f1f73a8a5c53fc3ef8f0e1da4854eaef49e2c1 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 5 Feb 2026 16:00:30 -0500 Subject: [PATCH] feat: add traefik routing --- create-a-container/bin/configure-traefik.js | 187 ++++++++++++++ create-a-container/job-runner.js | 16 ++ .../20260205170000-add-job-serial-group.js | 21 ++ create-a-container/models/job.js | 5 + create-a-container/routers/containers.js | 21 +- .../routers/external-domains.js | 10 + create-a-container/routers/settings.js | 8 +- create-a-container/routers/sites.js | 204 ++++++++++++++- .../20260205170001-base-url-setting.js | 21 ++ create-a-container/utils/traefik.js | 234 ++++++++++++++++++ create-a-container/views/nodes/index.ejs | 5 + create-a-container/views/settings/index.ejs | 22 ++ 12 files changed, 750 insertions(+), 4 deletions(-) create mode 100644 create-a-container/bin/configure-traefik.js create mode 100644 create-a-container/migrations/20260205170000-add-job-serial-group.js create mode 100644 create-a-container/seeders/20260205170001-base-url-setting.js create mode 100644 create-a-container/utils/traefik.js diff --git a/create-a-container/bin/configure-traefik.js b/create-a-container/bin/configure-traefik.js new file mode 100644 index 00000000..f4785736 --- /dev/null +++ b/create-a-container/bin/configure-traefik.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * configure-traefik.js + * + * Background job script that generates Traefik static configuration and + * manages the Traefik container lifecycle for a site. + * + * Usage: node bin/configure-traefik.js --site-id= + * + * The script will: + * 1. Load site configuration including external domains and transport services + * 2. Generate Traefik CLI flags for static configuration + * 3. Create or update the Traefik container: + * - If container doesn't exist: create it and queue a create-container job + * - If container exists: update entrypoint and queue a reconfigure-container job + * + * All output is logged to STDOUT for capture by the job-runner. + * Exit code 0 = success, non-zero = failure. + */ + +const path = require('path'); + +// Load models from parent directory +const db = require(path.join(__dirname, '..', 'models')); +const { Site, Node, Container, Service, TransportService, ExternalDomain, Job } = db; + +// Load utilities +const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli')); +const { + getBaseUrl, + getSystemContainerOwner, + buildTraefikCliFlags +} = require(path.join(__dirname, '..', 'utils', 'traefik')); + +const TRAEFIK_HOSTNAME = 'traefik'; +const TRAEFIK_IMAGE = 'docker.io/library/traefik:v3.0'; + +/** + * Main function + */ +async function main() { + const args = parseArgs(); + + if (!args['site-id']) { + console.error('Usage: node configure-traefik.js --site-id='); + process.exit(1); + } + + const siteId = parseInt(args['site-id'], 10); + console.log(`Starting Traefik configuration for site ID: ${siteId}`); + + // Load site with all necessary associations + const site = await Site.findByPk(siteId, { + include: [ + { + model: ExternalDomain, + as: 'externalDomains' + }, + { + model: Node, + as: 'nodes', + include: [{ + model: Container, + as: 'containers', + include: [{ + model: Service, + as: 'services', + include: [{ + model: TransportService, + as: 'transportService' + }] + }] + }] + } + ] + }); + + if (!site) { + console.error(`Site with ID ${siteId} not found`); + process.exit(1); + } + + console.log(`Site: ${site.name} (${site.internalDomain})`); + console.log(`External domains: ${site.externalDomains?.length || 0}`); + + // Get base URL for HTTP provider + const baseUrl = await getBaseUrl(); + console.log(`Base URL: ${baseUrl}`); + + // Build Traefik CLI flags + const cliFlags = await buildTraefikCliFlags(siteId, site, baseUrl); + console.log(`Generated ${cliFlags.length} CLI flags`); + + // Build entrypoint command + const entrypoint = `traefik ${cliFlags.join(' ')}`; + console.log(`Entrypoint: ${entrypoint.substring(0, 100)}...`); + + // Build environment variables for Cloudflare DNS challenge + const envVars = {}; + for (const domain of site.externalDomains || []) { + if (domain.cloudflareApiEmail && domain.cloudflareApiKey) { + envVars['CF_API_EMAIL'] = domain.cloudflareApiEmail; + envVars['CF_API_KEY'] = domain.cloudflareApiKey; + break; // Traefik uses global env vars for Cloudflare + } + } + + // Find existing Traefik container for this site + let traefikContainer = null; + for (const node of site.nodes || []) { + const existing = node.containers?.find(c => c.hostname === TRAEFIK_HOSTNAME); + if (existing) { + traefikContainer = existing; + break; + } + } + + if (traefikContainer) { + console.log(`Found existing Traefik container (ID: ${traefikContainer.id}, Node: ${traefikContainer.nodeId})`); + + // Update the container's entrypoint and environment variables + await traefikContainer.update({ + entrypoint, + environmentVars: Object.keys(envVars).length > 0 ? JSON.stringify(envVars) : null + }); + console.log('Updated container configuration'); + + // Queue a reconfigure job to restart the container + const reconfigureJob = await Job.create({ + command: `node bin/reconfigure-container.js --container-id=${traefikContainer.id}`, + createdBy: 'system', + serialGroup: `traefik-config-${siteId}` + }); + console.log(`Queued reconfigure job ${reconfigureJob.id}`); + + } else { + console.log('No existing Traefik container found, creating new one'); + + // Find a node in this site to run the container + const availableNode = site.nodes?.[0]; + if (!availableNode) { + console.error('No nodes available in this site'); + process.exit(1); + } + console.log(`Selected node: ${availableNode.name} (ID: ${availableNode.id})`); + + // Get owner for the container + const owner = await getSystemContainerOwner(); + if (!owner) { + console.error('No admin users found to assign as container owner'); + process.exit(1); + } + console.log(`Container owner: ${owner}`); + + // Create the container record + const newContainer = await Container.create({ + hostname: TRAEFIK_HOSTNAME, + username: owner, + status: 'pending', + template: TRAEFIK_IMAGE, + nodeId: availableNode.id, + entrypoint, + environmentVars: Object.keys(envVars).length > 0 ? JSON.stringify(envVars) : null + }); + console.log(`Created container record (ID: ${newContainer.id})`); + + // Queue a create-container job + const createJob = await Job.create({ + command: `node bin/create-container.js --container-id=${newContainer.id}`, + createdBy: 'system', + serialGroup: `traefik-config-${siteId}` + }); + console.log(`Queued create-container job ${createJob.id}`); + + // Link the creation job to the container + await newContainer.update({ creationJobId: createJob.id }); + } + + console.log('Traefik configuration completed successfully!'); + process.exit(0); +} + +// Run the main function +main().catch(err => { + console.error('Unhandled error:', err); + process.exit(1); +}); diff --git a/create-a-container/job-runner.js b/create-a-container/job-runner.js index 644b7c97..d92d5ffb 100644 --- a/create-a-container/job-runner.js +++ b/create-a-container/job-runner.js @@ -30,6 +30,22 @@ async function claimPendingJob() { if (!job) return null; + // If job has a serialGroup, check if another job in that group is running (in database) + if (job.serialGroup) { + const runningInGroup = await db.Job.findOne({ + where: { + serialGroup: job.serialGroup, + status: 'running' + }, + transaction: t, + }); + + if (runningInGroup) { + // Another job in this group is running, skip this one for now + return null; + } + } + await job.update({ status: 'running' }, { transaction: t }); return job; }); diff --git a/create-a-container/migrations/20260205170000-add-job-serial-group.js b/create-a-container/migrations/20260205170000-add-job-serial-group.js new file mode 100644 index 00000000..efbc675a --- /dev/null +++ b/create-a-container/migrations/20260205170000-add-job-serial-group.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Jobs', 'serialGroup', { + type: Sequelize.STRING(255), + allowNull: true, + defaultValue: null + }); + + await queryInterface.addIndex('Jobs', ['serialGroup', 'status'], { + name: 'jobs_serial_group_status_idx' + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex('Jobs', 'jobs_serial_group_status_idx'); + await queryInterface.removeColumn('Jobs', 'serialGroup'); + } +}; diff --git a/create-a-container/models/job.js b/create-a-container/models/job.js index 416de940..47d5a67e 100644 --- a/create-a-container/models/job.js +++ b/create-a-container/models/job.js @@ -19,6 +19,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.ENUM('pending','running','success','failure','cancelled'), allowNull: false, defaultValue: 'pending' + }, + serialGroup: { + type: DataTypes.STRING(255), + allowNull: true, + defaultValue: null } }, { sequelize, diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index 43e3ca1f..2b693327 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, Job, Sequelize, sequelize } = require('../models'); const { requireAuth } = require('../middlewares'); const ProxmoxApi = require('../utils/proxmox-api'); +const { queueTraefikConfigJob } = require('../utils/traefik'); const serviceMap = require('../data/services.json'); /** @@ -338,6 +339,7 @@ router.post('/', async (req, res) => { }, { transaction: t }); // Create services if provided (validate within transaction) + let hasTransportServices = false; if (services && typeof services === 'object') { for (const key in services) { const service = services[key]; @@ -358,6 +360,7 @@ router.post('/', async (req, res) => { // tcp or udp serviceType = 'transport'; protocol = type; + hasTransportServices = true; } const serviceData = { @@ -422,6 +425,11 @@ router.post('/', async (req, res) => { // Commit the transaction await t.commit(); + // Queue Traefik config job if transport services were created + if (hasTransportServices) { + await queueTraefikConfigJob(siteId, req.session.user); + } + await req.flash('success', `Container "${hostname}" is being created. Check back shortly for status updates.`); return res.redirect(`/jobs/${job.id}`); } catch (err) { @@ -511,6 +519,7 @@ router.put('/:id', requireAuth, async (req, res) => { // Wrap all database operations in a transaction let restartJob = null; + let transportServicesChanged = false; await sequelize.transaction(async (t) => { // Update environment variables and entrypoint if changed if (envChanged || entrypointChanged) { @@ -538,9 +547,13 @@ router.put('/:id', requireAuth, async (req, res) => { // Phase 1: Delete marked services for (const key in services) { const service = services[key]; - const { id, deleted } = service; + const { id, deleted, type } = service; if (deleted === 'true' && id) { + // Check if this is a transport service being deleted + if (type === 'tcp' || type === 'udp') { + transportServicesChanged = true; + } await Service.destroy({ where: { id: parseInt(id, 10), containerId: container.id }, transaction: t @@ -571,6 +584,7 @@ router.put('/:id', requireAuth, async (req, res) => { // tcp or udp serviceType = 'transport'; protocol = type; + transportServicesChanged = true; } const serviceData = { @@ -623,6 +637,11 @@ router.put('/:id', requireAuth, async (req, res) => { } }); + // Queue Traefik config job if transport services changed + if (transportServicesChanged) { + await queueTraefikConfigJob(siteId, req.session.user); + } + if (restartJob) { await req.flash('success', 'Container configuration updated. Restarting container...'); return res.redirect(`/jobs/${restartJob.id}`); diff --git a/create-a-container/routers/external-domains.js b/create-a-container/routers/external-domains.js index 76c3e3a8..8e93ae78 100644 --- a/create-a-container/routers/external-domains.js +++ b/create-a-container/routers/external-domains.js @@ -4,6 +4,7 @@ const { ExternalDomain, Site, Sequelize } = require('../models'); const { requireAuth, requireAdmin } = require('../middlewares'); const path = require('path'); const { run } = require('../utils'); +const { queueTraefikConfigJob } = require('../utils/traefik'); const axios = require('axios'); // All routes require authentication @@ -168,6 +169,9 @@ router.post('/', requireAdmin, async (req, res) => { await req.flash('success', `External domain ${name} created successfully (certificate provisioning skipped - missing required fields)`); } + // Queue Traefik config regeneration job + await queueTraefikConfigJob(siteId, req.session?.user?.uid); + return res.redirect(`/sites/${siteId}/external-domains`); } catch (error) { console.error('Error creating external domain:', error); @@ -213,6 +217,9 @@ router.put('/:id', requireAdmin, async (req, res) => { await externalDomain.update(updateData); + // Queue Traefik config regeneration job + await queueTraefikConfigJob(siteId, req.session?.user?.uid); + await req.flash('success', `External domain ${name} updated successfully`); return res.redirect(`/sites/${siteId}/external-domains`); } catch (error) { @@ -246,6 +253,9 @@ router.delete('/:id', requireAdmin, async (req, res) => { const domainName = externalDomain.name; await externalDomain.destroy(); + // Queue Traefik config regeneration job + await queueTraefikConfigJob(siteId, req.session?.user?.uid); + await req.flash('success', `External domain ${domainName} deleted successfully`); return res.redirect(`/sites/${siteId}/external-domains`); } catch (error) { diff --git a/create-a-container/routers/settings.js b/create-a-container/routers/settings.js index 37d308a6..ff2953eb 100644 --- a/create-a-container/routers/settings.js +++ b/create-a-container/routers/settings.js @@ -11,7 +11,8 @@ router.get('/', async (req, res) => { 'push_notification_url', 'push_notification_enabled', 'smtp_url', - 'smtp_noreply_address' + 'smtp_noreply_address', + 'base_url' ]); res.render('settings/index', { @@ -19,6 +20,7 @@ router.get('/', async (req, res) => { pushNotificationEnabled: settings.push_notification_enabled === 'true', smtpUrl: settings.smtp_url || '', smtpNoreplyAddress: settings.smtp_noreply_address || '', + baseUrl: settings.base_url || '', req }); }); @@ -28,7 +30,8 @@ router.post('/', async (req, res) => { push_notification_url, push_notification_enabled, smtp_url, - smtp_noreply_address + smtp_noreply_address, + base_url } = req.body; const enabled = push_notification_enabled === 'on'; @@ -42,6 +45,7 @@ router.post('/', async (req, res) => { await Setting.set('push_notification_enabled', enabled ? 'true' : 'false'); await Setting.set('smtp_url', smtp_url || ''); await Setting.set('smtp_noreply_address', smtp_noreply_address || ''); + await Setting.set('base_url', base_url || ''); await req.flash('success', 'Settings saved successfully'); return res.redirect('/settings'); diff --git a/create-a-container/routers/sites.js b/create-a-container/routers/sites.js index 0824deae..b9e3532e 100644 --- a/create-a-container/routers/sites.js +++ b/create-a-container/routers/sites.js @@ -3,8 +3,9 @@ const os = require('os'); 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 { Site, Node, Container, Service, HTTPService, TransportService, DnsService, ExternalDomain, Job, sequelize } = require('../models'); const { requireAuth, requireAdmin, requireLocalhost, setCurrentSite } = require('../middlewares'); +const { queueTraefikConfigJob } = require('../utils/traefik'); const router = express.Router(); @@ -101,6 +102,167 @@ router.get('/:siteId/nginx.conf', requireLocalhost, async (req, res) => { return res.render('nginx-conf', { httpServices, streamServices, externalDomains: site?.externalDomains || [] }); }); +// GET /sites/:siteId/traefik.json - Dynamic configuration for Traefik HTTP provider +router.get('/:siteId/traefik.json', async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + + // Fetch services for the specific site (only from running containers) + const site = await Site.findByPk(siteId, { + include: [{ + model: Node, + as: 'nodes', + include: [{ + model: Container, + as: 'containers', + where: { status: 'running' }, + required: false, + 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' }); + } + + // Build Traefik dynamic configuration + const config = { + http: { + routers: {}, + services: {} + }, + tcp: { + routers: {}, + services: {} + }, + udp: { + routers: {}, + services: {} + } + }; + + // Helper to sanitize names for Traefik identifiers + const sanitizeName = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // Process all services from site→nodes→containers→services + site?.nodes?.forEach(node => { + node?.containers?.forEach(container => { + container?.services?.forEach(service => { + if (service.type === 'http' && service.httpService) { + const hs = service.httpService; + const domain = hs.externalDomain; + if (!domain) return; + + const fqdn = `${hs.externalHostname}.${domain.name}`; + const routerName = sanitizeName(fqdn); + const serviceName = sanitizeName(fqdn); + const resolverName = domain.name.replace(/[.-]/g, '_'); + + // HTTP Router + config.http.routers[routerName] = { + rule: `Host(\`${fqdn}\`)`, + service: serviceName, + entryPoints: ['websecure'], + tls: { + certResolver: resolverName + } + }; + + // HTTP Service + config.http.services[serviceName] = { + loadBalancer: { + servers: [ + { url: `http://${container.ipv4Address}:${service.internalPort}` } + ] + } + }; + + } else if (service.type === 'transport' && service.transportService) { + const ts = service.transportService; + const protocol = ts.protocol; + const port = ts.externalPort; + const entryPointName = `${protocol}-${port}`; + const routerName = sanitizeName(`${container.hostname}-${protocol}-${port}`); + const serviceName = routerName; + + if (protocol === 'tcp') { + // TCP Router + config.tcp.routers[routerName] = { + entryPoints: [entryPointName], + rule: 'HostSNI(`*`)', + service: serviceName + }; + + // Add TLS if configured + if (ts.tls) { + config.tcp.routers[routerName].tls = {}; + } + + // TCP Service + config.tcp.services[serviceName] = { + loadBalancer: { + servers: [ + { address: `${container.ipv4Address}:${service.internalPort}` } + ] + } + }; + + } else if (protocol === 'udp') { + // UDP Router + config.udp.routers[routerName] = { + entryPoints: [entryPointName], + service: serviceName + }; + + // UDP Service + config.udp.services[serviceName] = { + loadBalancer: { + servers: [ + { address: `${container.ipv4Address}:${service.internalPort}` } + ] + } + }; + } + } + }); + }); + }); + + // Clean up empty sections + if (Object.keys(config.http.routers).length === 0) { + delete config.http; + } + if (Object.keys(config.tcp.routers).length === 0) { + delete config.tcp; + } + if (Object.keys(config.udp.routers).length === 0) { + delete config.udp; + } + + 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); @@ -213,6 +375,46 @@ router.use('/:siteId/nodes', nodesRouter); router.use('/:siteId/containers', containersRouter); router.use('/:siteId/external-domains', externalDomainsRouter); +// POST /sites/:siteId/reconfigure-traefik - Queue Traefik config regeneration job (admin only) +router.post('/:siteId/reconfigure-traefik', requireAdmin, async (req, res) => { + const siteId = parseInt(req.params.siteId, 10); + + try { + const site = await Site.findByPk(siteId); + if (!site) { + await req.flash('error', 'Site not found'); + return res.redirect('/sites'); + } + + const job = await queueTraefikConfigJob(siteId, req.session.user); + + if (job) { + return res.redirect(`/jobs/${job.id}`); + } else { + // Job already pending - find it and redirect to it + const existingJob = await Job.findOne({ + where: { + serialGroup: `traefik-config-${siteId}`, + status: ['pending', 'running'] + }, + order: [['createdAt', 'DESC']] + }); + + if (existingJob) { + await req.flash('info', 'Traefik config job already in progress'); + return res.redirect(`/jobs/${existingJob.id}`); + } + + await req.flash('info', 'Traefik config job already pending'); + return res.redirect(`/sites/${siteId}/nodes`); + } + } catch (err) { + console.error('Error queuing Traefik config job:', err); + await req.flash('error', `Failed to queue Traefik config job: ${err.message}`); + return res.redirect(`/sites/${siteId}/nodes`); + } +}); + // GET /sites - List all sites (available to all authenticated users) router.get('/', async (req, res) => { const sites = await Site.findAll({ diff --git a/create-a-container/seeders/20260205170001-base-url-setting.js b/create-a-container/seeders/20260205170001-base-url-setting.js new file mode 100644 index 00000000..7d16b7c9 --- /dev/null +++ b/create-a-container/seeders/20260205170001-base-url-setting.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert('Settings', [ + { + key: 'base_url', + value: '', + createdAt: new Date(), + updatedAt: new Date() + } + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('Settings', { + key: ['base_url'] + }, {}); + } +}; diff --git a/create-a-container/utils/traefik.js b/create-a-container/utils/traefik.js new file mode 100644 index 00000000..d2c901c6 --- /dev/null +++ b/create-a-container/utils/traefik.js @@ -0,0 +1,234 @@ +/** + * Traefik configuration utilities + */ + +const path = require('path'); +const os = require('os'); +const db = require('../models'); + +const ZEROSSL_ACME_URL = 'https://acme.zerossl.com/v2/DV90'; + +/** + * Fetch ZeroSSL EAB credentials for a given email + * @param {string} email - Email address for EAB credentials + * @returns {Promise<{kid: string, hmac: string}|null>} EAB credentials or null on failure + */ +async function getZeroSslEabCredentials(email) { + try { + console.log(`Fetching ZeroSSL EAB credentials for ${email}...`); + const response = await fetch('https://api.zerossl.com/acme/eab-credentials-email', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ email }) + }); + + const data = await response.json(); + + if (data.success && data.eab_kid && data.eab_hmac_key) { + console.log('ZeroSSL EAB credentials retrieved successfully'); + return { + kid: data.eab_kid, + hmac: data.eab_hmac_key + }; + } + + console.error('ZeroSSL EAB response missing required fields:', data); + return null; + } catch (err) { + console.error('Failed to fetch ZeroSSL EAB credentials:', err.message); + return null; + } +} + +/** + * Get the base URL for Traefik HTTP provider + * Falls back to http://:3000 if not configured + * @returns {Promise} Base URL + */ +async function getBaseUrl() { + const baseUrl = await db.Setting.get('base_url'); + if (baseUrl && baseUrl.trim()) { + return baseUrl.trim(); + } + + // Fallback: use first non-internal IPv4 address + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { + return `http://${iface.address}:3000`; + } + } + } + + // Last resort fallback + return 'http://localhost:3000'; +} + +/** + * Sanitize a domain name for use as a Traefik certificate resolver name + * Replaces dots and hyphens with underscores for valid identifier + * @param {string} domain - Domain name (e.g., 'example.com') + * @returns {string} Sanitized name (e.g., 'example_com') + */ +function sanitizeDomainForResolver(domain) { + return domain.replace(/[.-]/g, '_'); +} + +/** + * Queue a Traefik configuration job for a site + * Only queues if no pending job exists for the same site + * @param {number} siteId - Site ID + * @param {string} [createdBy] - Username of who triggered the job + * @returns {Promise} Created job or null if already pending + */ +async function queueTraefikConfigJob(siteId, createdBy = null) { + const serialGroup = `traefik-config-${siteId}`; + + // Check if a pending job already exists for this site + const existingPending = await db.Job.findOne({ + where: { + serialGroup, + status: 'pending' + } + }); + + if (existingPending) { + console.log(`Traefik config job already pending for site ${siteId}`); + return null; + } + + // Create the job + const job = await db.Job.create({ + command: `node bin/configure-traefik.js --site-id=${siteId}`, + createdBy, + serialGroup + }); + + console.log(`Queued Traefik config job ${job.id} for site ${siteId}`); + return job; +} + +/** + * Get the lowest UID user from the first admin group + * Used for assigning ownership of system containers + * @returns {Promise} Username (uid) or null if no admin users exist + */ +async function getSystemContainerOwner() { + // Find first admin group + const adminGroup = await db.Group.findOne({ + where: { isAdmin: true }, + order: [['gidNumber', 'ASC']], + include: [{ + model: db.User, + as: 'users', + through: { attributes: [] } + }] + }); + + if (!adminGroup || !adminGroup.users || adminGroup.users.length === 0) { + return null; + } + + // Sort by uidNumber and return lowest + const sortedUsers = adminGroup.users.sort((a, b) => a.uidNumber - b.uidNumber); + return sortedUsers[0].uid; +} + +/** + * Build Traefik CLI flags for static configuration + * @param {number} siteId - Site ID + * @param {object} site - Site with externalDomains and transport services + * @param {string} baseUrl - Base URL for HTTP provider + * @returns {Promise} Array of CLI flags + */ +async function buildTraefikCliFlags(siteId, site, baseUrl) { + const flags = []; + + // Logging + flags.push('--log.level=DEBUG'); + flags.push('--accesslog=true'); + + // HTTP provider + flags.push(`--providers.http.endpoint=${baseUrl}/sites/${siteId}/traefik.json`); + flags.push('--providers.http.pollInterval=10s'); + + // Web entrypoint (HTTP -> HTTPS redirect) + flags.push('--entrypoints.web.address=:80'); + flags.push('--entrypoints.web.http.redirections.entryPoint.to=websecure'); + flags.push('--entrypoints.web.http.redirections.entryPoint.scheme=https'); + + // Websecure entrypoint (HTTPS) + flags.push('--entrypoints.websecure.address=:443'); + flags.push('--entrypoints.websecure.http.tls=true'); + + // Certificate resolvers for each external domain + for (const domain of site.externalDomains || []) { + const resolverName = sanitizeDomainForResolver(domain.name); + + if (domain.acmeEmail) { + flags.push(`--certificatesresolvers.${resolverName}.acme.email=${domain.acmeEmail}`); + } + + if (domain.acmeDirectoryUrl) { + flags.push(`--certificatesresolvers.${resolverName}.acme.caServer=${domain.acmeDirectoryUrl}`); + + // ZeroSSL requires EAB credentials + if (domain.acmeDirectoryUrl === ZEROSSL_ACME_URL && domain.acmeEmail) { + const eab = await getZeroSslEabCredentials(domain.acmeEmail); + if (eab) { + flags.push(`--certificatesresolvers.${resolverName}.acme.eab.kid=${eab.kid}`); + flags.push(`--certificatesresolvers.${resolverName}.acme.eab.hmacEncoded=${eab.hmac}`); + } else { + console.warn(`Warning: Could not fetch ZeroSSL EAB credentials for ${domain.name}, certificate issuance may fail`); + } + } + } + + // Certificate storage path inside container + flags.push(`--certificatesresolvers.${resolverName}.acme.storage=/acme/${resolverName}.json`); + + // DNS challenge with Cloudflare if credentials provided + if (domain.cloudflareApiEmail && domain.cloudflareApiKey) { + flags.push(`--certificatesresolvers.${resolverName}.acme.dnsChallenge.provider=cloudflare`); + } else { + // Fallback to HTTP challenge + flags.push(`--certificatesresolvers.${resolverName}.acme.httpChallenge.entryPoint=web`); + } + } + + // Collect transport services for TCP/UDP entrypoints + const transportServices = []; + for (const node of site.nodes || []) { + for (const container of node.containers || []) { + for (const service of container.services || []) { + if (service.type === 'transport' && service.transportService) { + transportServices.push(service.transportService); + } + } + } + } + + // Create entrypoints for each transport service + for (const ts of transportServices) { + const protocol = ts.protocol; + const port = ts.externalPort; + const entryPointName = `${protocol}-${port}`; + + if (protocol === 'udp') { + flags.push(`--entrypoints.${entryPointName}.address=:${port}/udp`); + } else { + flags.push(`--entrypoints.${entryPointName}.address=:${port}`); + } + } + + return flags; +} + +module.exports = { + getBaseUrl, + sanitizeDomainForResolver, + queueTraefikConfigJob, + getSystemContainerOwner, + buildTraefikCliFlags +}; diff --git a/create-a-container/views/nodes/index.ejs b/create-a-container/views/nodes/index.ejs index f48da939..cbf039a7 100644 --- a/create-a-container/views/nodes/index.ejs +++ b/create-a-container/views/nodes/index.ejs @@ -12,6 +12,11 @@

Proxmox Nodes for <%= site.name %>

+
+ +
Import Nodes New Node
diff --git a/create-a-container/views/settings/index.ejs b/create-a-container/views/settings/index.ejs index 104fe800..8824c548 100644 --- a/create-a-container/views/settings/index.ejs +++ b/create-a-container/views/settings/index.ejs @@ -92,6 +92,28 @@
+
+
Traefik Configuration
+ +
+ + +
+ Base URL for Traefik HTTP provider to reach this application. If empty, defaults to http://<job-runner-ip>:3000 +
+
+
+