From b9028da78f7c1a35536cde7fb3feb3e11b119f93 Mon Sep 17 00:00:00 2001 From: nguyenthuyendieu4 Date: Tue, 14 Oct 2025 06:37:04 +0000 Subject: [PATCH 01/10] feat: update DomainDialog to DomainDialogV2 with advanced configuration options - Replaced DomainDialog with DomainDialogV2 in Domains component. - Added new fields for advanced domain settings including real IP configuration, HSTS, HTTP/2, gRPC support, and custom location blocks. - Updated Domain type to include new properties for advanced settings. - Created migration script to add new columns for advanced settings in the database. --- .../migration.sql | 5 + apps/api/prisma/schema.prisma | 6 + .../src/domains/domains/domains.controller.ts | 6 +- .../src/domains/domains/domains.repository.ts | 22 + apps/api/src/domains/domains/domains.types.ts | 18 + .../domains/services/nginx-config.service.ts | 362 ++++++- .../src/components/domains/DomainDialogV2.tsx | 943 ++++++++++++++++++ apps/web/src/components/pages/Domains.tsx | 4 +- apps/web/src/types/index.ts | 17 + 9 files changed, 1366 insertions(+), 17 deletions(-) create mode 100644 apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql create mode 100644 apps/web/src/components/domains/DomainDialogV2.tsx diff --git a/apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql b/apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql new file mode 100644 index 0000000..82b0e96 --- /dev/null +++ b/apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "domains" ADD COLUMN "customLocations" JSONB, +ADD COLUMN "grpcEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "hstsEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "http2Enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 80c3de0..1dc2d83 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -183,6 +183,12 @@ model Domain { realIpCloudflare Boolean @default(false) // Use Cloudflare IP ranges realIpCustomCidrs String[] @default([]) // Custom CIDR ranges for set_real_ip_from + // Advanced Configuration + hstsEnabled Boolean @default(false) // HTTP Strict Transport Security + http2Enabled Boolean @default(true) // Enable HTTP/2 + grpcEnabled Boolean @default(false) // Enable gRPC/gRPCs support + customLocations Json? // Custom location blocks configuration + // Relations upstreams Upstream[] loadBalancer LoadBalancerConfig? diff --git a/apps/api/src/domains/domains/domains.controller.ts b/apps/api/src/domains/domains/domains.controller.ts index 7cabaa3..f2146db 100644 --- a/apps/api/src/domains/domains/domains.controller.ts +++ b/apps/api/src/domains/domains/domains.controller.ts @@ -98,7 +98,7 @@ export class DomainsController { return; } - const { name, upstreams, loadBalancer, modsecEnabled, realIpConfig } = req.body; + const { name, upstreams, loadBalancer, modsecEnabled, realIpConfig, advancedConfig } = req.body; const domain = await domainsService.createDomain( { @@ -107,6 +107,7 @@ export class DomainsController { loadBalancer, modsecEnabled, realIpConfig, + advancedConfig, }, req.user!.userId, req.user!.username, @@ -152,7 +153,7 @@ export class DomainsController { } const { id } = req.params; - const { name, status, modsecEnabled, upstreams, loadBalancer, realIpConfig } = req.body; + const { name, status, modsecEnabled, upstreams, loadBalancer, realIpConfig, advancedConfig } = req.body; const domain = await domainsService.updateDomain( id, @@ -163,6 +164,7 @@ export class DomainsController { upstreams, loadBalancer, realIpConfig, + advancedConfig, }, req.user!.userId, req.user!.username, diff --git a/apps/api/src/domains/domains/domains.repository.ts b/apps/api/src/domains/domains/domains.repository.ts index 0562765..f93736e 100644 --- a/apps/api/src/domains/domains/domains.repository.ts +++ b/apps/api/src/domains/domains/domains.repository.ts @@ -139,6 +139,11 @@ export class DomainsRepository { realIpEnabled: input.realIpConfig?.realIpEnabled || false, realIpCloudflare: input.realIpConfig?.realIpCloudflare || false, realIpCustomCidrs: input.realIpConfig?.realIpCustomCidrs || [], + // Advanced configuration + hstsEnabled: input.advancedConfig?.hstsEnabled || false, + http2Enabled: input.advancedConfig?.http2Enabled !== undefined ? input.advancedConfig.http2Enabled : true, + grpcEnabled: input.advancedConfig?.grpcEnabled || false, + customLocations: input.advancedConfig?.customLocations ? JSON.parse(JSON.stringify(input.advancedConfig.customLocations)) : null, upstreams: { create: input.upstreams.map((u: CreateUpstreamData) => ({ host: u.host, @@ -226,6 +231,23 @@ export class DomainsRepository { input.realIpConfig?.realIpCustomCidrs !== undefined ? input.realIpConfig.realIpCustomCidrs : currentDomain.realIpCustomCidrs, + // Advanced configuration + hstsEnabled: + input.advancedConfig?.hstsEnabled !== undefined + ? input.advancedConfig.hstsEnabled + : currentDomain.hstsEnabled, + http2Enabled: + input.advancedConfig?.http2Enabled !== undefined + ? input.advancedConfig.http2Enabled + : currentDomain.http2Enabled, + grpcEnabled: + input.advancedConfig?.grpcEnabled !== undefined + ? input.advancedConfig.grpcEnabled + : currentDomain.grpcEnabled, + customLocations: + input.advancedConfig?.customLocations !== undefined + ? JSON.parse(JSON.stringify(input.advancedConfig.customLocations)) + : currentDomain.customLocations, }, }); diff --git a/apps/api/src/domains/domains/domains.types.ts b/apps/api/src/domains/domains/domains.types.ts index fa4ee73..9ae7c7f 100644 --- a/apps/api/src/domains/domains/domains.types.ts +++ b/apps/api/src/domains/domains/domains.types.ts @@ -39,6 +39,22 @@ export interface RealIpConfigData { realIpCustomCidrs?: string[]; } +// Custom location configuration +export interface CustomLocationData { + path: string; // Location path (e.g., /api, /admin) + upstreamType: 'proxy_pass' | 'grpc_pass' | 'grpcs_pass'; // Type of upstream + upstreams: CreateUpstreamData[]; // Upstream servers for this location + config?: string; // Additional custom nginx config +} + +// Advanced configuration data +export interface AdvancedConfigData { + hstsEnabled?: boolean; // Enable HSTS header + http2Enabled?: boolean; // Enable HTTP/2 + grpcEnabled?: boolean; // Enable gRPC support (default proxy_pass replacement) + customLocations?: CustomLocationData[]; // Custom location blocks +} + // Domain creation input export interface CreateDomainInput { name: string; @@ -46,6 +62,7 @@ export interface CreateDomainInput { loadBalancer?: LoadBalancerConfigData; modsecEnabled?: boolean; realIpConfig?: RealIpConfigData; + advancedConfig?: AdvancedConfigData; } // Domain update input @@ -56,6 +73,7 @@ export interface UpdateDomainInput { upstreams?: CreateUpstreamData[]; loadBalancer?: LoadBalancerConfigData; realIpConfig?: RealIpConfigData; + advancedConfig?: AdvancedConfigData; } // Domain query filters diff --git a/apps/api/src/domains/domains/services/nginx-config.service.ts b/apps/api/src/domains/domains/services/nginx-config.service.ts index 3210c18..c421ddd 100644 --- a/apps/api/src/domains/domains/services/nginx-config.service.ts +++ b/apps/api/src/domains/domains/services/nginx-config.service.ts @@ -1,10 +1,14 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; import logger from '../../../utils/logger'; import { PATHS } from '../../../shared/constants/paths.constants'; import { DomainWithRelations } from '../domains.types'; import { cloudflareIpsService } from './cloudflare-ips.service'; +const execAsync = promisify(exec); + /** * Service for generating Nginx configuration files */ @@ -12,12 +16,35 @@ export class NginxConfigService { private readonly sitesAvailable = PATHS.NGINX.SITES_AVAILABLE; private readonly sitesEnabled = PATHS.NGINX.SITES_ENABLED; + /** + * Validate nginx configuration syntax + * @returns {Promise<{valid: boolean, error?: string}>} + */ + async validateNginxConfig(): Promise<{ valid: boolean; error?: string }> { + try { + const { stdout, stderr } = await execAsync('nginx -t 2>&1'); + const output = stdout + stderr; + + if (output.includes('syntax is ok') && output.includes('test is successful')) { + logger.info('Nginx configuration validation passed'); + return { valid: true }; + } + + logger.error('Nginx configuration validation failed:', output); + return { valid: false, error: output }; + } catch (error: any) { + logger.error('Nginx configuration validation error:', error.message); + return { valid: false, error: error.message }; + } + } + /** * Generate complete Nginx configuration for a domain */ async generateConfig(domain: DomainWithRelations): Promise { const configPath = path.join(this.sitesAvailable, `${domain.name}.conf`); const enabledPath = path.join(this.sitesEnabled, `${domain.name}.conf`); + const backupPath = path.join(this.sitesAvailable, `${domain.name}.conf.backup`); // Debug logging logger.info(`Generating nginx config for ${domain.name}:`); @@ -38,6 +65,15 @@ export class NginxConfigService { try { await fs.mkdir(this.sitesAvailable, { recursive: true }); await fs.mkdir(this.sitesEnabled, { recursive: true }); + + // Backup existing config if it exists + try { + await fs.copyFile(configPath, backupPath); + logger.info(`Backed up existing config to ${backupPath}`); + } catch (e) { + // No existing config to backup + } + await fs.writeFile(configPath, fullConfig); // Create symlink if domain is active @@ -50,9 +86,32 @@ export class NginxConfigService { await fs.symlink(configPath, enabledPath); } - logger.info(`Nginx configuration generated for ${domain.name}`); + // Validate nginx configuration + const validation = await this.validateNginxConfig(); + + if (!validation.valid) { + // Restore backup if validation fails + logger.error(`Nginx config validation failed for ${domain.name}, restoring backup`); + try { + await fs.copyFile(backupPath, configPath); + logger.info('Backup restored successfully'); + } catch (restoreError) { + logger.error('Failed to restore backup:', restoreError); + } + + throw new Error(`Invalid nginx configuration: ${validation.error}`); + } + + logger.info(`Nginx configuration generated and validated for ${domain.name}`); + + // Clean up backup after successful validation + try { + await fs.unlink(backupPath); + } catch (e) { + // Ignore cleanup errors + } } catch (error) { - logger.error(`Failed to write nginx config for ${domain.name}:`, error); + logger.error(`Failed to generate nginx config for ${domain.name}:`, error); throw error; } } @@ -64,7 +123,7 @@ export class NginxConfigService { const upstreamName = domain.name.replace(/\./g, '_'); const algorithm = domain.loadBalancer?.algorithm || 'round_robin'; - const algorithmDirectives = []; + const algorithmDirectives: string[] = []; if (algorithm === 'least_conn') { algorithmDirectives.push('least_conn;'); } else if (algorithm === 'ip_hash') { @@ -78,7 +137,7 @@ export class NginxConfigService { // Calculate keepalive connections: 10 connections per backend const keepaliveConnections = domain.upstreams.length * 10; - return ` + let upstreamBlocks = ` upstream ${upstreamName}_backend { ${algorithmDirectives.join('\n ')} ${algorithmDirectives.length > 0 ? '\n ' : ''}${servers} @@ -87,6 +146,54 @@ upstream ${upstreamName}_backend { keepalive ${keepaliveConnections}; } `; + + // Generate upstream blocks for custom locations + if (domain.customLocations && typeof domain.customLocations === 'object') { + try { + const locations = Array.isArray(domain.customLocations) + ? domain.customLocations + : (domain.customLocations as any).locations || []; + + locations.forEach((loc: any) => { + // Skip if config already contains proxy_pass or grpc_pass (user manages their own upstream) + const hasProxyDirective = loc.config && ( + loc.config.includes('proxy_pass') || + loc.config.includes('grpc_pass') + ); + + if (hasProxyDirective) { + return; // Skip upstream generation + } + + // Only generate upstream if we have valid upstreams with non-empty hosts + if (loc.upstreams && Array.isArray(loc.upstreams) && loc.upstreams.length > 0) { + const validUpstreams = loc.upstreams.filter((u: any) => u.host && u.host.trim() !== ''); + + if (validUpstreams.length === 0) { + return; // Skip if no valid upstreams + } + + const locationUpstreamName = `${domain.name.replace(/\./g, '_')}_${loc.path.replace(/[^a-zA-Z0-9]/g, '_')}`; + const locationServers = validUpstreams.map((u: any) => + `server ${u.host}:${u.port} weight=${u.weight || 1} max_fails=${u.maxFails || 3} fail_timeout=${u.failTimeout || 10}s;` + ).join('\n '); + + upstreamBlocks += ` +upstream ${locationUpstreamName}_backend { + ${algorithmDirectives.join('\n ')} + ${algorithmDirectives.length > 0 ? '\n ' : ''}${locationServers} + + keepalive ${validUpstreams.length * 10}; +} +`; + } + }); + } catch (error) { + logger.error('Failed to generate custom location upstreams:', error); + } + } + + return upstreamBlocks; } /** @@ -170,10 +277,21 @@ ${realIpBlock} // Generate Real IP block const realIpBlock = await this.generateRealIpBlock(domain); + + // Generate HSTS header if enabled + const hstsHeader = domain.hstsEnabled + ? 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;' + : 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;'; + + // HTTP/2 support (enabled by default, can be disabled) + const http2Support = domain.http2Enabled !== false ? ' http2' : ''; + + // Generate custom locations if configured + const customLocations = this.generateCustomLocations(domain); return ` server { - listen 443 ssl http2; + listen 443 ssl${http2Support}; server_name ${domain.name}; ${realIpBlock} @@ -195,7 +313,7 @@ ${realIpBlock} ssl_stapling_verify on; # Security Headers - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + ${hstsHeader} add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; @@ -205,13 +323,9 @@ ${realIpBlock} access_log /var/log/nginx/${domain.name}_ssl_access.log main; error_log /var/log/nginx/${domain.name}_ssl_error.log warn; +${customLocations} location / { - ${this.generateProxyHeaders(domain)} - proxy_pass ${upstreamProtocol}://${upstreamName}_backend; - - ${this.generateHttpsBackendSettings(domain)} - - ${this.generateHealthCheckSettings(domain)} + ${domain.grpcEnabled ? this.generateGrpcLocationBlock(domain, upstreamName) : this.generateProxyLocationBlock(domain, upstreamName, upstreamProtocol)} } location /nginx_health { @@ -298,7 +412,7 @@ ${realIpBlock} } /** - * Generate health check settings + * Generate health check settings for HTTP/HTTPS proxy */ private generateHealthCheckSettings(domain: DomainWithRelations): string { if (!domain.loadBalancer?.healthCheckEnabled) { @@ -313,6 +427,228 @@ ${realIpBlock} `; } + /** + * Generate health check settings for gRPC + */ + private generateGrpcHealthCheckSettings(domain: DomainWithRelations): string { + if (!domain.loadBalancer?.healthCheckEnabled) { + return ''; + } + + return ` + # gRPC Health check settings + grpc_next_upstream error timeout http_502 http_503 http_504; + grpc_next_upstream_tries 3; + grpc_next_upstream_timeout ${domain.loadBalancer.healthCheckTimeout}s; + `; + } + + /** + * Generate proxy_pass directive + */ + private generateProxyPass(domain: DomainWithRelations, upstreamName: string, protocol: string): string { + return `proxy_pass ${protocol}://${upstreamName}_backend;`; + } + + /** + * Generate grpc_pass directive with proper scheme + * Note: Only grpc_pass directive exists. Use grpc:// or grpcs:// scheme for protocol + */ + private generateGrpcPass(domain: DomainWithRelations, upstreamName: string): string { + const hasHttpsUpstream = domain.upstreams.some((u) => u.protocol === 'https'); + const grpcScheme = hasHttpsUpstream ? 'grpcs://' : 'grpc://'; + return `grpc_pass ${grpcScheme}${upstreamName}_backend;`; + } + + /** + * Generate complete gRPC location block with SSL settings + */ + private generateGrpcLocationBlock(domain: DomainWithRelations, upstreamName: string): string { + const hasHttpsUpstream = domain.upstreams.some((u) => u.protocol === 'https'); + const grpcScheme = hasHttpsUpstream ? 'grpcs://' : 'grpc://'; + + const shouldVerify = domain.upstreams.some( + (u) => u.protocol === 'https' && u.sslVerify + ); + + // gRPC SSL settings (if using HTTPS/TLS backend) + let sslSettings = ''; + if (hasHttpsUpstream) { + sslSettings = ` + # gRPC SSL Backend Settings + ${shouldVerify ? 'grpc_ssl_verify on;' : 'grpc_ssl_verify off;'} + grpc_ssl_server_name on; + grpc_ssl_name ${domain.name}; + grpc_ssl_protocols TLSv1.2 TLSv1.3;`; + } + + // gRPC timeout and error handling (equivalent to proxy_next_upstream) + // Note: grpc_next_upstream valid values: error, timeout, invalid_header, http_500, http_502, http_503, http_504, http_403, http_404, http_429, non_idempotent, off + let healthCheckSettings = ''; + if (domain.loadBalancer?.healthCheckEnabled) { + healthCheckSettings = ` + # gRPC error handling and failover + grpc_next_upstream error timeout http_502 http_503 http_504; + grpc_next_upstream_tries 3; + grpc_next_upstream_timeout ${domain.loadBalancer.healthCheckTimeout}s;`; + } + + // gRPC timeouts (equivalent to proxy timeouts) + const timeoutSettings = ` + # gRPC timeouts + grpc_connect_timeout 60s; + grpc_send_timeout 60s; + grpc_read_timeout 60s;`; + + return `# gRPC Configuration + grpc_pass ${grpcScheme}${upstreamName}_backend; +${sslSettings} +${timeoutSettings} +${healthCheckSettings}`; + } + + /** + * Generate complete proxy location block with SSL settings + */ + private generateProxyLocationBlock(domain: DomainWithRelations, upstreamName: string, protocol: string): string { + return `${this.generateProxyHeaders(domain)} + + # Proxy Configuration + proxy_pass ${protocol}://${upstreamName}_backend; + + ${this.generateHttpsBackendSettings(domain)} + + ${this.generateHealthCheckSettings(domain)}`; + } + + /** + * Generate custom location blocks + */ + private generateCustomLocations(domain: DomainWithRelations): string { + if (!domain.customLocations || typeof domain.customLocations !== 'object') { + return ''; + } + + try { + const locations = Array.isArray(domain.customLocations) + ? domain.customLocations + : (domain.customLocations as any).locations || []; + + if (locations.length === 0) { + return ''; + } + + return locations.map((loc: any) => { + const { path: locPath, useUpstream, upstreamType, upstreams, config } = loc; + + // Case 1: User disabled upstream (useUpstream = false) - use custom config only + if (useUpstream === false) { + if (!config || config.trim() === '') { + logger.warn(`Custom location ${locPath} has no config and upstream disabled, skipping`); + return ''; + } + + return ` + location ${locPath} { + ${config} + }`; + } + + // Case 2: User enabled upstream (useUpstream = true) - generate upstream-based config + if (useUpstream === true) { + // Validate that upstreams exist and have valid hosts + if (!upstreams || !Array.isArray(upstreams) || upstreams.length === 0) { + logger.warn(`Custom location ${locPath} has upstream enabled but no upstreams defined, skipping`); + return ''; + } + + const validUpstreams = upstreams.filter((u: any) => u.host && u.host.trim() !== ''); + if (validUpstreams.length === 0) { + logger.warn(`Custom location ${locPath} has no valid upstream hosts, skipping`); + return ''; + } + + const locationUpstreamName = `${domain.name.replace(/\./g, '_')}_${locPath.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // Determine directive based on type + // Important: Add trailing slash (/) to strip location path before proxying + let proxyDirective = ''; + if (upstreamType === 'grpc_pass') { + proxyDirective = `grpc_pass grpc://${locationUpstreamName}_backend/;`; + } else if (upstreamType === 'grpcs_pass') { + proxyDirective = `grpc_pass grpcs://${locationUpstreamName}_backend/;`; + } else { + const hasHttpsUpstream = upstreams?.some((u: any) => u.protocol === 'https'); + const protocol = hasHttpsUpstream ? 'https' : 'http'; + proxyDirective = `proxy_pass ${protocol}://${locationUpstreamName}_backend/;`; + } + + return ` + location ${locPath} { + ${this.generateProxyHeaders(domain)} + ${proxyDirective} + ${config ? '\n ' + config : ''} + }`; + } + + // Case 3: Legacy - no useUpstream field (backward compatibility) + // Check if config already contains proxy_pass or grpc_pass + const hasProxyDirective = config && ( + config.includes('proxy_pass') || + config.includes('grpc_pass') + ); + + if (hasProxyDirective) { + return ` + location ${locPath} { + ${config} + }`; + } + + // Generate upstream-based config if upstreams exist + if (upstreams && Array.isArray(upstreams) && upstreams.length > 0) { + const validUpstreams = upstreams.filter((u: any) => u.host && u.host.trim() !== ''); + if (validUpstreams.length > 0) { + const locationUpstreamName = `${domain.name.replace(/\./g, '_')}_${locPath.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // Add trailing slash (/) to strip location path before proxying + let proxyDirective = ''; + if (upstreamType === 'grpc_pass') { + proxyDirective = `grpc_pass grpc://${locationUpstreamName}_backend/;`; + } else if (upstreamType === 'grpcs_pass') { + proxyDirective = `grpc_pass grpcs://${locationUpstreamName}_backend/;`; + } else { + const hasHttpsUpstream = upstreams?.some((u: any) => u.protocol === 'https'); + const protocol = hasHttpsUpstream ? 'https' : 'http'; + proxyDirective = `proxy_pass ${protocol}://${locationUpstreamName}_backend/;`; + } + + return ` + location ${locPath} { + ${this.generateProxyHeaders(domain)} + ${proxyDirective} + ${config ? '\n ' + config : ''} + }`; + } + } + + // Fallback: just use config if provided + if (config && config.trim() !== '') { + return ` + location ${locPath} { + ${config} + }`; + } + + logger.warn(`Custom location ${locPath} has no valid configuration, skipping`); + return ''; + }).filter((loc: string) => loc !== '').join('\n'); + } catch (error) { + logger.error('Failed to generate custom locations:', error); + return ''; + } + } + /** * Delete nginx configuration for a domain */ diff --git a/apps/web/src/components/domains/DomainDialogV2.tsx b/apps/web/src/components/domains/DomainDialogV2.tsx new file mode 100644 index 0000000..1215ca0 --- /dev/null +++ b/apps/web/src/components/domains/DomainDialogV2.tsx @@ -0,0 +1,943 @@ +import { useEffect } from 'react'; +import { useForm, useFieldArray, Controller } from 'react-hook-form'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Plus, Trash2, HelpCircle, Shield, Server, Settings } from 'lucide-react'; +import { Domain } from '@/types'; +import { toast } from 'sonner'; + +interface UpstreamFormData { + host: string; + port: number; + protocol: 'http' | 'https'; + sslVerify: boolean; + weight: number; + maxFails: number; + failTimeout: number; +} + +interface CustomLocationFormData { + path: string; + upstreamType: 'proxy_pass' | 'grpc_pass' | 'grpcs_pass'; + upstreams: UpstreamFormData[]; + config?: string; +} + +interface FormData { + // Basic + name: string; + status: 'active' | 'inactive' | 'error'; + lbAlgorithm: 'round_robin' | 'least_conn' | 'ip_hash'; + upstreams: UpstreamFormData[]; + + // Security + modsecEnabled: boolean; + realIpEnabled: boolean; + realIpCloudflare: boolean; + healthCheckEnabled: boolean; + healthCheckPath: string; + healthCheckInterval: number; + healthCheckTimeout: number; + + // Advanced + hstsEnabled: boolean; + http2Enabled: boolean; + grpcEnabled: boolean; + customLocations: CustomLocationFormData[]; +} + +interface DomainDialogV2Props { + open: boolean; + onOpenChange: (open: boolean) => void; + domain?: Domain | null; + onSave: (domain: any) => void; +} + +export function DomainDialogV2({ open, onOpenChange, domain, onSave }: DomainDialogV2Props) { + const { + register, + handleSubmit, + control, + watch, + setValue, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + name: '', + status: 'active', + lbAlgorithm: 'round_robin', + upstreams: [{ host: '', port: 80, protocol: 'http', sslVerify: true, weight: 1, maxFails: 3, failTimeout: 30 }], + modsecEnabled: true, + realIpEnabled: false, + realIpCloudflare: false, + healthCheckEnabled: true, + healthCheckPath: '/health', + healthCheckInterval: 30, + healthCheckTimeout: 5, + hstsEnabled: false, + http2Enabled: true, + grpcEnabled: false, + customLocations: [], + }, + }); + + const { fields: upstreamFields, append: appendUpstream, remove: removeUpstream } = useFieldArray({ + control, + name: 'upstreams', + }); + + const { fields: locationFields, append: appendLocation, remove: removeLocation } = useFieldArray({ + control, + name: 'customLocations', + }); + + const realIpEnabled = watch('realIpEnabled'); + const healthCheckEnabled = watch('healthCheckEnabled'); + + // Reset form when dialog opens or domain changes + useEffect(() => { + if (open) { + if (domain) { + // Edit mode + reset({ + name: domain.name || '', + status: domain.status || 'active', + lbAlgorithm: (domain.loadBalancer?.algorithm || 'round_robin') as any, + upstreams: domain.upstreams && domain.upstreams.length > 0 + ? domain.upstreams.map(u => ({ + host: u.host, + port: u.port, + protocol: (u.protocol || 'http') as 'http' | 'https', + sslVerify: u.sslVerify !== undefined ? u.sslVerify : true, + weight: u.weight || 1, + maxFails: u.maxFails || 3, + failTimeout: u.failTimeout || 30, + })) + : [{ host: '', port: 80, protocol: 'http', sslVerify: true, weight: 1, maxFails: 3, failTimeout: 30 }], + modsecEnabled: domain.modsecEnabled !== undefined ? domain.modsecEnabled : true, + realIpEnabled: (domain as any).realIpEnabled || false, + realIpCloudflare: (domain as any).realIpCloudflare || false, + healthCheckEnabled: domain.loadBalancer?.healthCheckEnabled !== undefined ? domain.loadBalancer.healthCheckEnabled : true, + healthCheckPath: domain.loadBalancer?.healthCheckPath || '/health', + healthCheckInterval: domain.loadBalancer?.healthCheckInterval || 30, + healthCheckTimeout: domain.loadBalancer?.healthCheckTimeout || 5, + hstsEnabled: (domain as any).hstsEnabled || false, + http2Enabled: (domain as any).http2Enabled !== undefined ? (domain as any).http2Enabled : true, + grpcEnabled: (domain as any).grpcEnabled || false, + customLocations: (domain as any).customLocations || [], + }); + } else { + // Create mode + reset({ + name: '', + status: 'active', + lbAlgorithm: 'round_robin', + upstreams: [{ host: '', port: 80, protocol: 'http', sslVerify: true, weight: 1, maxFails: 3, failTimeout: 30 }], + modsecEnabled: true, + realIpEnabled: false, + realIpCloudflare: false, + healthCheckEnabled: true, + healthCheckPath: '/health', + healthCheckInterval: 30, + healthCheckTimeout: 5, + hstsEnabled: false, + http2Enabled: true, + grpcEnabled: false, + customLocations: [], + }); + } + } + }, [open, domain, reset]); + + const onSubmit = (data: FormData) => { + if (!data.name) { + toast.error('Domain name is required'); + return; + } + + if (data.upstreams.length === 0 || !data.upstreams.some(u => u.host)) { + toast.error('At least one valid upstream backend is required'); + return; + } + + // Prepare data in API format + const domainData = { + name: data.name, + status: data.status, + modsecEnabled: data.modsecEnabled, + upstreams: data.upstreams.filter(u => u.host).map(u => ({ + host: u.host, + port: Number(u.port), + protocol: u.protocol, + sslVerify: u.sslVerify, + weight: Number(u.weight), + maxFails: Number(u.maxFails), + failTimeout: Number(u.failTimeout), + })), + loadBalancer: { + algorithm: data.lbAlgorithm, + healthCheckEnabled: data.healthCheckEnabled, + healthCheckInterval: Number(data.healthCheckInterval), + healthCheckTimeout: Number(data.healthCheckTimeout), + healthCheckPath: data.healthCheckPath, + }, + realIpConfig: { + realIpEnabled: data.realIpEnabled, + realIpCloudflare: data.realIpCloudflare, + realIpCustomCidrs: [], + }, + advancedConfig: { + hstsEnabled: data.hstsEnabled, + http2Enabled: data.http2Enabled, + grpcEnabled: data.grpcEnabled, + customLocations: data.customLocations.filter(loc => loc.path && loc.upstreams.length > 0), + }, + }; + + onSave(domainData); + onOpenChange(false); + }; + + return ( + + + + {domain ? 'Edit Domain' : 'Add New Domain'} + + Configure domain settings with Basic, Security, and Advanced options + + + +
+ + + + + Basic + + + + Security + + + + Advanced + + + + {/* TAB 1: BASIC */} + +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+

Upstream Backends *

+ +
+ +
+ {upstreamFields.map((field, index) => ( + + +
+
+

Backend #{index + 1}

+ {upstreamFields.length > 1 && ( + + )} +
+ +
+
+ + + {errors.upstreams?.[index]?.host && ( +

{errors.upstreams[index]?.host?.message}

+ )} +
+ +
+ + +
+ +
+ + +
+
+ + {watch(`upstreams.${index}.protocol`) === 'https' && ( +
+
+ +

+ Skip backend certificate validation +

+
+ ( + field.onChange(!checked)} + /> + )} + /> +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ ))} +
+
+
+ + {/* TAB 2: SECURITY */} + +
+
+ +

+ Activate Web Application Firewall protection +

+
+ ( + + )} + /> +
+ +
+
+ +

+ Enable real IP detection (for Cloudflare/CDN) +

+
+ ( + + )} + /> +
+ + {realIpEnabled && ( +
+
+
+ +

+ Automatically trust all Cloudflare IPs +

+
+ ( + + )} + /> +
+
+ )} + +
+
+ +

+ Enable health checks for upstream backends +

+
+ ( + + )} + /> +
+ + {healthCheckEnabled && ( +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ )} +
+ + {/* TAB 3: ADVANCED */} + +
+

Security Features

+ +
+
+
+ +

+ Force browsers to use HTTPS with preload +

+
+ ( + + )} + /> +
+ +
+
+ +

+ Enable HTTP/2 protocol support +

+
+ ( + + )} + /> +
+
+
+ +
+

gRPC Configuration

+ + +
+
+
+ +

+ Use grpc_pass/grpcs_pass instead of proxy_pass for main location +

+
+ + + + + +

When enabled, the root location (/) will use grpc_pass/grpcs_pass

+

protocol instead of HTTP proxy_pass

+
+
+
+ ( + + )} + /> +
+
+
+ +
+
+

Custom Location Blocks

+ +
+ +

+ Define custom location blocks with optional upstream backends or custom configuration +

+ +
+ {locationFields.map((field, locationIndex) => ( + + +
+
+

Location #{locationIndex + 1}

+ +
+ +
+ + +

Example: /api, /static, /admin

+
+ + {/* Toggle: Use Upstream Backend */} +
+
+ + + + + + + +

Enable: Configure backend servers for load balancing

+

Disable: Write custom nginx config manually

+
+
+
+
+ ( + { + field.onChange(checked); + if (checked && (!watch(`customLocations.${locationIndex}.upstreams`) || watch(`customLocations.${locationIndex}.upstreams`).length === 0)) { + setValue(`customLocations.${locationIndex}.upstreams`, [ + { host: '', port: 80, protocol: 'http', sslVerify: true, weight: 1, maxFails: 3, failTimeout: 30 } + ]); + } + }} + /> + )} + /> +
+ + {/* Show upstream config if enabled */} + {watch(`customLocations.${locationIndex}.useUpstream`) && ( +
+
+
+ + + + + + + +

proxy_pass: Standard HTTP/HTTPS proxy

+

grpc_pass: gRPC over HTTP/2 (grpc://)

+

grpcs_pass: gRPC over TLS (grpcs://)

+
+
+
+
+ +
+ + {/* Upstream Servers - Required when useUpstream is true */} +
+
+
+ +

At least one backend server with valid host and port

+
+ +
+ + {(watch(`customLocations.${locationIndex}.upstreams`) || []).map((upstream: any, upstreamIndex: number) => ( + + +
+ Server #{upstreamIndex + 1} + {(watch(`customLocations.${locationIndex}.upstreams`) || []).length > 1 && ( + + )} +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {watch(`customLocations.${locationIndex}.upstreams.${upstreamIndex}.protocol`) === 'https' && ( +
+ + ( + field.onChange(!checked)} + /> + )} + /> +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ ))} + + {(!watch(`customLocations.${locationIndex}.upstreams`) || watch(`customLocations.${locationIndex}.upstreams`).length === 0) && ( +
+

No backend servers configured

+

Click "Add Server" to add a backend

+
+ )} +
+ +
+ +