diff --git a/database/model/model.go b/database/model/model.go index 5ad12305..76910d7a 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -164,10 +164,14 @@ type ClientEntity struct { type Node struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier Name string `json:"name" form:"name"` // Node name/identifier - Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080") + Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...") ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp + UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls + CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA) + KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA) + InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended) CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp } diff --git a/web/controller/node.go b/web/controller/node.go index bd3ca595..11180d87 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -150,6 +150,19 @@ func (a *NodeController) updateNode(c *gin.Context) { if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" { node.ApiKey = apiKeyVal } + // TLS settings + if useTlsVal, ok := jsonData["useTls"].(bool); ok { + node.UseTLS = useTlsVal + } + if certPathVal, ok := jsonData["certPath"].(string); ok { + node.CertPath = certPathVal + } + if keyPathVal, ok := jsonData["keyPath"].(string); ok { + node.KeyPath = keyPathVal + } + if insecureTlsVal, ok := jsonData["insecureTls"].(bool); ok { + node.InsecureTLS = insecureTlsVal + } } } else { // Parse as form data (default for web UI) @@ -163,6 +176,15 @@ func (a *NodeController) updateNode(c *gin.Context) { if apiKey := c.PostForm("apiKey"); apiKey != "" { node.ApiKey = apiKey } + // TLS settings + node.UseTLS = c.PostForm("useTls") == "true" || c.PostForm("useTls") == "on" + if certPath := c.PostForm("certPath"); certPath != "" { + node.CertPath = certPath + } + if keyPath := c.PostForm("keyPath"); keyPath != "" { + node.KeyPath = keyPath + } + node.InsecureTLS = c.PostForm("insecureTls") == "true" || c.PostForm("insecureTls") == "on" } // Validate API key if it was changed diff --git a/web/html/modals/node_modal.html b/web/html/modals/node_modal.html index 8822967d..a840bc52 100644 --- a/web/html/modals/node_modal.html +++ b/web/html/modals/node_modal.html @@ -1,232 +1,149 @@ {{define "modals/nodeModal"}} - - - + + + - - -
- {{ i18n "pages.nodes.fullUrlHint" }} -
+ + - - -
- {{ i18n "pages.nodes.apiKeyHint" }} -
+ + + + +
{{end}} diff --git a/web/html/nodes.html b/web/html/nodes.html index 2d2cedac..010a22ab 100644 --- a/web/html/nodes.html +++ b/web/html/nodes.html @@ -12,12 +12,7 @@

{{ i18n "pages.nodes.title" }}

-

{{ i18n "pages.nodes.addNewNode" }}

- - - - - {{ i18n "pages.nodes.addNode" }} + {{ i18n "pages.nodes.addNewNode" }}
@@ -109,6 +104,7 @@ {{template "component/aSidebar" .}} {{template "component/aThemeSwitch" .}} +{{template "modals/nodeModal"}} {{template "page/body_end" .}} diff --git a/web/service/node.go b/web/service/node.go index 01d773d9..c0a72203 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -3,10 +3,13 @@ package service import ( "bytes" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" "net/http" + "os" "strings" "time" @@ -70,6 +73,16 @@ func (s *NodeService) UpdateNode(node *model.Node) error { updates["api_key"] = node.ApiKey } + // Update TLS settings if provided + updates["use_tls"] = node.UseTLS + if node.CertPath != "" { + updates["cert_path"] = node.CertPath + } + if node.KeyPath != "" { + updates["key_path"] = node.KeyPath + } + updates["insecure_tls"] = node.InsecureTLS + // Update status and last_check if provided (these are usually set by health checks, not user edits) if node.Status != "" && node.Status != existingNode.Status { updates["status"] = node.Status @@ -117,10 +130,53 @@ func (s *NodeService) CheckNodeHealth(node *model.Node) error { return s.UpdateNode(node) } +// createHTTPClient creates an HTTP client configured for the node's TLS settings. +func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration) (*http.Client, error) { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: node.InsecureTLS, + }, + } + + // If custom certificates are provided, load them + if node.UseTLS && node.CertPath != "" { + // Load custom CA certificate + cert, err := os.ReadFile(node.CertPath) + if err != nil { + return nil, fmt.Errorf("failed to read certificate file: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(cert) { + return nil, fmt.Errorf("failed to parse certificate") + } + + transport.TLSClientConfig.RootCAs = caCertPool + transport.TLSClientConfig.InsecureSkipVerify = false // Use custom CA + } + + // If custom key is provided, load client certificate + if node.UseTLS && node.KeyPath != "" && node.CertPath != "" { + // Load client certificate (cert + key) + clientCert, err := tls.LoadX509KeyPair(node.CertPath, node.KeyPath) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + + transport.TLSClientConfig.Certificates = []tls.Certificate{clientCert} + } + + return &http.Client{ + Timeout: timeout, + Transport: transport, + }, nil +} + // CheckNodeStatus performs a health check on a given node. func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) { - client := &http.Client{ - Timeout: 5 * time.Second, + client, err := s.createHTTPClient(node, 5*time.Second) + if err != nil { + return "error", err } url := fmt.Sprintf("%s/health", node.Address) @@ -226,8 +282,9 @@ type NodeClientTraffic struct { // GetNodeStats retrieves traffic and online clients statistics from a node. func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) { - client := &http.Client{ - Timeout: 10 * time.Second, + client, err := s.createHTTPClient(node, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client: %w", err) } url := fmt.Sprintf("%s/api/v1/stats", node.Address) @@ -419,8 +476,9 @@ func (s *NodeService) UnassignInboundFromNode(inboundId int) error { // ApplyConfigToNode sends XRAY configuration to a node. func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error { - client := &http.Client{ - Timeout: 30 * time.Second, + client, err := s.createHTTPClient(node, 30*time.Second) + if err != nil { + return fmt.Errorf("failed to create HTTP client: %w", err) } url := fmt.Sprintf("%s/api/v1/apply-config", node.Address) @@ -448,8 +506,9 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err // ReloadNode reloads XRAY on a specific node. func (s *NodeService) ReloadNode(node *model.Node) error { - client := &http.Client{ - Timeout: 30 * time.Second, + client, err := s.createHTTPClient(node, 30*time.Second) + if err != nil { + return fmt.Errorf("failed to create HTTP client: %w", err) } url := fmt.Sprintf("%s/api/v1/reload", node.Address) @@ -476,8 +535,9 @@ func (s *NodeService) ReloadNode(node *model.Node) error { // ForceReloadNode forcefully reloads XRAY on a specific node (even if hung). func (s *NodeService) ForceReloadNode(node *model.Node) error { - client := &http.Client{ - Timeout: 30 * time.Second, + client, err := s.createHTTPClient(node, 30*time.Second) + if err != nil { + return fmt.Errorf("failed to create HTTP client: %w", err) } url := fmt.Sprintf("%s/api/v1/force-reload", node.Address) @@ -539,8 +599,9 @@ func (s *NodeService) ReloadAllNodes() error { // ValidateApiKey validates the API key by making a test request to the node. func (s *NodeService) ValidateApiKey(node *model.Node) error { - client := &http.Client{ - Timeout: 5 * time.Second, + client, err := s.createHTTPClient(node, 5*time.Second) + if err != nil { + return fmt.Errorf("failed to create HTTP client: %w", err) } // First, check if node is reachable via health endpoint @@ -593,8 +654,9 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error { // GetNodeStatus retrieves the status of a node. func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) { - client := &http.Client{ - Timeout: 5 * time.Second, + client, err := s.createHTTPClient(node, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client: %w", err) } url := fmt.Sprintf("%s/api/v1/status", node.Address) diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 237db735..d9c88c21 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -622,7 +622,7 @@ "validUrl" = "Must be a valid URL (http:// or https://)" "validPort" = "Port must be a number between 1 and 65535" "duplicateNode" = "A node with this address and port already exists" -"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100:8080)" +"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100 or domain)" "enterApiKey" = "Please enter API key" "apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)" "leaveEmptyToKeep" = "leave empty to keep current" @@ -644,6 +644,15 @@ "reloadError" = "Failed to reload node" "reloadAllSuccess" = "All nodes reloaded successfully" "reloadAllError" = "Failed to reload some nodes" +"tlsSettings" = "TLS/HTTPS Settings" +"useTls" = "Use TLS/HTTPS" +"useTlsHint" = "Enable TLS/HTTPS for API calls to this node" +"certPath" = "Certificate Path" +"certPathHint" = "Path to CA certificate file (optional, for custom CA)" +"keyPath" = "Private Key Path" +"keyPathHint" = "Path to private key file (optional, for client certificate)" +"insecureTls" = "Skip Certificate Verification" +"insecureTlsHint" = "⚠️ Not recommended: Skip TLS certificate verification (insecure)" [pages.nodes.toasts] "createSuccess" = "Node created successfully" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index bec5f449..e4bddc7e 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -622,7 +622,7 @@ "validUrl" = "Должен быть действительным URL (http:// или https://)" "validPort" = "Порт должен быть числом от 1 до 65535" "duplicateNode" = "Нода с таким адресом и портом уже существует" -"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)" +"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100 или домен)" "enterApiKey" = "Пожалуйста, введите API ключ" "apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)" "leaveEmptyToKeep" = "оставьте пустым чтобы не менять" @@ -644,6 +644,15 @@ "reloadError" = "Не удалось перезагрузить ноду" "reloadAllSuccess" = "Все ноды успешно перезагружены" "reloadAllError" = "Не удалось перезагрузить некоторые ноды" +"tlsSettings" = "Настройки TLS/HTTPS" +"useTls" = "Использовать TLS/HTTPS" +"useTlsHint" = "Включить TLS/HTTPS для API вызовов к этой ноде" +"certPath" = "Путь к сертификату" +"certPathHint" = "Путь к файлу сертификата CA (опционально, для кастомного CA)" +"keyPath" = "Путь к приватному ключу" +"keyPathHint" = "Путь к файлу приватного ключа (опционально, для клиентского сертификата)" +"insecureTls" = "Пропустить проверку сертификата" +"insecureTlsHint" = "⚠️ Не рекомендуется: пропустить проверку TLS сертификата (небезопасно)" [pages.nodes.toasts] "createSuccess" = "Нода успешно создана"