Add subdirectory routing mode for public domain deployments
Apps can now be served at domain.com/appname/ instead of requiring subdomain DNS records (appname.domain.com). Supports three subpath modes per template: native (URL base env var), strip (handle_path), and none (incompatible warning). Tested on Linux with deploy/removal lifecycle verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,10 @@ module.exports = function(ctx, helpers) {
|
||||
if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
|
||||
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format');
|
||||
}
|
||||
// Block reserved path names in subdirectory mode
|
||||
if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) {
|
||||
return ctx.errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`);
|
||||
}
|
||||
}
|
||||
if (config.port && !isValidPort(config.port)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)');
|
||||
@@ -236,8 +240,11 @@ module.exports = function(ctx, helpers) {
|
||||
ctx.log.info('deploy', 'Container is healthy', { containerId });
|
||||
}
|
||||
|
||||
const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain;
|
||||
|
||||
// DNS record creation (skip in subdirectory mode — only one domain needed)
|
||||
let dnsWarning = null;
|
||||
if (config.createDns) {
|
||||
if (config.createDns && !isSubdirectoryMode) {
|
||||
try {
|
||||
await ctx.dns.createRecord(config.subdomain, config.ip);
|
||||
ctx.log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
||||
@@ -248,7 +255,12 @@ module.exports = function(ctx, helpers) {
|
||||
}
|
||||
}
|
||||
|
||||
const caddyOptions = { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [] };
|
||||
// Caddy config generation
|
||||
const caddyOptions = {
|
||||
tailscaleOnly: config.tailscaleOnly || false,
|
||||
allowedIPs: config.allowedIPs || [],
|
||||
subpathSupport: template.subpathSupport || 'strip',
|
||||
};
|
||||
let caddyConfig;
|
||||
if (template.isStaticSite) {
|
||||
const sitePath = platformPaths.sitePath(config.subdomain);
|
||||
@@ -261,29 +273,40 @@ module.exports = function(ctx, helpers) {
|
||||
caddyConfig = ctx.caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions);
|
||||
}
|
||||
|
||||
await helpers.addCaddyConfig(config.subdomain, caddyConfig);
|
||||
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), tailscaleOnly: config.tailscaleOnly || false });
|
||||
// Write Caddy config (subdirectory: inject into main block; subdomain: append as new block)
|
||||
if (isSubdirectoryMode && !template.isStaticSite) {
|
||||
await helpers.ensureMainDomainBlock();
|
||||
await helpers.addSubpathConfig(config.subdomain, caddyConfig);
|
||||
} else {
|
||||
await helpers.addCaddyConfig(config.subdomain, caddyConfig);
|
||||
}
|
||||
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false });
|
||||
|
||||
// Build service URL based on routing mode
|
||||
const serviceUrl = ctx.buildServiceUrl(config.subdomain);
|
||||
|
||||
await ctx.addServiceToConfig({
|
||||
id: config.subdomain, name: template.name,
|
||||
logo: template.logo || `/assets/${appId}.png`,
|
||||
url: serviceUrl,
|
||||
containerId, appTemplate: appId,
|
||||
tailscaleOnly: config.tailscaleOnly || false,
|
||||
routingMode: ctx.siteConfig.routingMode,
|
||||
deployedAt: new Date().toISOString()
|
||||
});
|
||||
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
|
||||
|
||||
const response = {
|
||||
success: true, containerId, usedExisting,
|
||||
url: `https://${ctx.buildDomain(config.subdomain)}`,
|
||||
url: serviceUrl,
|
||||
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
|
||||
setupInstructions: template.setupInstructions || []
|
||||
};
|
||||
if (dnsWarning) response.warning = dnsWarning;
|
||||
|
||||
const notificationMessage = usedExisting
|
||||
? `**${template.name}** configured using existing container.\nURL: https://${ctx.buildDomain(config.subdomain)}`
|
||||
: `**${template.name}** has been deployed successfully.\nURL: https://${ctx.buildDomain(config.subdomain)}`;
|
||||
? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
|
||||
: `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
|
||||
ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success');
|
||||
|
||||
res.json(response);
|
||||
|
||||
@@ -109,6 +109,18 @@ module.exports = function(ctx) {
|
||||
processed.docker.environment.PLEX_CLAIM = config.plexClaimToken;
|
||||
}
|
||||
|
||||
// Inject URL base env var for subdirectory routing mode
|
||||
if (ctx.siteConfig.routingMode === 'subdirectory' && template.urlBaseEnv) {
|
||||
processed.docker.environment = processed.docker.environment || {};
|
||||
const basePath = `/${config.subdomain}`;
|
||||
// Some apps need the full URL, not just the path
|
||||
if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) {
|
||||
processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/';
|
||||
} else {
|
||||
processed.docker.environment[template.urlBaseEnv] = basePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom volume overrides
|
||||
if (config.customVolumes?.length && processed.docker?.volumes) {
|
||||
processed.docker.volumes = processed.docker.volumes.map(vol => {
|
||||
@@ -266,6 +278,105 @@ module.exports = function(ctx) {
|
||||
await ctx.caddy.verifySite(domain);
|
||||
}
|
||||
|
||||
// Reserved paths that cannot be used as subpath names in subdirectory mode
|
||||
const RESERVED_SUBPATHS = ['api', 'probe', 'assets', 'health', 'dist', 'js', 'css', 'fonts', 'favicon.ico'];
|
||||
|
||||
/** Ensure the main domain block with route markers exists in the Caddyfile.
|
||||
* Called before the first subdirectory app is deployed. */
|
||||
async function ensureMainDomainBlock() {
|
||||
if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return;
|
||||
|
||||
const content = await ctx.caddy.read();
|
||||
const domain = ctx.siteConfig.domain;
|
||||
const ROUTE_MARKER = '# === DashCaddy App Routes ===';
|
||||
|
||||
// Already has markers — nothing to do
|
||||
if (content.includes(ROUTE_MARKER)) return;
|
||||
|
||||
// Domain block exists but lacks markers — inject them
|
||||
if (content.includes(`${domain} {`)) {
|
||||
const result = await ctx.caddy.modify(c => {
|
||||
// Insert markers before the final catch-all handle block inside the domain block
|
||||
const domainStart = c.indexOf(`${domain} {`);
|
||||
// Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces
|
||||
const searchFrom = domainStart + domain.length;
|
||||
const handleMatch = c.slice(searchFrom).match(/^([ \t]+)handle\s*\{/m);
|
||||
if (!handleMatch) return null;
|
||||
const handleIdx = searchFrom + handleMatch.index;
|
||||
const indent = handleMatch[1];
|
||||
const markerBlock = `${indent}${ROUTE_MARKER}\n${indent}# === End App Routes ===\n\n`;
|
||||
return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx);
|
||||
});
|
||||
if (result.success) {
|
||||
ctx.log.info('caddy', 'Injected route markers into existing domain block', { domain });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No domain block at all — create one with dashboard + markers
|
||||
const dashboardRoot = platformPaths.sitePath('status');
|
||||
const apiPort = process.env.PORT || 3001;
|
||||
const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`;
|
||||
|
||||
const result = await ctx.caddy.modify(c => c + block);
|
||||
if (result.success) {
|
||||
ctx.log.info('caddy', 'Created main domain block with route markers', { domain });
|
||||
} else {
|
||||
throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Inject a subpath config block between route markers in the Caddyfile. */
|
||||
async function addSubpathConfig(subdomain, configBlock) {
|
||||
const marker = `# --- DashCaddy: ${subdomain} ---`;
|
||||
const endMarker = `# --- End: ${subdomain} ---`;
|
||||
const END_ROUTE_MARKER = '# === End App Routes ===';
|
||||
|
||||
const result = await ctx.caddy.modify(content => {
|
||||
if (content.includes(marker)) {
|
||||
ctx.log.info('caddy', 'Subpath config already exists, skipping', { subdomain });
|
||||
return null;
|
||||
}
|
||||
|
||||
const endIdx = content.indexOf(END_ROUTE_MARKER);
|
||||
if (endIdx === -1) {
|
||||
throw new Error(`Route marker "${END_ROUTE_MARKER}" not found in Caddyfile`);
|
||||
}
|
||||
|
||||
// Detect indentation from the end marker line
|
||||
const lineStart = content.lastIndexOf('\n', endIdx) + 1;
|
||||
const indent = content.slice(lineStart, endIdx).match(/^([ \t]*)/)?.[1] || '\t';
|
||||
|
||||
const injection = `${indent}${marker}\n${configBlock}\n${indent}${endMarker}\n`;
|
||||
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
||||
async function removeSubpathConfig(subdomain) {
|
||||
const marker = `# --- DashCaddy: ${subdomain} ---`;
|
||||
const endMarker = `# --- End: ${subdomain} ---`;
|
||||
|
||||
return await ctx.caddy.modify(content => {
|
||||
const startIdx = content.indexOf(marker);
|
||||
if (startIdx === -1) return null;
|
||||
|
||||
const endIdx = content.indexOf(endMarker);
|
||||
if (endIdx === -1) return null;
|
||||
|
||||
// Remove from the line start of the marker to the line end of the end marker
|
||||
const lineStart = content.lastIndexOf('\n', startIdx);
|
||||
const lineEnd = content.indexOf('\n', endIdx + endMarker.length);
|
||||
|
||||
const modified = content.slice(0, lineStart) + content.slice(lineEnd);
|
||||
return modified.replace(/\n{3,}/g, '\n\n');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
checkPortConflicts,
|
||||
findExistingContainerByImage,
|
||||
@@ -273,6 +384,10 @@ module.exports = function(ctx) {
|
||||
processTemplateVariables,
|
||||
waitForHealthCheck,
|
||||
addCaddyConfig,
|
||||
addSubpathConfig,
|
||||
removeSubpathConfig,
|
||||
ensureMainDomainBlock,
|
||||
RESERVED_SUBPATHS,
|
||||
generateStaticSiteConfig
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,18 +56,30 @@ module.exports = function(ctx, helpers) {
|
||||
|
||||
if (shouldDeleteContainer && subdomain) {
|
||||
try {
|
||||
const domain = ctx.buildDomain(subdomain);
|
||||
let content = await ctx.caddy.read();
|
||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
|
||||
const originalLength = content.length;
|
||||
content = content.replace(siteBlockRegex, '\n');
|
||||
if (content.length !== originalLength) {
|
||||
content = content.replace(/\n{3,}/g, '\n\n');
|
||||
const caddyResult = await ctx.caddy.modify(() => content);
|
||||
results.caddy = caddyResult.success ? 'removed' : 'removed (reload failed)';
|
||||
// Check if this service was deployed in subdirectory mode
|
||||
const services = await ctx.servicesStateManager.read();
|
||||
const serviceList = Array.isArray(services) ? services : [];
|
||||
const service = serviceList.find(s => s.id === subdomain);
|
||||
|
||||
if (service?.routingMode === 'subdirectory') {
|
||||
// Subdirectory mode: remove handle block from inside main domain block
|
||||
const subResult = await helpers.removeSubpathConfig(subdomain);
|
||||
results.caddy = subResult?.success ? 'removed' : 'not found';
|
||||
} else {
|
||||
results.caddy = 'not found';
|
||||
// Subdomain mode: remove standalone domain block
|
||||
const domain = ctx.buildDomain(subdomain);
|
||||
let content = await ctx.caddy.read();
|
||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
|
||||
const originalLength = content.length;
|
||||
content = content.replace(siteBlockRegex, '\n');
|
||||
if (content.length !== originalLength) {
|
||||
content = content.replace(/\n{3,}/g, '\n\n');
|
||||
const caddyResult = await ctx.caddy.modify(() => content);
|
||||
results.caddy = caddyResult.success ? 'removed' : 'removed (reload failed)';
|
||||
} else {
|
||||
results.caddy = 'not found';
|
||||
}
|
||||
}
|
||||
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user