From 77030931b7058f0a459ca8cb3a96bb694a908919 Mon Sep 17 00:00:00 2001 From: Sami Date: Fri, 6 Mar 2026 03:03:17 -0800 Subject: [PATCH] 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 --- dashcaddy-api/app-templates.js | 97 +++++++++++++++++++++- dashcaddy-api/config-schema.js | 20 ++++- dashcaddy-api/routes/apps/deploy.js | 37 +++++++-- dashcaddy-api/routes/apps/helpers.js | 115 +++++++++++++++++++++++++++ dashcaddy-api/routes/apps/removal.js | 34 +++++--- dashcaddy-api/server.js | 49 ++++++++++-- status/index.html | 28 ++++++- status/js/app-selector.js | 25 +++++- status/js/core/grid.js | 2 +- status/js/core/service-create.js | 4 +- status/js/core/service-crud.js | 2 +- status/js/globals.js | 12 ++- status/js/setup-wizard.js | 23 +++++- 13 files changed, 407 insertions(+), 41 deletions(-) diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/app-templates.js index 3e2cf5c..54f0561 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/app-templates.js @@ -29,6 +29,7 @@ const APP_TEMPLATES = { subdomain: "plex", defaultPort: 32400, healthCheck: "/web/index.html", + subpathSupport: 'none', mediaMount: { required: true, containerPath: "/data", @@ -75,6 +76,8 @@ const APP_TEMPLATES = { subdomain: "jellyfin", defaultPort: 8096, healthCheck: "/health", + subpathSupport: 'native', + urlBaseEnv: 'JELLYFIN_BaseUrl', mediaMount: { required: true, containerPath: "/media", @@ -113,6 +116,7 @@ const APP_TEMPLATES = { subdomain: "emby", defaultPort: 8096, healthCheck: "/emby/web/", + subpathSupport: 'none', mediaMount: { required: true, containerPath: "/media", @@ -152,6 +156,8 @@ const APP_TEMPLATES = { subdomain: "sonarr", defaultPort: 8989, healthCheck: "/api/v3/system/status", + subpathSupport: 'native', + urlBaseEnv: 'URL_BASE', setupInstructions: [ "Configure download clients (qBittorrent, etc.)", "Add indexers for content discovery", @@ -182,7 +188,9 @@ const APP_TEMPLATES = { }, subdomain: "radarr", defaultPort: 7878, - healthCheck: "/api/v3/system/status" + healthCheck: "/api/v3/system/status", + subpathSupport: 'native', + urlBaseEnv: 'URL_BASE' }, "prowlarr": { @@ -204,7 +212,9 @@ const APP_TEMPLATES = { }, subdomain: "prowlarr", defaultPort: 9696, - healthCheck: "/api/v1/system/status" + healthCheck: "/api/v1/system/status", + subpathSupport: 'native', + urlBaseEnv: 'URL_BASE' }, "qbittorrent": { @@ -231,6 +241,8 @@ const APP_TEMPLATES = { subdomain: "torrent", defaultPort: 8080, healthCheck: "/", + subpathSupport: 'native', + urlBaseEnv: 'WEBUI_BASE_PATH', setupInstructions: [ "Default login: admin/adminadmin", "Change default password immediately", @@ -262,6 +274,7 @@ const APP_TEMPLATES = { subdomain: "cloud", defaultPort: 8080, healthCheck: "/status.php", + subpathSupport: 'none', setupInstructions: [ "Change the default admin password", "Configure trusted domains", @@ -301,6 +314,7 @@ const APP_TEMPLATES = { subdomain: "code", defaultPort: 8443, healthCheck: "/healthz", + subpathSupport: 'strip', secrets: [ { envVar: "VSCODE_PASSWORD", @@ -332,7 +346,8 @@ const APP_TEMPLATES = { }, subdomain: "portainer", defaultPort: 9000, - healthCheck: "/api/status" + healthCheck: "/api/status", + subpathSupport: 'strip' }, "grafana": { @@ -353,6 +368,8 @@ const APP_TEMPLATES = { subdomain: "grafana", defaultPort: 3000, healthCheck: "/api/health", + subpathSupport: 'native', + urlBaseEnv: 'GF_SERVER_ROOT_URL', secrets: [ { envVar: "GRAFANA_ADMIN_PASSWORD", @@ -380,7 +397,8 @@ const APP_TEMPLATES = { }, subdomain: "uptime", defaultPort: 3002, - healthCheck: "/" + healthCheck: "/", + subpathSupport: 'strip' }, // === NETWORKING & SECURITY === @@ -406,6 +424,7 @@ const APP_TEMPLATES = { subdomain: "pihole", defaultPort: 80, healthCheck: "/admin/", + subpathSupport: 'strip', secrets: [ { envVar: "PIHOLE_WEB_PASSWORD", @@ -443,6 +462,7 @@ const APP_TEMPLATES = { subdomain: "vpn", defaultPort: 51820, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure your external IP/domain", "Set up port forwarding on router", @@ -477,6 +497,7 @@ const APP_TEMPLATES = { subdomain: "dns1", defaultPort: 5380, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Access web interface at https://dns1.sami", "Login with admin credentials", @@ -529,6 +550,7 @@ const APP_TEMPLATES = { subdomain: "dns2", defaultPort: 953, healthCheck: null, + subpathSupport: 'strip', setupInstructions: [ "Configure zone files in /opt/bind9/config/", "Create named.conf.local for your .sami zone", @@ -570,6 +592,7 @@ const APP_TEMPLATES = { subdomain: "dns3", defaultPort: 8081, healthCheck: "/api/v1/servers", + subpathSupport: 'strip', setupInstructions: [ "Access API at https://dns3.sami:8081", "Use API key for authentication", @@ -625,6 +648,7 @@ const APP_TEMPLATES = { subdomain: "dns4", defaultPort: 53, healthCheck: null, + subpathSupport: 'strip', setupInstructions: [ "Create Corefile in /opt/coredns/config/", "Define .sami zone with file plugin", @@ -656,6 +680,7 @@ const APP_TEMPLATES = { subdomain: "files", defaultPort: 8085, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Default login: admin/admin", "Change default password immediately", @@ -686,6 +711,7 @@ const APP_TEMPLATES = { subdomain: "sync", defaultPort: 8384, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Add devices using their Device IDs", "Configure shared folders", @@ -721,6 +747,7 @@ const APP_TEMPLATES = { subdomain: "mail", defaultPort: 25, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure DNS records (MX, SPF, DKIM, DMARC)", "Create email accounts using setup.sh", @@ -750,6 +777,7 @@ const APP_TEMPLATES = { subdomain: "webmail", defaultPort: 8086, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure IMAP/SMTP server settings", "Set up database connection", @@ -776,6 +804,7 @@ const APP_TEMPLATES = { subdomain: "matrix", defaultPort: 8008, healthCheck: "/_matrix/client/versions", + subpathSupport: 'none', setupInstructions: [ "Generate initial config with --generate", "Configure homeserver.yaml", @@ -802,6 +831,7 @@ const APP_TEMPLATES = { subdomain: "chat", defaultPort: 3004, healthCheck: "/api/info", + subpathSupport: 'strip', setupInstructions: [ "Requires MongoDB - deploy mongo container first", "Complete admin setup wizard", @@ -831,6 +861,7 @@ const APP_TEMPLATES = { subdomain: "home", defaultPort: 8123, healthCheck: "/api/", + subpathSupport: 'strip', setupInstructions: [ "Complete onboarding wizard", "Add integrations for your smart devices", @@ -856,6 +887,7 @@ const APP_TEMPLATES = { subdomain: "nodered", defaultPort: 1880, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Install additional nodes from palette", "Create flows for automation", @@ -884,6 +916,7 @@ const APP_TEMPLATES = { subdomain: "postgres", defaultPort: 5432, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Change default password immediately", "Create databases and users as needed", @@ -918,6 +951,7 @@ const APP_TEMPLATES = { subdomain: "redis", defaultPort: 6379, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure redis.conf for persistence", "Set up authentication if needed", @@ -944,6 +978,7 @@ const APP_TEMPLATES = { subdomain: "mongo", defaultPort: 27017, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Change default admin password", "Create application databases and users", @@ -980,6 +1015,7 @@ const APP_TEMPLATES = { subdomain: "adminer", defaultPort: 8087, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Connect to your database servers", "Supports MySQL, PostgreSQL, SQLite, etc." @@ -1006,6 +1042,7 @@ const APP_TEMPLATES = { subdomain: "vault", defaultPort: 8088, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Change admin token immediately", "Create your account", @@ -1036,6 +1073,7 @@ const APP_TEMPLATES = { subdomain: "ca", defaultPort: null, // Static site, no port needed healthCheck: null, + subpathSupport: 'strip', features: [ "Automatic OS detection", "One-click installation", @@ -1065,6 +1103,7 @@ const APP_TEMPLATES = { subdomain: null, defaultPort: null, healthCheck: null, + subpathSupport: 'strip', features: [ "Current temperature and conditions", "Wind speed and direction", @@ -1091,6 +1130,7 @@ const APP_TEMPLATES = { subdomain: null, defaultPort: null, healthCheck: null, + subpathSupport: 'strip', features: [ "12-hour format with AM/PM", "Live seconds display", @@ -1130,6 +1170,8 @@ const APP_TEMPLATES = { subdomain: "lidarr", defaultPort: 8686, healthCheck: "/api/v1/system/status", + subpathSupport: 'native', + urlBaseEnv: 'URL_BASE', setupInstructions: [ "Configure download clients", "Add indexers", @@ -1161,6 +1203,8 @@ const APP_TEMPLATES = { subdomain: "readarr", defaultPort: 8787, healthCheck: "/api/v1/system/status", + subpathSupport: 'native', + urlBaseEnv: 'URL_BASE', setupInstructions: [ "Configure download clients", "Add indexers for books", @@ -1192,6 +1236,8 @@ const APP_TEMPLATES = { subdomain: "bazarr", defaultPort: 6767, healthCheck: "/", + subpathSupport: 'native', + urlBaseEnv: 'BASE_URL', setupInstructions: [ "Connect to Sonarr and Radarr", "Configure subtitle providers", @@ -1218,6 +1264,8 @@ const APP_TEMPLATES = { subdomain: "requests", defaultPort: 5055, healthCheck: "/api/v1/status", + subpathSupport: 'native', + urlBaseEnv: 'BASE_PATH', setupInstructions: [ "Connect to Plex, Jellyfin, or Emby server", "Link Sonarr and Radarr", @@ -1245,6 +1293,8 @@ const APP_TEMPLATES = { subdomain: "tautulli", defaultPort: 8181, healthCheck: "/", + subpathSupport: 'native', + urlBaseEnv: 'TAUTULLI_HTTP_ROOT', setupInstructions: [ "Connect to Plex server", "Configure notifications", @@ -1276,6 +1326,8 @@ const APP_TEMPLATES = { subdomain: "git", defaultPort: 3005, healthCheck: "/", + subpathSupport: 'native', + urlBaseEnv: 'GITEA__server__ROOT_URL', setupInstructions: [ "Complete initial setup wizard", "Create admin account", @@ -1299,6 +1351,7 @@ const APP_TEMPLATES = { subdomain: "jenkins", defaultPort: 8089, healthCheck: "/login", + subpathSupport: 'strip', setupInstructions: [ "Get initial admin password from logs", "Install suggested plugins", @@ -1327,6 +1380,7 @@ const APP_TEMPLATES = { subdomain: "drone", defaultPort: 8090, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure Git provider integration", "Set up shared secret", @@ -1370,6 +1424,7 @@ const APP_TEMPLATES = { subdomain: "wiki", defaultPort: 8091, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Requires MariaDB/MySQL database", "Default login: admin@admin.com / password", @@ -1408,6 +1463,7 @@ const APP_TEMPLATES = { subdomain: "outline", defaultPort: 3006, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Requires PostgreSQL and Redis", "Configure OAuth provider", @@ -1453,6 +1509,7 @@ const APP_TEMPLATES = { subdomain: "notes", defaultPort: 3007, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure environment variables", "Set up database connection", @@ -1486,6 +1543,7 @@ const APP_TEMPLATES = { subdomain: "photos", defaultPort: 2283, healthCheck: "/api/server-info/ping", + subpathSupport: 'strip', setupInstructions: [ "Requires PostgreSQL and Redis", "Install mobile apps for backup", @@ -1527,6 +1585,7 @@ const APP_TEMPLATES = { subdomain: "gallery", defaultPort: 2342, healthCheck: "/api/v1/status", + subpathSupport: 'strip', setupInstructions: [ "Change admin password", "Import your photos", @@ -1569,6 +1628,8 @@ const APP_TEMPLATES = { subdomain: "sabnzbd", defaultPort: 8092, healthCheck: "/", + subpathSupport: 'native', + urlBaseEnv: 'SABNZBD_URL_BASE', setupInstructions: [ "Configure Usenet server credentials", "Set up download categories", @@ -1599,6 +1660,7 @@ const APP_TEMPLATES = { subdomain: "nzbget", defaultPort: 6789, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Default login: nzbget/tegbzn6789", "Configure news servers", @@ -1629,6 +1691,8 @@ const APP_TEMPLATES = { subdomain: "transmission", defaultPort: 9092, healthCheck: "/transmission/web/", + subpathSupport: 'native', + urlBaseEnv: 'TRANSMISSION_WEB_HOME', setupInstructions: [ "Configure download paths", "Set bandwidth limits", @@ -1655,6 +1719,7 @@ const APP_TEMPLATES = { subdomain: "jdownloader", defaultPort: 5800, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Access web interface to configure", "Link to MyJDownloader account", @@ -1685,6 +1750,7 @@ const APP_TEMPLATES = { subdomain: "music", defaultPort: 4533, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Point to your music library", "Create user accounts", @@ -1716,6 +1782,7 @@ const APP_TEMPLATES = { subdomain: "airsonic", defaultPort: 4040, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Default login: admin/admin", "Configure media folders", @@ -1743,6 +1810,7 @@ const APP_TEMPLATES = { subdomain: "dashboard", defaultPort: 3008, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Edit config files to add services", "Configure widgets", @@ -1770,6 +1838,7 @@ const APP_TEMPLATES = { subdomain: "homarr", defaultPort: 7575, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Add your services via UI", "Configure integrations", @@ -1793,6 +1862,7 @@ const APP_TEMPLATES = { subdomain: "watch", defaultPort: 5001, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Add URLs to monitor", "Configure check frequency", @@ -1820,6 +1890,7 @@ const APP_TEMPLATES = { subdomain: "speedtest", defaultPort: 8093, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Configure test schedule", "View historical data", @@ -1843,6 +1914,7 @@ const APP_TEMPLATES = { subdomain: "whoami", defaultPort: 8094, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Useful for testing reverse proxy setup", "Shows request headers and info" @@ -1873,6 +1945,7 @@ const APP_TEMPLATES = { subdomain: "pdf", defaultPort: 8084, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Access the web interface to start manipulating PDFs", "Supports merge, split, rotate, convert, compress, and more", @@ -1899,6 +1972,7 @@ const APP_TEMPLATES = { subdomain: "budget", defaultPort: 5006, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Create your first budget in the web interface", "Import transactions from your bank (OFX, QFX, CSV)", @@ -1930,6 +2004,7 @@ const APP_TEMPLATES = { subdomain: "mealie", defaultPort: 9925, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Default login: changeme@example.com / MyPassword", "Import recipes from URLs or add them manually", @@ -1965,6 +2040,7 @@ const APP_TEMPLATES = { subdomain: "paperless", defaultPort: 8095, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Create admin account via: docker exec -it python3 manage.py createsuperuser", "Drop documents into the consume folder for automatic import", @@ -1993,6 +2069,7 @@ const APP_TEMPLATES = { subdomain: "audiobooks", defaultPort: 13378, healthCheck: "/", + subpathSupport: 'strip', mediaMount: { required: true, containerPath: "/audiobooks", @@ -2031,6 +2108,7 @@ const APP_TEMPLATES = { subdomain: "books", defaultPort: 8083, healthCheck: "/", + subpathSupport: 'strip', mediaMount: { required: true, containerPath: "/books", @@ -2067,6 +2145,7 @@ const APP_TEMPLATES = { subdomain: "komga", defaultPort: 25600, healthCheck: "/", + subpathSupport: 'strip', mediaMount: { required: true, containerPath: "/data", @@ -2101,6 +2180,7 @@ const APP_TEMPLATES = { subdomain: "kavita", defaultPort: 5004, healthCheck: "/", + subpathSupport: 'strip', mediaMount: { required: true, containerPath: "/data", @@ -2134,6 +2214,7 @@ const APP_TEMPLATES = { subdomain: "notes", defaultPort: 8085, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Set your password on first access", "Organize notes in a tree hierarchy", @@ -2158,6 +2239,7 @@ const APP_TEMPLATES = { subdomain: "draw", defaultPort: 8086, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Start drawing immediately - no account needed", "Share drawings via link for real-time collaboration", @@ -2182,6 +2264,7 @@ const APP_TEMPLATES = { subdomain: "tools", defaultPort: 8087, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "Access the web interface for instant tools access", "Includes: hash generators, UUID, JWT decoder, base64, regex tester, and 70+ more", @@ -2208,6 +2291,7 @@ const APP_TEMPLATES = { subdomain: "logs", defaultPort: 8088, healthCheck: "/", + subpathSupport: 'strip', setupInstructions: [ "View real-time logs from all running containers", "Filter and search across container logs", @@ -2239,6 +2323,7 @@ const APP_TEMPLATES = { subdomain: "watchtower", defaultPort: 8089, healthCheck: "/v1/update", + subpathSupport: 'strip', setupInstructions: [ "Watchtower checks for image updates daily at 4 AM by default", "Customize schedule via WATCHTOWER_SCHEDULE (cron format)", @@ -2269,6 +2354,7 @@ const APP_TEMPLATES = { subdomain: "auth", defaultPort: 9010, healthCheck: "/-/health/live/", + subpathSupport: 'strip', setupInstructions: [ "Requires a PostgreSQL database and Redis instance", "Consider deploying via the Dev Environment recipe for full stack", @@ -2298,6 +2384,7 @@ const APP_TEMPLATES = { subdomain: "crowdsec", defaultPort: 8091, healthCheck: "/health", + subpathSupport: 'strip', setupInstructions: [ "Register at app.crowdsec.net for community threat intelligence", "Install bouncers on your reverse proxy for active blocking", @@ -2331,6 +2418,7 @@ const APP_TEMPLATES = { subdomain: "mc", defaultPort: 25565, healthCheck: null, + subpathSupport: 'none', setupInstructions: [ "Server accepts the Minecraft EULA automatically", "Connect with your Minecraft client to the server IP:port", @@ -2364,6 +2452,7 @@ const APP_TEMPLATES = { subdomain: "valheim", defaultPort: 2456, healthCheck: null, + subpathSupport: 'none', setupInstructions: [ "Connect via Steam: Add Server > IP:2456", "Default server password is auto-generated (check environment variables)", diff --git a/dashcaddy-api/config-schema.js b/dashcaddy-api/config-schema.js index 18ac201..9cf0948 100644 --- a/dashcaddy-api/config-schema.js +++ b/dashcaddy-api/config-schema.js @@ -93,13 +93,31 @@ function validateConfig(config) { } } + // Routing mode validation + if (config.routingMode !== undefined) { + const validModes = ['subdomain', 'subdirectory']; + if (!validModes.includes(config.routingMode)) { + errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`); + } + } + + // Domain validation + if (config.domain !== undefined) { + if (typeof config.domain !== 'string') { + errors.push('domain must be a string'); + } else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) { + warnings.push(`domain "${config.domain}" may not be a valid domain name`); + } + } + // Warn on unknown top-level keys const knownKeys = [ 'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme', 'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather', 'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted', 'configurationType', 'defaults', 'customLogo', 'customFavicon', - 'dashboardTitle', 'tailscale', 'license', 'skipped' + 'dashboardTitle', 'tailscale', 'license', 'skipped', + 'routingMode', 'domain', 'email', 'defaultIP' ]; for (const key of Object.keys(config)) { if (!knownKeys.includes(key)) { diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index 13fc899..01d6deb 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -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); diff --git a/dashcaddy-api/routes/apps/helpers.js b/dashcaddy-api/routes/apps/helpers.js index 8ba943f..b136f24 100644 --- a/dashcaddy-api/routes/apps/helpers.js +++ b/dashcaddy-api/routes/apps/helpers.js @@ -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 }; }; diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 1ebf4a3..54ee2ee 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -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) { diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index d100224..e6f1448 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -122,7 +122,7 @@ licenseManager.loadSecret(LICENSE_SECRET_FILE); // ===== Site configuration loaded from config.json (#5) ===== // These are read at startup and refreshed on config save. // All code should use these instead of hardcoded values. -let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '' }; +let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; function loadSiteConfig() { try { @@ -150,6 +150,7 @@ function loadSiteConfig() { siteConfig.dnsServers = raw.dnsServers || {}; siteConfig.configurationType = raw.configurationType || 'homelab'; siteConfig.domain = raw.domain || ''; + siteConfig.routingMode = raw.routingMode || 'subdomain'; } } catch (e) { // log.error may not be assigned yet during initial module load @@ -168,6 +169,16 @@ function buildDomain(subdomain) { return `${subdomain}${siteConfig.tld}`; } +/** Build full service URL (protocol + host + path) for a given subdomain. + * Subdirectory mode: https://example.com/sonarr + * Subdomain mode: https://sonarr.example.com */ +function buildServiceUrl(subdomain) { + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + return `https://${siteConfig.domain}/${subdomain}`; + } + return `https://${buildDomain(subdomain)}`; +} + /** Build full Technitium DNS API URL for any server (handles IP vs hostname, port) */ function buildDnsUrl(server, apiPath, params) { const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https'; @@ -1149,7 +1160,7 @@ Object.assign(ctx, { auditLogger, portLockManager, APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES, asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage, - buildDomain, getServiceById, readConfig, saveConfig, addServiceToConfig, + buildDomain, buildServiceUrl, getServiceById, readConfig, saveConfig, addServiceToConfig, validateURL, strictLimiter, totpConfig, saveTotpConfig, loadSiteConfig, loadNotificationConfig, @@ -1227,8 +1238,8 @@ app.get('/probe/:id', asyncHandler(async (req, res) => { } else if (service?.url) { url = service.url.startsWith('http') ? service.url : `https://${service.url}`; } else { - // Check via configured TLD through Caddy - url = `https://${buildDomain(id)}`; + // Build URL from configured routing mode (subdomain or subdirectory) + url = buildServiceUrl(id); } } @@ -1552,15 +1563,37 @@ async function getTokenForServer(targetServer, role = 'readonly') { // (Docker helper functions moved to routes/apps.js) function generateCaddyConfig(subdomain, ip, port, options = {}) { - const { tailscaleOnly = false, allowedIPs = [] } = options; + const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; + // Subdirectory mode: generate handle/handle_path block (injected into main domain block) + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + let config = ''; + + // Native-support apps: use handle (preserve path prefix) + // Strip-mode apps: use handle_path (remove path prefix before proxying) + if (subpathSupport === 'native') { + config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; + config += `\thandle /${subdomain}/* {\n`; + } else { + config += `\thandle_path /${subdomain}/* {\n`; + } + + if (tailscaleOnly) { + config += `\t\t@blocked not remote_ip 100.64.0.0/10`; + if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; + config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`; + } + + config += `\t\treverse_proxy ${ip}:${port}\n`; + config += `\t}`; + return config; + } + + // Subdomain mode (default): standalone domain block let config = `${buildDomain(subdomain)} {\n`; - // Add Tailscale IP restriction if enabled if (tailscaleOnly) { - // Tailscale CGNAT range: 100.64.0.0/10 config += ` @blocked not remote_ip 100.64.0.0/10`; - // Add any additional allowed IPs if (allowedIPs.length > 0) { config += ` ${allowedIPs.join(' ')}`; } diff --git a/status/index.html b/status/index.html index 9a7ab72..158ff27 100644 --- a/status/index.html +++ b/status/index.html @@ -451,13 +451,39 @@ +
+ +
+ + +
+
+

⚠️ Requirements:

  • Port 80 and 443 must be open to the internet
  • Domain DNS must point to this server's public IP
  • Server must be reachable from the internet
  • -
  • You'll need to configure DNS manually for each subdomain
  • +
  • Only one DNS record needed (for the main domain)
diff --git a/status/js/app-selector.js b/status/js/app-selector.js index b76a132..4b18b8f 100644 --- a/status/js/app-selector.js +++ b/status/js/app-selector.js @@ -25,6 +25,7 @@
Your app will be available at: uptime.home
+ @@ -407,6 +408,25 @@ const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, ''); subdomainInput.value = defaultSubdomain; + // Show subpath compatibility warning in subdirectory mode + const subpathWarning = document.getElementById('subpath-compat-warning'); + if (subpathWarning) { + if (SITE.routingMode === 'subdirectory') { + const support = appTemplate.subpathSupport || 'strip'; + if (support === 'none') { + subpathWarning.style.display = 'block'; + subpathWarning.innerHTML = '' + appTemplate.name + ' does not support subdirectory mode. It may not work correctly at a subpath.'; + } else if (support === 'strip') { + subpathWarning.style.display = 'block'; + subpathWarning.innerHTML = 'ⓘ ' + appTemplate.name + ' has unverified subdirectory support. It may require additional configuration.'; + } else { + subpathWarning.style.display = 'none'; + } + } else { + subpathWarning.style.display = 'none'; + } + } + // Pre-select DNS/SSL from site config (set during setup wizard) const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private'); const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal'); @@ -622,7 +642,10 @@ const sslType = document.querySelector('input[name="ssl-type"]:checked').value; let url = ''; - if (dnsType === 'private') { + if (SITE.routingMode === 'subdirectory' && SITE.domain) { + // Subdirectory mode: domain.com/app + url = `https://${SITE.domain}/${subdomain}`; + } else if (dnsType === 'private') { const protocol = sslType === 'none' ? 'http' : 'https'; url = `${protocol}://${buildDomain(subdomain)}`; } else if (dnsType === 'public') { diff --git a/status/js/core/grid.js b/status/js/core/grid.js index e8e76c1..478432f 100644 --- a/status/js/core/grid.js +++ b/status/js/core/grid.js @@ -112,7 +112,7 @@ } } - function serviceUrl(id) { return `https://${buildDomain(id)}`; } + function serviceUrl(id) { return buildServiceUrl(id); } function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; } function buildGrid() { diff --git a/status/js/core/service-create.js b/status/js/core/service-create.js index 450f9ee..86b4419 100644 --- a/status/js/core/service-create.js +++ b/status/js/core/service-create.js @@ -32,7 +32,7 @@ if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`; const urlPreview = document.getElementById('url-preview'); - if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`; + if (urlPreview) urlPreview.textContent = buildServiceUrl(subdomain); const config = { subdomain, port, ip, sslType, caName, existingCa, @@ -534,7 +534,7 @@ `Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`, `Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}` ]; - showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 https://${buildDomain(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000); + showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 ${buildServiceUrl(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000); closeAddServiceModal(); diff --git a/status/js/core/service-crud.js b/status/js/core/service-crud.js index b8aa446..13c7d3b 100644 --- a/status/js/core/service-crud.js +++ b/status/js/core/service-crud.js @@ -11,7 +11,7 @@ document.getElementById('service-edit-title').textContent = `Edit ${service.name}`; document.getElementById('edit-service-name-display').textContent = service.name; - document.getElementById('edit-service-url-display').textContent = `https://${buildDomain(service.id)}`; + document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id); document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`; document.getElementById('edit-subdomain').value = service.id; document.getElementById('edit-port').value = service.port || ''; diff --git a/status/js/globals.js b/status/js/globals.js index d82bc45..e155370 100644 --- a/status/js/globals.js +++ b/status/js/globals.js @@ -33,7 +33,8 @@ const SITE = { dnsServers: (_cachedCfg && _cachedCfg.dnsServers) || {}, configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab', domain: (_cachedCfg && _cachedCfg.domain) || '', - defaults: (_cachedCfg && _cachedCfg.defaults) || {} + defaults: (_cachedCfg && _cachedCfg.defaults) || {}, + routingMode: (_cachedCfg && _cachedCfg.routingMode) || 'subdomain' }; (async function loadSiteConfig() { try { @@ -51,10 +52,12 @@ const SITE = { if (c.configurationType) SITE.configurationType = c.configurationType; if (c.domain) SITE.domain = c.domain; if (c.defaults) SITE.defaults = c.defaults; + if (c.routingMode) SITE.routingMode = c.routingMode; // Cache config so next page load uses correct TLD even if API is slow localStorage.setItem('dashcaddy_site_config', JSON.stringify({ tld: SITE.tld, dnsIp: SITE.dnsIp, dnsPort: SITE.dnsPort, dnsServers: SITE.dnsServers, - configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults + configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults, + routingMode: SITE.routingMode })); // Render DNS cards dynamically based on configured servers renderDnsCards(); @@ -68,6 +71,11 @@ const SITE = { if (proxyIpInput && SITE.dnsIp) { proxyIpInput.value = SITE.dnsIp; proxyIpInput.placeholder = SITE.dnsIp; } })(); function buildDomain(sub) { return sub + SITE.tld; } +function buildServiceUrl(sub) { + if (SITE.routingMode === 'subdirectory' && SITE.domain) return 'https://' + SITE.domain + '/' + sub; + if (SITE.configurationType === 'public' && SITE.domain) return 'https://' + sub + '.' + SITE.domain; + return 'https://' + buildDomain(sub); +} function getDnsServerAddr(dnsId) { const s = SITE.dnsServers[dnsId]; return s ? `${s.ip}:${s.port}` : buildDomain(dnsId); diff --git a/status/js/setup-wizard.js b/status/js/setup-wizard.js index 21175f2..15a396d 100644 --- a/status/js/setup-wizard.js +++ b/status/js/setup-wizard.js @@ -104,6 +104,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) { } else if (currentConfigType === 'public') { const domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; const email = document.getElementById('setup-public-email')?.value?.trim() || ''; + const routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory'; + const exampleUrls = routingMode === 'subdirectory' + ? `https://${domain}/sonarr, https://${domain}/grafana` + : `https://sonarr.${domain}, https://grafana.${domain}`; + const routingLabel = routingMode === 'subdirectory' ? 'Subdirectory (domain.com/app)' : 'Subdomain (app.domain.com)'; html += `
@@ -112,7 +117,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
Domain: ${domain}
SSL: Let's Encrypt
Email: ${email}
-
Example URLs: https://app.${domain}, https://cloud.${domain}
+
Routing: ${routingLabel}
+
Example URLs: ${exampleUrls}
`; @@ -186,8 +192,9 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) { } else if (currentConfigType === 'public') { config.domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; config.email = document.getElementById('setup-public-email')?.value?.trim() || ''; + config.routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory'; config.defaults = { - dnsType: 'public', + dnsType: config.routingMode === 'subdirectory' ? 'none' : 'public', sslType: 'letsencrypt', targetIP: 'localhost' }; @@ -311,6 +318,18 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) { }; } + // Public routing mode toggle — update requirement text + document.querySelectorAll('input[name="routing-mode"]').forEach(function(radio) { + radio.onchange = function() { + var note = document.getElementById('dns-requirement-note'); + if (note) { + note.textContent = this.value === 'subdirectory' + ? 'Only one DNS record needed (for the main domain)' + : 'You\'ll need to configure DNS manually for each subdomain'; + } + }; + }); + // Public navigation const publicBack = document.getElementById('setup-public-back'); if (publicBack) {