diff --git a/backend/internal/api/handlers/health_handler.go b/backend/internal/api/handlers/health_handler.go index 2da47944..413ecb02 100644 --- a/backend/internal/api/handlers/health_handler.go +++ b/backend/internal/api/handlers/health_handler.go @@ -1,19 +1,38 @@ package handlers import ( + "net" "net/http" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version" "github.com/gin-gonic/gin" ) +// getLocalIP returns the non-loopback local IP of the host +func getLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, address := range addrs { + // check the address type and if it is not a loopback then return it + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "" +} + // HealthHandler responds with basic service metadata for uptime checks. func HealthHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "service": version.Name, - "version": version.Version, - "git_commit": version.GitCommit, - "build_time": version.BuildTime, + "status": "ok", + "service": version.Name, + "version": version.Version, + "git_commit": version.GitCommit, + "build_time": version.BuildTime, + "internal_ip": getLocalIP(), }) } diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 192b2334..83587992 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -223,7 +223,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin }, }, Handle: []Handler{ - ReverseProxyHandler(dial, host.WebsocketSupport), + ReverseProxyHandler(dial, host.WebsocketSupport, host.Application), }, Terminal: true, } @@ -232,7 +232,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Main proxy handler dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort) - mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport)) + mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application)) route := &Route{ Match: []Match{ diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index 98ea7375..7034f1cf 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -98,7 +98,8 @@ type Match struct { type Handler map[string]interface{} // ReverseProxyHandler creates a reverse_proxy handler. -func ReverseProxyHandler(dial string, enableWS bool) Handler { +// application: "none", "plex", "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden" +func ReverseProxyHandler(dial string, enableWS bool, application string) Handler { h := Handler{ "handler": "reverse_proxy", "flush_interval": -1, // Disable buffering for better streaming performance (Plex, etc.) @@ -107,16 +108,33 @@ func ReverseProxyHandler(dial string, enableWS bool) Handler { }, } + // Build headers configuration + headers := make(map[string]interface{}) + requestHeaders := make(map[string]interface{}) + setHeaders := make(map[string][]string) + + // WebSocket support if enableWS { - // Enable WebSocket support by preserving upgrade headers - h["headers"] = map[string]interface{}{ - "request": map[string]interface{}{ - "set": map[string][]string{ - "Upgrade": {"{http.request.header.Upgrade}"}, - "Connection": {"{http.request.header.Connection}"}, - }, - }, - } + setHeaders["Upgrade"] = []string{"{http.request.header.Upgrade}"} + setHeaders["Connection"] = []string{"{http.request.header.Connection}"} + } + + // Application-specific headers for proper client IP forwarding + // These are critical for media servers behind tunnels/CGNAT + switch application { + case "plex", "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden": + // X-Real-IP is required by most apps to identify the real client + // Caddy already sets X-Forwarded-For and X-Forwarded-Proto by default + setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"} + // Some apps also check these headers + setHeaders["X-Forwarded-Host"] = []string{"{http.request.host}"} + } + + // Only add headers config if we have headers to set + if len(setHeaders) > 0 { + requestHeaders["set"] = setHeaders + headers["request"] = requestHeaders + h["headers"] = headers } return h diff --git a/backend/internal/caddy/types_test.go b/backend/internal/caddy/types_test.go index 0a5f4ad5..d4808d52 100644 --- a/backend/internal/caddy/types_test.go +++ b/backend/internal/caddy/types_test.go @@ -18,7 +18,7 @@ func TestHandlers(t *testing.T) { assert.Equal(t, "/var/www/html", h["root"]) // Test ReverseProxyHandler - h = ReverseProxyHandler("localhost:8080", true) + h = ReverseProxyHandler("localhost:8080", true, "plex") assert.Equal(t, "reverse_proxy", h["handler"]) // Test HeaderHandler diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index bbeae9d6..0e3a8e85 100644 --- a/backend/internal/caddy/validator_test.go +++ b/backend/internal/caddy/validator_test.go @@ -41,13 +41,13 @@ func TestValidate_DuplicateHosts(t *testing.T) { { Match: []Match{{Host: []string{"test.com"}}}, Handle: []Handler{ - ReverseProxyHandler("app:8080", false), + ReverseProxyHandler("app:8080", false, "none"), }, }, { Match: []Match{{Host: []string{"test.com"}}}, Handle: []Handler{ - ReverseProxyHandler("app2:8080", false), + ReverseProxyHandler("app2:8080", false, "none"), }, }, }, diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index 2f1dbdb6..d1bc29d6 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -19,6 +19,7 @@ type ProxyHost struct { HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"` BlockExploits bool `json:"block_exploits" gorm:"default:true"` WebsocketSupport bool `json:"websocket_support" gorm:"default:false"` + Application string `json:"application" gorm:"default:none"` // none, plex, jellyfin, emby, homeassistant, nextcloud, vaultwarden Enabled bool `json:"enabled" gorm:"default:true"` CertificateID *uint `json:"certificate_id"` Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"` diff --git a/docs/issues/plex-remote-access-helper.md b/docs/issues/plex-remote-access-helper.md index 31bfb4c8..e1a073c9 100644 --- a/docs/issues/plex-remote-access-helper.md +++ b/docs/issues/plex-remote-access-helper.md @@ -66,7 +66,7 @@ plex.example.com { header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} - + # Preserve Plex-specific headers header_up X-Plex-Client-Identifier {header.X-Plex-Client-Identifier} header_up X-Plex-Device {header.X-Plex-Device} @@ -76,7 +76,7 @@ plex.example.com { header_up X-Plex-Product {header.X-Plex-Product} header_up X-Plex-Token {header.X-Plex-Token} header_up X-Plex-Version {header.X-Plex-Version} - + # WebSocket support for Plex Companion transport http { read_buffer 8192 @@ -106,14 +106,14 @@ Users must configure in Plex Settings → Network: Add a collapsible "Media Server Settings" section: ``` ☑ Enable Plex Mode - + External Domain for Plex: [ plex.example.com ] - + [📋 Copy Plex Custom URL] → https://plex.example.com:443 - + â„šī¸ Add this URL to Plex Settings → Network → Custom server access URLs - + ☑ Forward client IP headers (recommended) ☑ Enable WebSocket support ``` diff --git a/frontend/src/api/__tests__/proxyHosts.test.ts b/frontend/src/api/__tests__/proxyHosts.test.ts index 36e050dd..026d03af 100644 --- a/frontend/src/api/__tests__/proxyHosts.test.ts +++ b/frontend/src/api/__tests__/proxyHosts.test.ts @@ -37,6 +37,7 @@ describe('proxyHosts API', () => { hsts_subdomains: false, block_exploits: false, websocket_support: false, + application: 'none', locations: [], enabled: true, created_at: '2023-01-01', diff --git a/frontend/src/api/proxyHosts.ts b/frontend/src/api/proxyHosts.ts index 4ab9ebea..5b00bcb5 100644 --- a/frontend/src/api/proxyHosts.ts +++ b/frontend/src/api/proxyHosts.ts @@ -17,6 +17,8 @@ export interface Certificate { expires_at: string; } +export type ApplicationPreset = 'none' | 'plex' | 'jellyfin' | 'emby' | 'homeassistant' | 'nextcloud' | 'vaultwarden'; + export interface ProxyHost { uuid: string; name: string; @@ -30,6 +32,7 @@ export interface ProxyHost { hsts_subdomains: boolean; block_exploits: boolean; websocket_support: boolean; + application: ApplicationPreset; locations: Location[]; advanced_config?: string; enabled: boolean; diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 2c195adb..d40945a1 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import { CircleHelp, AlertCircle, Check, X, Loader2 } from 'lucide-react' -import type { ProxyHost } from '../api/proxyHosts' +import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info } from 'lucide-react' +import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts' import { testProxyHostConnection } from '../api/proxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' import { useDomains } from '../hooks/useDomains' @@ -8,6 +8,32 @@ import { useCertificates } from '../hooks/useCertificates' import { useDocker } from '../hooks/useDocker' import { parse } from 'tldts' +// Application preset configurations +const APPLICATION_PRESETS: { value: ApplicationPreset; label: string; description: string }[] = [ + { value: 'none', label: 'None', description: 'Standard reverse proxy' }, + { value: 'plex', label: 'Plex', description: 'Media server with remote access' }, + { value: 'jellyfin', label: 'Jellyfin', description: 'Open source media server' }, + { value: 'emby', label: 'Emby', description: 'Media server' }, + { value: 'homeassistant', label: 'Home Assistant', description: 'Home automation' }, + { value: 'nextcloud', label: 'Nextcloud', description: 'File sync and share' }, + { value: 'vaultwarden', label: 'Vaultwarden', description: 'Password manager' }, +] + +// Docker image to preset mapping for auto-detection +const IMAGE_TO_PRESET: Record = { + 'plexinc/pms-docker': 'plex', + 'linuxserver/plex': 'plex', + 'jellyfin/jellyfin': 'jellyfin', + 'linuxserver/jellyfin': 'jellyfin', + 'emby/embyserver': 'emby', + 'linuxserver/emby': 'emby', + 'homeassistant/home-assistant': 'homeassistant', + 'ghcr.io/home-assistant/home-assistant': 'homeassistant', + 'nextcloud': 'nextcloud', + 'linuxserver/nextcloud': 'nextcloud', + 'vaultwarden/server': 'vaultwarden', +} + interface ProxyHostFormProps { host?: ProxyHost onSubmit: (data: Partial) => Promise @@ -27,11 +53,57 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor hsts_subdomains: host?.hsts_subdomains ?? true, block_exploits: host?.block_exploits ?? true, websocket_support: host?.websocket_support ?? true, + application: (host?.application || 'none') as ApplicationPreset, advanced_config: host?.advanced_config || '', enabled: host?.enabled ?? true, certificate_id: host?.certificate_id, }) + // CPMP internal IP for config helpers + const [cpmpInternalIP, setCpmpInternalIP] = useState('') + const [copiedField, setCopiedField] = useState(null) + + // Fetch CPMP internal IP on mount + useEffect(() => { + fetch('/api/v1/health') + .then(res => res.json()) + .then(data => { + if (data.internal_ip) { + setCpmpInternalIP(data.internal_ip) + } + }) + .catch(() => {}) + }, []) + + // Auto-detect application preset from Docker image + const detectApplicationPreset = (imageName: string): ApplicationPreset => { + const lowerImage = imageName.toLowerCase() + for (const [pattern, preset] of Object.entries(IMAGE_TO_PRESET)) { + if (lowerImage.includes(pattern.toLowerCase())) { + return preset + } + } + return 'none' + } + + // Copy to clipboard helper + const copyToClipboard = async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + } catch { + console.error('Failed to copy to clipboard') + } + } + + // Get the external URL for this proxy host + const getExternalUrl = () => { + const domain = formData.domain_names.split(',')[0]?.trim() + if (!domain) return '' + return `https://${domain}:443` + } + const { servers: remoteServers } = useRemoteServers() const { domains, createDomain } = useDomains() const { certificates } = useCertificates() @@ -184,12 +256,19 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor newDomainNames = `${subdomain}.${selectedDomain}` } + // Auto-detect application preset from image name + const detectedPreset = detectApplicationPreset(container.image) + // Auto-enable websockets for apps that need it + const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(detectedPreset) + setFormData({ ...formData, forward_host: host, forward_port: port, forward_scheme: 'http', domain_names: newDomainNames, + application: detectedPreset, + websocket_support: needsWebsockets || formData.websocket_support, }) } } @@ -411,6 +490,156 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor

+ {/* Application Preset */} +
+ + +

+ Presets automatically configure headers for remote access behind tunnels/CGNAT. +

+
+ + {/* Application Config Helper */} + {formData.application !== 'none' && formData.domain_names && ( +
+
+ +
+

+ {formData.application === 'plex' && 'Plex Remote Access Setup'} + {formData.application === 'jellyfin' && 'Jellyfin Proxy Setup'} + {formData.application === 'emby' && 'Emby Proxy Setup'} + {formData.application === 'homeassistant' && 'Home Assistant Proxy Setup'} + {formData.application === 'nextcloud' && 'Nextcloud Proxy Setup'} + {formData.application === 'vaultwarden' && 'Vaultwarden Setup'} +

+ + {/* Plex Helper */} + {formData.application === 'plex' && ( + <> +

+ Copy this URL and paste it into Plex Settings → Network → Custom server access URLs +

+
+ + {getExternalUrl()} + + +
+ + )} + + {/* Jellyfin/Emby Helper */} + {(formData.application === 'jellyfin' || formData.application === 'emby') && cpmpInternalIP && ( + <> +

+ Add this IP to {formData.application === 'jellyfin' ? 'Jellyfin' : 'Emby'} → Dashboard → Networking → Known Proxies +

+
+ + {cpmpInternalIP} + + +
+ + )} + + {/* Home Assistant Helper */} + {formData.application === 'homeassistant' && cpmpInternalIP && ( + <> +

+ Add this to your configuration.yaml under http: +

+
+
+{`http:
+  use_x_forwarded_for: true
+  trusted_proxies:
+    - ${cpmpInternalIP}`}
+                        
+ +
+ + )} + + {/* Nextcloud Helper */} + {formData.application === 'nextcloud' && cpmpInternalIP && ( + <> +

+ Add this to your config/config.php +

+
+
+{`'trusted_proxies' => ['${cpmpInternalIP}'],
+'overwriteprotocol' => 'https',`}
+                        
+ +
+ + )} + + {/* Vaultwarden Helper */} + {formData.application === 'vaultwarden' && ( +

+ WebSocket support is enabled automatically for live sync. Ensure your Bitwarden clients use this domain: {formData.domain_names.split(',')[0]?.trim()} +

+ )} +
+
+
+ )} + {/* SSL & Security Options */}