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:
@@ -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(' ')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user