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:
2026-03-06 03:03:17 -08:00
parent f61e85d9a7
commit 77030931b7
13 changed files with 407 additions and 41 deletions

View File

@@ -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);

View File

@@ -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
};
};

View File

@@ -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) {