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:
@@ -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 <container> 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)",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(' ')}`;
|
||||
}
|
||||
|
||||
@@ -451,13 +451,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label-accent">
|
||||
🔀 App Routing Mode
|
||||
</label>
|
||||
<div style="display: grid; gap: 10px; margin-top: 8px;">
|
||||
<label style="display: flex; align-items: start; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
||||
<input type="radio" name="routing-mode" value="subdirectory" checked style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong>Subdirectory</strong> <span style="color: var(--muted);">(example.com/app)</span>
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 2px;">
|
||||
Only needs a single DNS record. Recommended for most setups.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label style="display: flex; align-items: start; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
||||
<input type="radio" name="routing-mode" value="subdomain" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong>Subdomain</strong> <span style="color: var(--muted);">(app.example.com)</span>
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 2px;">
|
||||
Requires wildcard DNS (*.example.com) or per-app DNS records
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 8px; border: 1px solid var(--accent);">
|
||||
<h4 class="heading-accent-md">⚠️ Requirements:</h4>
|
||||
<ul style="margin: 0; padding-left: 20px; font-size: 0.9rem; line-height: 1.6;">
|
||||
<li>Port 80 and 443 must be open to the internet</li>
|
||||
<li>Domain DNS must point to this server's public IP</li>
|
||||
<li>Server must be reachable from the internet</li>
|
||||
<li>You'll need to configure DNS manually for each subdomain</li>
|
||||
<li id="dns-requirement-note">Only one DNS record needed (for the main domain)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="form-hint-sm">
|
||||
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
|
||||
</div>
|
||||
<div id="subpath-compat-warning" style="display: none; padding: 8px 12px; border-radius: 6px; background: color-mix(in srgb, #ff9800 10%, var(--card-bg)); border: 1px solid color-mix(in srgb, #ff9800 30%, transparent); margin-top: 8px; font-size: 0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- DNS Configuration -->
|
||||
@@ -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 = '<span style="color: #ff9800;">⚠ <strong>' + appTemplate.name + '</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>';
|
||||
} else if (support === 'strip') {
|
||||
subpathWarning.style.display = 'block';
|
||||
subpathWarning.innerHTML = '<span style="color: var(--muted);">ⓘ ' + appTemplate.name + ' has unverified subdirectory support. It may require additional configuration.</span>';
|
||||
} 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') {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 += `
|
||||
<div>
|
||||
@@ -112,7 +117,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
<div><strong>Domain:</strong> ${domain}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${email}</div>
|
||||
<div><strong>Example URLs:</strong> https://app.${domain}, https://cloud.${domain}</div>
|
||||
<div><strong>Routing:</strong> ${routingLabel}</div>
|
||||
<div><strong>Example URLs:</strong> ${exampleUrls}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user