From 7f831510bdd57a35e077555cae971b7a31c6f911 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 12:54:17 -0700 Subject: [PATCH] =?UTF-8?q?[grade=3DB]=20DC-106:=20Caddyfile-as-code=20?= =?UTF-8?q?=E2=80=94=20visual=20reverse=20proxy=20builder=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashcaddy-api/routes/caddycode.js | 227 ++++++++++++++++++++++++++++++ dashcaddy-api/src/app.js | 6 + 2 files changed, 233 insertions(+) create mode 100644 dashcaddy-api/routes/caddycode.js diff --git a/dashcaddy-api/routes/caddycode.js b/dashcaddy-api/routes/caddycode.js new file mode 100644 index 0000000..2e5a755 --- /dev/null +++ b/dashcaddy-api/routes/caddycode.js @@ -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; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 27e421b..fb110b9 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -66,6 +66,7 @@ const discoverAdoptRoutes = require('../routes/discover-adopt'); const catalogRoutes = require('../routes/catalog'); const wizardRoutes = require('../routes/wizard'); const disasterRoutes = require('../routes/disaster-recovery'); +const caddycodeRoutes = require('../routes/caddycode'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -641,6 +642,11 @@ async function createApp() { log: ctx.log, asyncHandler: ctx.asyncHandler, })); + + // DC-106: Caddyfile-as-code — visual reverse proxy builder + apiRouter.use(caddycodeRoutes({ + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,