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 */}
+
+
+ Application Preset
+ (Optional)
+
+
{
+ const preset = e.target.value as ApplicationPreset
+ // Auto-enable websockets for apps that need it
+ const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(preset)
+ setFormData({
+ ...formData,
+ application: preset,
+ websocket_support: needsWebsockets || formData.websocket_support,
+ })
+ }}
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {APPLICATION_PRESETS.map(preset => (
+
+ {preset.label} - {preset.description}
+
+ ))}
+
+
+ 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()}
+
+ copyToClipboard(getExternalUrl(), 'plex-url')}
+ className="px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-1"
+ >
+ {copiedField === 'plex-url' ? : }
+ {copiedField === 'plex-url' ? 'Copied!' : 'Copy'}
+
+
+ >
+ )}
+
+ {/* Jellyfin/Emby Helper */}
+ {(formData.application === 'jellyfin' || formData.application === 'emby') && cpmpInternalIP && (
+ <>
+
+ Add this IP to {formData.application === 'jellyfin' ? 'Jellyfin' : 'Emby'} â Dashboard â Networking â Known Proxies
+
+
+
+ {cpmpInternalIP}
+
+ copyToClipboard(cpmpInternalIP, 'proxy-ip')}
+ className="px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm flex items-center gap-1"
+ >
+ {copiedField === 'proxy-ip' ? : }
+ {copiedField === 'proxy-ip' ? 'Copied!' : 'Copy'}
+
+
+ >
+ )}
+
+ {/* Home Assistant Helper */}
+ {formData.application === 'homeassistant' && cpmpInternalIP && (
+ <>
+
+ Add this to your configuration.yaml under http:
+
+
+
+{`http:
+ use_x_forwarded_for: true
+ trusted_proxies:
+ - ${cpmpInternalIP}`}
+
+
copyToClipboard(`http:\n use_x_forwarded_for: true\n trusted_proxies:\n - ${cpmpInternalIP}`, 'ha-yaml')}
+ className="absolute top-2 right-2 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs flex items-center gap-1"
+ >
+ {copiedField === 'ha-yaml' ? : }
+ {copiedField === 'ha-yaml' ? 'Copied!' : 'Copy'}
+
+
+ >
+ )}
+
+ {/* Nextcloud Helper */}
+ {formData.application === 'nextcloud' && cpmpInternalIP && (
+ <>
+
+ Add this to your config/config.php
+
+
+
+{`'trusted_proxies' => ['${cpmpInternalIP}'],
+'overwriteprotocol' => 'https',`}
+
+
copyToClipboard(`'trusted_proxies' => ['${cpmpInternalIP}'],\n'overwriteprotocol' => 'https',`, 'nc-php')}
+ className="absolute top-2 right-2 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs flex items-center gap-1"
+ >
+ {copiedField === 'nc-php' ? : }
+ {copiedField === 'nc-php' ? 'Copied!' : 'Copy'}
+
+
+ >
+ )}
+
+ {/* 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 */}
diff --git a/frontend/src/components/__tests__/ProxyHostForm.test.tsx b/frontend/src/components/__tests__/ProxyHostForm.test.tsx
index d012fa61..b1b418f1 100644
--- a/frontend/src/components/__tests__/ProxyHostForm.test.tsx
+++ b/frontend/src/components/__tests__/ProxyHostForm.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, afterEach } from 'vitest'
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProxyHostForm from '../ProxyHostForm'
@@ -71,6 +71,10 @@ vi.mock('../../api/proxyHosts', () => ({
testProxyHostConnection: vi.fn(),
}))
+// Mock global fetch for health API
+const mockFetch = vi.fn()
+vi.stubGlobal('fetch', mockFetch)
+
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -93,6 +97,13 @@ describe('ProxyHostForm', () => {
const mockOnSubmit = vi.fn((_data: any) => Promise.resolve())
const mockOnCancel = vi.fn()
+ beforeEach(() => {
+ // Default fetch mock for health endpoint
+ mockFetch.mockResolvedValue({
+ json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
+ })
+ })
+
afterEach(() => {
vi.clearAllMocks()
})
@@ -234,4 +245,305 @@ describe('ProxyHostForm', () => {
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
})
+
+ // Application Preset Tests
+ describe('Application Presets', () => {
+ it('renders application preset dropdown with all options', async () => {
+ renderWithClient(
+
+ )
+
+ const presetSelect = screen.getByLabelText(/Application Preset/i)
+ expect(presetSelect).toBeInTheDocument()
+
+ // Check that all presets are available
+ expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument()
+ expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument()
+ expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument()
+ expect(screen.getByText('Emby - Media server')).toBeInTheDocument()
+ expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument()
+ expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument()
+ expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument()
+ })
+
+ it('defaults to none preset', async () => {
+ renderWithClient(
+
+ )
+
+ const presetSelect = screen.getByLabelText(/Application Preset/i)
+ expect(presetSelect).toHaveValue('none')
+ })
+
+ it('enables websockets when selecting plex preset', async () => {
+ renderWithClient(
+
+ )
+
+ // First uncheck websockets
+ const websocketCheckbox = screen.getByLabelText(/Websockets Support/i)
+ if (websocketCheckbox.getAttribute('checked') !== null) {
+ fireEvent.click(websocketCheckbox)
+ }
+
+ // Select Plex preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
+
+ // Websockets should be enabled
+ expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked()
+ })
+
+ it('shows plex config helper with external URL when preset is selected', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'plex.mydomain.com' }
+ })
+
+ // Select Plex preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
+
+ // Should show the helper with external URL
+ await waitFor(() => {
+ expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
+ expect(screen.getByText('https://plex.mydomain.com:443')).toBeInTheDocument()
+ })
+ })
+
+ it('shows jellyfin config helper with internal IP', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'jellyfin.mydomain.com' }
+ })
+
+ // Select Jellyfin preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'jellyfin' } })
+
+ // Wait for health API fetch and show helper
+ await waitFor(() => {
+ expect(screen.getByText('Jellyfin Proxy Setup')).toBeInTheDocument()
+ expect(screen.getByText('192.168.1.50')).toBeInTheDocument()
+ })
+ })
+
+ it('shows home assistant config helper with yaml snippet', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'ha.mydomain.com' }
+ })
+
+ // Select Home Assistant preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'homeassistant' } })
+
+ // Wait for health API fetch and show helper
+ await waitFor(() => {
+ expect(screen.getByText('Home Assistant Proxy Setup')).toBeInTheDocument()
+ expect(screen.getByText(/use_x_forwarded_for/)).toBeInTheDocument()
+ expect(screen.getByText(/192\.168\.1\.50/)).toBeInTheDocument()
+ })
+ })
+
+ it('shows nextcloud config helper with php snippet', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'nextcloud.mydomain.com' }
+ })
+
+ // Select Nextcloud preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'nextcloud' } })
+
+ // Wait for health API fetch and show helper
+ await waitFor(() => {
+ expect(screen.getByText('Nextcloud Proxy Setup')).toBeInTheDocument()
+ expect(screen.getByText(/trusted_proxies/)).toBeInTheDocument()
+ expect(screen.getByText(/overwriteprotocol/)).toBeInTheDocument()
+ })
+ })
+
+ it('shows vaultwarden helper text', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'vault.mydomain.com' }
+ })
+
+ // Select Vaultwarden preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'vaultwarden' } })
+
+ // Wait for helper text
+ await waitFor(() => {
+ expect(screen.getByText('Vaultwarden Setup')).toBeInTheDocument()
+ expect(screen.getByText(/WebSocket support is enabled automatically/)).toBeInTheDocument()
+ expect(screen.getByText('vault.mydomain.com')).toBeInTheDocument()
+ })
+ })
+
+ it('auto-detects plex preset from container image', async () => {
+ // Mock useDocker to return a Plex container
+ const { useDocker } = await import('../../hooks/useDocker')
+ vi.mocked(useDocker).mockReturnValue({
+ containers: [
+ {
+ id: 'plex-container',
+ names: ['plex'],
+ image: 'linuxserver/plex:latest',
+ state: 'running',
+ status: 'Up 1 hour',
+ network: 'bridge',
+ ip: '172.17.0.3',
+ ports: [{ private_port: 32400, public_port: 32400, type: 'tcp' }]
+ }
+ ],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ })
+
+ renderWithClient(
+
+ )
+
+ // Select local source
+ fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'local' } })
+
+ // Select the plex container
+ await waitFor(() => {
+ expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument()
+ })
+
+ fireEvent.change(screen.getByLabelText('Containers'), { target: { value: 'plex-container' } })
+
+ // The preset should be auto-detected as plex
+ expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
+ })
+
+ it('includes application field in form submission', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill required fields
+ fireEvent.change(screen.getByPlaceholderText('My Service'), { target: { value: 'My Plex Server' } })
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), { target: { value: 'plex.test.com' } })
+ fireEvent.change(screen.getByLabelText(/^Host$/), { target: { value: '192.168.1.100' } })
+ fireEvent.change(screen.getByLabelText(/^Port$/), { target: { value: '32400' } })
+
+ // Select Plex preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
+
+ // Submit form
+ fireEvent.click(screen.getByText('Save'))
+
+ await waitFor(() => {
+ expect(mockOnSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ application: 'plex',
+ websocket_support: true,
+ })
+ )
+ })
+ })
+
+ it('loads existing host application preset', async () => {
+ const existingHost = {
+ uuid: 'test-uuid',
+ name: 'Existing Plex',
+ domain_names: 'plex.example.com',
+ forward_scheme: 'http',
+ forward_host: '192.168.1.100',
+ forward_port: 32400,
+ ssl_forced: true,
+ http2_support: true,
+ hsts_enabled: true,
+ hsts_subdomains: false,
+ block_exploits: true,
+ websocket_support: true,
+ application: 'plex' as const,
+ locations: [],
+ enabled: true,
+ created_at: '2025-01-01',
+ updated_at: '2025-01-01',
+ }
+
+ renderWithClient(
+
+ )
+
+ // The preset should be pre-selected
+ expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
+
+ // The config helper should be visible
+ await waitFor(() => {
+ expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
+ })
+ })
+
+ it('does not show config helper when preset is none', async () => {
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'test.mydomain.com' }
+ })
+
+ // Preset defaults to none, so no helper should be shown
+ expect(screen.queryByText('Plex Remote Access Setup')).not.toBeInTheDocument()
+ expect(screen.queryByText('Jellyfin Proxy Setup')).not.toBeInTheDocument()
+ expect(screen.queryByText('Home Assistant Proxy Setup')).not.toBeInTheDocument()
+ })
+
+ it('copies external URL to clipboard for plex', async () => {
+ // Mock clipboard API
+ const mockWriteText = vi.fn().mockResolvedValue(undefined)
+ Object.assign(navigator, {
+ clipboard: { writeText: mockWriteText },
+ })
+
+ renderWithClient(
+
+ )
+
+ // Fill in domain names
+ fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
+ target: { value: 'plex.mydomain.com' }
+ })
+
+ // Select Plex preset
+ fireEvent.change(screen.getByLabelText(/Application Preset/i), { target: { value: 'plex' } })
+
+ // Wait for helper to appear
+ await waitFor(() => {
+ expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
+ })
+
+ // Click the copy button
+ const copyButtons = screen.getAllByText('Copy')
+ fireEvent.click(copyButtons[0])
+
+ await waitFor(() => {
+ expect(mockWriteText).toHaveBeenCalledWith('https://plex.mydomain.com:443')
+ expect(screen.getByText('Copied!')).toBeInTheDocument()
+ })
+ })
+ })
})
diff --git a/frontend/src/hooks/__tests__/useProxyHosts.test.tsx b/frontend/src/hooks/__tests__/useProxyHosts.test.tsx
index 8a6d7cfe..b7aff4b1 100644
--- a/frontend/src/hooks/__tests__/useProxyHosts.test.tsx
+++ b/frontend/src/hooks/__tests__/useProxyHosts.test.tsx
@@ -26,6 +26,7 @@ const createMockHost = (overrides: Partial = {}): api.ProxyHost =
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
+ application: 'none',
locations: [],
enabled: true,
created_at: '2025-01-01T00:00:00Z',
diff --git a/frontend/src/test/mockData.ts b/frontend/src/test/mockData.ts
index 151c4f26..a31cb667 100644
--- a/frontend/src/test/mockData.ts
+++ b/frontend/src/test/mockData.ts
@@ -15,6 +15,7 @@ export const mockProxyHosts: ProxyHost[] = [
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
+ application: 'none',
locations: [],
advanced_config: undefined,
enabled: true,
@@ -34,6 +35,7 @@ export const mockProxyHosts: ProxyHost[] = [
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
+ application: 'none',
locations: [],
advanced_config: undefined,
enabled: true,