import { useState, useEffect, useRef, useCallback } from 'react' import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info, AlertTriangle } from 'lucide-react' import { toast } from 'react-hot-toast' import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts' import { testProxyHostConnection } from '../api/proxyHosts' import { syncMonitors } from '../api/uptime' import { useRemoteServers } from '../hooks/useRemoteServers' import { useDomains } from '../hooks/useDomains' import { useCertificates } from '../hooks/useCertificates' import { useDocker } from '../hooks/useDocker' import AccessListSelector from './AccessListSelector' import { useSecurityHeaderProfiles } from '../hooks/useSecurityHeaders' import { SecurityScoreDisplay } from './SecurityScoreDisplay' import { parse } from 'tldts' import { Alert } from './ui/Alert' import { isLikelyDockerContainerIP, isPrivateOrDockerIP } from '../utils/validation' import DNSProviderSelector from './DNSProviderSelector' import { useDetectDNSProvider } from '../hooks/useDNSDetection' import { DNSDetectionResult } from './DNSDetectionResult' import type { DNSProvider } from '../api/dnsProviders' // 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', } // Advanced Caddy config snippets for presets (auto-populate for review) const PRESET_ADVANCED_CONFIG: Record = { none: '', plex: JSON.stringify([ { handler: 'headers', request: { set: { 'X-Plex-Client-Identifier': '{http.request.header.X-Plex-Client-Identifier}', 'X-Real-IP': '{http.request.remote.host}', 'X-Forwarded-Host': '{http.request.host}', }, }, }, ], null, 2), jellyfin: JSON.stringify([ { handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}', 'X-Forwarded-Host': '{http.request.host}', }, }, }, ], null, 2), emby: JSON.stringify([ { handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}' } }, }, ], null, 2), homeassistant: JSON.stringify([ { handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}' } } }, ], null, 2), nextcloud: JSON.stringify([ { handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}', 'X-Forwarded-Host': '{http.request.host}' } } }, ], null, 2), vaultwarden: JSON.stringify([ { handler: 'headers', request: { set: { 'X-Real-IP': '{http.request.remote.host}' } } }, ], null, 2), } interface ProxyHostFormProps { host?: ProxyHost onSubmit: (data: Partial) => Promise onCancel: () => void } export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) { type ProxyHostFormState = Partial & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number } const [formData, setFormData] = useState({ name: host?.name || '', domain_names: host?.domain_names || '', forward_scheme: host?.forward_scheme || 'http', forward_host: host?.forward_host || '', forward_port: host?.forward_port || 80, ssl_forced: host?.ssl_forced ?? true, http2_support: host?.http2_support ?? true, hsts_enabled: host?.hsts_enabled ?? true, hsts_subdomains: host?.hsts_subdomains ?? true, block_exploits: host?.block_exploits ?? true, websocket_support: host?.websocket_support ?? true, enable_standard_headers: host?.enable_standard_headers ?? true, application: (host?.application || 'none') as ApplicationPreset, advanced_config: host?.advanced_config || '', enabled: host?.enabled ?? true, certificate_id: host?.certificate_id, access_list_id: host?.access_list_id, security_header_profile_id: host?.security_header_profile_id, dns_provider_id: host?.dns_provider_id || null, }) // Charon internal IP for config helpers (previously CPMP internal IP) const [charonInternalIP, setCharonInternalIP] = useState('') const [copiedField, setCopiedField] = useState(null) // DNS auto-detection state const { mutateAsync: detectProvider, isPending: isDetecting, data: detectionResult, reset: resetDetection } = useDetectDNSProvider() const [manualProviderSelection, setManualProviderSelection] = useState(false) const detectionTimeoutRef = useRef | null>(null) // Fetch Charon internal IP on mount (legacy: CPMP internal IP) useEffect(() => { fetch('/api/v1/health') .then(res => res.json()) .then(data => { if (data.internal_ip) { setCharonInternalIP(data.internal_ip) } }) .catch(() => {}) }, []) // Auto-detect DNS provider when wildcard domain is entered (debounced 500ms) useEffect(() => { // Clear any pending detection if (detectionTimeoutRef.current) { clearTimeout(detectionTimeoutRef.current) detectionTimeoutRef.current = null } // Reset detection if domain is cleared or manual selection is active if (!formData.domain_names || manualProviderSelection) { resetDetection() return } // Check if domain contains wildcard const domains = formData.domain_names.split(',').map(d => d.trim()) const wildcardDomain = domains.find(d => d.startsWith('*')) if (!wildcardDomain) { resetDetection() return } // Extract base domain from wildcard (*.example.com -> example.com) const baseDomain = wildcardDomain.replace(/^\*\./, '') // Don't detect if provider already set (unless detection succeeded before) if (formData.dns_provider_id && !detectionResult?.suggested_provider) { return } // Debounce detection call by 500ms detectionTimeoutRef.current = setTimeout(() => { detectProvider(baseDomain).catch(err => { console.error('DNS detection failed:', err) }) }, 500) return () => { if (detectionTimeoutRef.current) { clearTimeout(detectionTimeoutRef.current) } } }, [formData.domain_names, formData.dns_provider_id, detectProvider, resetDetection, detectionResult, manualProviderSelection]) // Auto-select suggested provider if confidence is high useEffect(() => { if (detectionResult?.suggested_provider && detectionResult.confidence === 'high' && !manualProviderSelection && !formData.dns_provider_id) { setFormData(prev => ({ ...prev, dns_provider_id: detectionResult.suggested_provider!.id })) toast.success(`Auto-selected: ${detectionResult.suggested_provider.name}`) } }, [detectionResult, manualProviderSelection, formData.dns_provider_id]) // Handle using suggested provider const handleUseSuggested = useCallback((provider: DNSProvider) => { setFormData(prev => ({ ...prev, dns_provider_id: provider.id })) setManualProviderSelection(false) toast.success(`Selected: ${provider.name}`) }, []) // Handle manual provider selection const handleManualSelection = useCallback(() => { setManualProviderSelection(true) }, []) // 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 { // Silently fail if clipboard access is denied } } // 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() const { data: securityProfiles } = useSecurityHeaderProfiles() const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom') const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker( connectionSource === 'local' ? 'local' : undefined, connectionSource !== 'local' && connectionSource !== 'custom' ? connectionSource : undefined ) const [selectedDomain, setSelectedDomain] = useState('') const [selectedContainerId, setSelectedContainerId] = useState('') // New Domain Popup State const [showDomainPrompt, setShowDomainPrompt] = useState(false) const [pendingDomain, setPendingDomain] = useState('') const [dontAskAgain, setDontAskAgain] = useState(false) useEffect(() => { const stored = localStorage.getItem('charon_dont_ask_domain') ?? localStorage.getItem('cpmp_dont_ask_domain') if (stored === 'true') { setDontAskAgain(true) } }, []) const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle') const [showPresetConfirmModal, setShowPresetConfirmModal] = useState(false) const [pendingPreset, setPendingPreset] = useState(null) const handleConfirmPresetOverwrite = () => { if (!pendingPreset) return const presetConfig = PRESET_ADVANCED_CONFIG[pendingPreset] const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(pendingPreset) setFormData(prev => ({ ...prev, application: pendingPreset, websocket_support: needsWebsockets || prev.websocket_support, advanced_config: presetConfig, })) setShowPresetConfirmModal(false) setPendingPreset(null) } const handleCancelPresetOverwrite = () => { setShowPresetConfirmModal(false) setPendingPreset(null) } const handleRestoreBackup = () => { if (host?.advanced_config_backup) { setFormData(prev => ({ ...prev, advanced_config: host.advanced_config_backup || '' })) } } const checkNewDomains = (input: string) => { if (dontAskAgain) return const domainList = input.split(',').map(d => d.trim()).filter(d => d) for (const domain of domainList) { const parsed = parse(domain) if (parsed.domain && parsed.domain !== domain) { // It's a subdomain, check if the base domain exists const baseDomain = parsed.domain const exists = domains.some(d => d.name === baseDomain) if (!exists) { setPendingDomain(baseDomain) setShowDomainPrompt(true) return // Only prompt for one at a time } } else if (parsed.domain && parsed.domain === domain) { // It is a base domain, check if it exists const exists = domains.some(d => d.name === domain) if (!exists) { setPendingDomain(domain) setShowDomainPrompt(true) return } } } } const handleSaveDomain = async () => { try { await createDomain(pendingDomain) setShowDomainPrompt(false) } catch { // Failed to save domain - user can retry } } const handleDontAskToggle = (checked: boolean) => { setDontAskAgain(checked) localStorage.setItem('charon_dont_ask_domain', String(checked)) // Keep legacy value for stored user preference compatibility localStorage.setItem('cpmp_dont_ask_domain', String(checked)) } const handleTestConnection = async () => { if (!formData.forward_host || !formData.forward_port) return setTestStatus('testing') try { await testProxyHostConnection(formData.forward_host, formData.forward_port) setTestStatus('success') // Reset status after 3 seconds setTimeout(() => setTestStatus('idle'), 3000) } catch { setTestStatus('error') // Reset status after 3 seconds setTimeout(() => setTestStatus('idle'), 3000) } } // Validate forward_host for IP address warnings useEffect(() => { const host = formData.forward_host?.trim() || '' if (isLikelyDockerContainerIP(host)) { setForwardHostWarning( 'This looks like a Docker container IP address. Docker IPs can change when containers restart. ' + 'Consider using the container name (e.g., "my-app" or "my-app:8080") for more reliable connections.' ) } else if (isPrivateOrDockerIP(host)) { setForwardHostWarning( 'Using a private IP address. If this is a Docker container, the IP may change on restart. ' + 'Container names are more reliable for Docker services.' ) } else { setForwardHostWarning(null) } }, [formData.forward_host]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [nameError, setNameError] = useState(null) const [forwardHostWarning, setForwardHostWarning] = useState(null) const [addUptime, setAddUptime] = useState(false) const [uptimeInterval, setUptimeInterval] = useState(60) const [uptimeMaxRetries, setUptimeMaxRetries] = useState(3) // Wildcard domain detection for DNS-01 challenge requirement const hasWildcardDomain = formData.domain_names ?.split(',') .some(d => d.trim().startsWith('*')) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() // Validate DNS provider for wildcard domains if (hasWildcardDomain && !formData.dns_provider_id) { toast.error('DNS provider is required for wildcard domains') return } setLoading(true) try { const payload = { ...formData } // strip temporary uptime-only flags from payload by destructuring const { addUptime: _addUptime, uptimeInterval: _uptimeInterval, uptimeMaxRetries: _uptimeMaxRetries, ...payloadWithoutUptime } = payload as ProxyHostFormState void _addUptime; void _uptimeInterval; void _uptimeMaxRetries; const res = await onSubmit(payloadWithoutUptime) // if user asked to add uptime, request server to sync monitors if (addUptime) { try { await syncMonitors({ interval: uptimeInterval, max_retries: uptimeMaxRetries }) toast.success('Requested uptime monitor creation') } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) toast.error(msg || 'Failed to request uptime creation') } } onCancel() return res } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save host' setError(message) toast.error(message) throw err } finally { setLoading(false) } } const tryApplyPreset = (preset: ApplicationPreset) => { const presetConfig = PRESET_ADVANCED_CONFIG[preset] || '' // Auto-apply if no advanced config is present if (!formData.advanced_config || formData.advanced_config.trim() === '') { const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(preset) setFormData(prev => ({ ...prev, application: preset, websocket_support: needsWebsockets || prev.websocket_support, advanced_config: presetConfig })) return } // Otherwise, prompt if different if (formData.advanced_config.trim() !== presetConfig.trim()) { setPendingPreset(preset) setShowPresetConfirmModal(true) return } } const handleContainerSelect = (containerId: string) => { setSelectedContainerId(containerId) const container = dockerContainers.find(c => c.id === containerId) if (container) { // Prefer container name over IP for stability across restarts // Container names work when both Charon and the target are on the same Docker network let host = container.names[0] || container.ip let port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80 // If using local Docker and we have a container name, show info toast if (connectionSource === 'local' && container.names[0]) { toast.success(`Using container name "${container.names[0]}" for stable addressing`, { duration: 3000 }) } // If using a Remote Server, try to use the Host IP and Mapped Public Port if (connectionSource !== 'local' && connectionSource !== 'custom') { const server = remoteServers.find(s => s.uuid === connectionSource) if (server) { // Use the Remote Server's Host IP (e.g. public/tailscale IP) host = server.host // Find a mapped public port // We prefer the first mapped port we find const mappedPort = container.ports?.find(p => p.public_port) if (mappedPort) { port = mappedPort.public_port } else { // If no public port is mapped, we can't reach it from outside // But we'll leave the internal port as a fallback, though it likely won't work } } } let newDomainNames = formData.domain_names if (selectedDomain) { const subdomain = container.names[0].replace(/^\//, '') 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) // Try to apply the preset logic (auto-populate or prompt) tryApplyPreset(detectedPreset) setFormData({ ...formData, forward_host: host, forward_port: port, forward_scheme: 'http', domain_names: newDomainNames, application: detectedPreset, websocket_support: needsWebsockets || formData.websocket_support, }) } } const handleBaseDomainChange = (domain: string) => { setSelectedDomain(domain) if (selectedContainerId && domain) { const container = dockerContainers.find(c => c.id === selectedContainerId) if (container) { const subdomain = container.names[0].replace(/^\//, '') setFormData(prev => ({ ...prev, domain_names: `${subdomain}.${domain}` })) } } } return (

{host ? 'Edit Proxy Host' : 'Add Proxy Host'}

{error && (
{error}
)} {/* Name Field */}
{ setFormData({ ...formData, name: e.target.value }) if (nameError && e.target.value.trim()) { setNameError(null) } }} placeholder="My Service" className={`w-full bg-gray-900 border rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${ nameError ? 'border-red-500' : 'border-gray-700' }`} /> {nameError ? (

{nameError}

) : (

A friendly name to identify this proxy host

)}
{/* Docker Container Quick Select */}
{dockerError && connectionSource !== 'custom' && (

Docker Connection Failed

{(dockerError as Error).message}

Troubleshooting: Ensure Docker is running and the socket is accessible. If running in a container, mount /var/run/docker.sock.

)}
{/* Domain Names */}
{domains.length > 0 && (
)}
setFormData({ ...formData, domain_names: e.target.value })} onBlur={e => checkNewDomains(e.target.value)} placeholder="example.com, www.example.com" 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" />
{/* Forward Details */}
setFormData({ ...formData, forward_host: e.target.value })} placeholder="my-container or 192.168.1.100" 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" /> {forwardHostWarning && ( {forwardHostWarning} )}
{ const v = parseInt(e.target.value) setFormData({ ...formData, forward_port: Number.isNaN(v) ? 0 : v }) }} 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" />
{/* SSL Certificate Selection */}

Choose an existing certificate if already issued for these domains, or let Charon request/renew via Let's Encrypt automatically.

{/* DNS Provider Selector for Wildcard Domains */} {hasWildcardDomain && (

Wildcard Certificate Required

Wildcard certificates (*.example.com) require DNS-01 challenge. Select a DNS provider to automatically manage DNS records for certificate validation.

{/* DNS Detection Result */} {(isDetecting || detectionResult) && !manualProviderSelection && ( )} { setFormData(prev => ({ ...prev, dns_provider_id: id ?? null })) if (id) { setManualProviderSelection(true) } }} required={true} />
)} {/* Access Control List */} setFormData({ ...formData, access_list_id: id })} /> {/* Security Headers Profile */}
{formData.security_header_profile_id && (() => { const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id) if (!selected) return null return (
{selected.description}
) })()} {/* Mobile App Compatibility Warning for Strict/Paranoid profiles */} {formData.security_header_profile_id && (() => { const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id) if (!selected) return null const isRestrictive = selected.preset_type === 'strict' || selected.preset_type === 'paranoid' if (!isRestrictive) return null return (

Mobile App Compatibility Warning

This security profile may break mobile apps like Radarr, Plex, Jellyfin, or Home Assistant companion apps. Consider using "API-Friendly" or "Basic" for services accessed by mobile clients.

) })()}

Apply HTTP security headers to protect against common web vulnerabilities.{' '} Manage Profiles →

{/* 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()}
{charonInternalIP && (

Add this IP to Plex Settings → Network → List of IP addresses that are considered local or as a trusted proxy to ensure Plex displays remote clients correctly.

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

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

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

Add this to your configuration.yaml under http:

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

Add this to your config/config.php

{`'trusted_proxies' => ['${charonInternalIP}'],
'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 */}
{/* Legacy Headers Warning Banner */} {host && (formData.enable_standard_headers === false) && (

Standard Proxy Headers Disabled

This proxy host is using the legacy behavior (headers only with WebSocket support). Enable this option to ensure backend applications receive client IP and protocol information.

)} {/* Advanced Config */}
{host?.advanced_config_backup && (
)}