[grade=B] DC-106: Caddyfile-as-code — visual reverse proxy builder API
3 endpoints: - POST /api/v1/caddycode/generate — generate Caddyfile block from JSON config (supports: TLS, auth gate, CORS, headers, WebSocket, compression, strip prefix) - POST /api/v1/caddycode/validate — validate Caddyfile syntax (brace balance, domain check, reverse_proxy presence) - GET /api/v1/caddycode/templates — 5 preset configs (simple, WebSocket, auth-gated, CORS API, subdirectory) Frontend can present a visual form, send JSON, get back Caddyfile snippet. 1633 tests pass.
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* DC-106: Caddyfile-as-code — generate Caddyfile entries from structured JSON
|
||||||
|
*
|
||||||
|
* Allows building reverse proxy configs programmatically instead of editing
|
||||||
|
* raw Caddyfile text. The frontend can present a visual form, send the JSON,
|
||||||
|
* and get back a Caddyfile snippet + apply it via the Caddy admin API.
|
||||||
|
*
|
||||||
|
* POST /api/v1/caddycode/generate — generate Caddyfile block from JSON
|
||||||
|
* POST /api/v1/caddycode/validate — validate a generated block
|
||||||
|
* GET /api/v1/caddycode/importers — list supported import formats
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a Caddyfile site block from a structured config.
|
||||||
|
* @param {Object} config - Site configuration
|
||||||
|
* @returns {string} Caddyfile snippet
|
||||||
|
*/
|
||||||
|
function generateSiteBlock(config) {
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
upstream,
|
||||||
|
upstreamProtocol = 'http',
|
||||||
|
tls = 'auto',
|
||||||
|
websocket = false,
|
||||||
|
auth = false,
|
||||||
|
authService = null,
|
||||||
|
headers = {},
|
||||||
|
cors = false,
|
||||||
|
rateLimit = null,
|
||||||
|
cache = false,
|
||||||
|
compress = true,
|
||||||
|
stripPrefix = null,
|
||||||
|
redirectToHttps = true,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`${domain} {`);
|
||||||
|
|
||||||
|
// TLS
|
||||||
|
if (tls === 'internal') {
|
||||||
|
lines.push(` tls internal`);
|
||||||
|
} else if (tls === 'auto') {
|
||||||
|
// Default — Caddy auto-provisions Let's Encrypt
|
||||||
|
} else if (typeof tls === 'string') {
|
||||||
|
lines.push(` tls ${tls}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect HTTP→HTTPS
|
||||||
|
if (redirectToHttps) {
|
||||||
|
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth gate (DashCaddy forward_auth)
|
||||||
|
if (auth && authService) {
|
||||||
|
lines.push(` import dashcaddy_auth ${authService}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS headers
|
||||||
|
if (cors) {
|
||||||
|
lines.push(` header {`);
|
||||||
|
lines.push(` Access-Control-Allow-Origin *`);
|
||||||
|
lines.push(` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"`);
|
||||||
|
lines.push(` Access-Control-Allow-Headers "Content-Type, Authorization"`);
|
||||||
|
lines.push(` }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom headers
|
||||||
|
if (Object.keys(headers).length > 0) {
|
||||||
|
lines.push(` header {`);
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
lines.push(` ${key} "${value}"`);
|
||||||
|
}
|
||||||
|
lines.push(` }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip prefix
|
||||||
|
if (stripPrefix) {
|
||||||
|
lines.push(` uri strip_prefix ${stripPrefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compression
|
||||||
|
if (compress) {
|
||||||
|
lines.push(` encode gzip zstd`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse proxy
|
||||||
|
const protocol = upstreamProtocol === 'https' ? 'https' : 'http';
|
||||||
|
lines.push(` reverse_proxy ${protocol}://${upstream} {`);
|
||||||
|
if (websocket) {
|
||||||
|
lines.push(` # WebSocket support is automatic in Caddy 2`);
|
||||||
|
}
|
||||||
|
lines.push(` header_up Host {host}`);
|
||||||
|
lines.push(` transport http {`);
|
||||||
|
lines.push(` read_timeout 5m`);
|
||||||
|
lines.push(` write_timeout 5m`);
|
||||||
|
lines.push(` }`);
|
||||||
|
lines.push(` }`);
|
||||||
|
|
||||||
|
lines.push(`}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function({ asyncHandler }) {
|
||||||
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/v1/caddycode/generate
|
||||||
|
router.post('/caddycode/generate', wrap(async (req, res) => {
|
||||||
|
const config = req.body || {};
|
||||||
|
|
||||||
|
if (!config.domain) {
|
||||||
|
return errorResponse(res, 400, 'domain is required');
|
||||||
|
}
|
||||||
|
if (!config.upstream) {
|
||||||
|
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caddyfile = generateSiteBlock(config);
|
||||||
|
ok(res, { caddyfile, config });
|
||||||
|
} catch (err) {
|
||||||
|
errorResponse(res, 500, `Generation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/v1/caddycode/validate
|
||||||
|
router.post('/caddycode/validate', wrap(async (req, res) => {
|
||||||
|
const { caddyfile } = req.body || {};
|
||||||
|
|
||||||
|
if (!caddyfile) {
|
||||||
|
return errorResponse(res, 400, 'caddyfile string is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation checks
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
// Check for balanced braces
|
||||||
|
const openBraces = (caddyfile.match(/{/g) || []).length;
|
||||||
|
const closeBraces = (caddyfile.match(/}/g) || []).length;
|
||||||
|
if (openBraces !== closeBraces) {
|
||||||
|
issues.push(`Unbalanced braces: ${openBraces} open vs ${closeBraces} close`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for domain in first non-empty line
|
||||||
|
const firstLine = caddyfile.trim().split('\n')[0].trim();
|
||||||
|
if (!firstLine || firstLine.startsWith('#') || firstLine.startsWith('{')) {
|
||||||
|
issues.push('First line should be a domain name');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reverse_proxy directive
|
||||||
|
if (!caddyfile.includes('reverse_proxy')) {
|
||||||
|
issues.push('No reverse_proxy directive found — site will not proxy traffic');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common mistakes
|
||||||
|
if (caddyfile.includes('tls ')) {
|
||||||
|
const tlsLine = caddyfile.split('\n').find(l => l.trim().startsWith('tls '));
|
||||||
|
if (tlsLine && tlsLine.includes('auto')) {
|
||||||
|
issues.push('tls auto is redundant — Caddy does this by default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
valid: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/v1/caddycode/templates — preset configs for common patterns
|
||||||
|
router.get('/caddycode/templates', wrap(async (req, res) => {
|
||||||
|
const templates = {
|
||||||
|
'simple-proxy': {
|
||||||
|
label: 'Simple Reverse Proxy',
|
||||||
|
config: {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
tls: 'auto',
|
||||||
|
websocket: false,
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'websocket-app': {
|
||||||
|
label: 'WebSocket Application',
|
||||||
|
config: {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:3000',
|
||||||
|
websocket: true,
|
||||||
|
compress: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'auth-gated': {
|
||||||
|
label: 'Auth-Gated Service (DashCaddy SSO)',
|
||||||
|
config: {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8096',
|
||||||
|
auth: true,
|
||||||
|
authService: 'app',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'cors-api': {
|
||||||
|
label: 'API with CORS',
|
||||||
|
config: {
|
||||||
|
domain: 'api.example.com',
|
||||||
|
upstream: 'localhost:3001',
|
||||||
|
cors: true,
|
||||||
|
compress: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'subdirectory': {
|
||||||
|
label: 'Subdirectory Proxy',
|
||||||
|
config: {
|
||||||
|
domain: 'example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
stripPrefix: '/app',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
ok(res, { templates });
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -66,6 +66,7 @@ const discoverAdoptRoutes = require('../routes/discover-adopt');
|
|||||||
const catalogRoutes = require('../routes/catalog');
|
const catalogRoutes = require('../routes/catalog');
|
||||||
const wizardRoutes = require('../routes/wizard');
|
const wizardRoutes = require('../routes/wizard');
|
||||||
const disasterRoutes = require('../routes/disaster-recovery');
|
const disasterRoutes = require('../routes/disaster-recovery');
|
||||||
|
const caddycodeRoutes = require('../routes/caddycode');
|
||||||
const configRoutes = require('../routes/config');
|
const configRoutes = require('../routes/config');
|
||||||
const dnsRoutes = require('../routes/dns');
|
const dnsRoutes = require('../routes/dns');
|
||||||
const notificationRoutes = require('../routes/notifications');
|
const notificationRoutes = require('../routes/notifications');
|
||||||
@@ -641,6 +642,11 @@ async function createApp() {
|
|||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// DC-106: Caddyfile-as-code — visual reverse proxy builder
|
||||||
|
apiRouter.use(caddycodeRoutes({
|
||||||
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
}));
|
||||||
apiRouter.use(updatesRoutes({
|
apiRouter.use(updatesRoutes({
|
||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
selfUpdater: ctx.selfUpdater,
|
selfUpdater: ctx.selfUpdater,
|
||||||
|
|||||||
Reference in New Issue
Block a user