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

@@ -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(' ')}`;
}