package control import ( "context" "encoding/json" "os" "path/filepath" "strconv" "vpnem/internal/models" ) type PublishDecision struct { NodeID string `json:"node_id"` Eligible bool `json:"eligible"` Reasons []string `json:"reasons,omitempty"` PublicHost string `json:"public_host,omitempty"` Status string `json:"status,omitempty"` } func PublishableNodes(nodes []Node, states map[string]*NodeState) []Node { filtered := make([]Node, 0, len(nodes)) for _, node := range nodes { if PublishDecisionForNode(node, states[node.ID]).Eligible { filtered = append(filtered, node) } } return filtered } func NodeStateReadyForPublish(state NodeState) bool { if state.BootstrapStatus != "healthy" && state.BootstrapStatus != "ready" { return false } if code, ok := state.Metadata["healthz_http_code"]; ok { switch v := code.(type) { case int: if v != 200 { return false } case float64: if int(v) != 200 { return false } } } if len(state.Services) == 0 { return true } for _, service := range state.Services { if service.Status != "running" { return false } } return true } func PublishDecisionForNode(node Node, state *NodeState) PublishDecision { decision := PublishDecision{ NodeID: node.ID, Eligible: false, PublicHost: publicHost(node), } if !node.Enabled { decision.Reasons = append(decision.Reasons, "узел выключен") return decision } if state == nil { decision.Reasons = append(decision.Reasons, "нет сохранённого состояния узла") return decision } decision.Status = state.BootstrapStatus if state.PublicHost != "" { decision.PublicHost = state.PublicHost } if state.BootstrapStatus != "healthy" && state.BootstrapStatus != "ready" { decision.Reasons = append(decision.Reasons, "статус bootstrap: "+state.BootstrapStatus) return decision } if code, ok := state.Metadata["healthz_http_code"]; ok { switch v := code.(type) { case int: if v != 200 { decision.Reasons = append(decision.Reasons, "healthz_http_code: "+itoa(v)) } case float64: if int(v) != 200 { decision.Reasons = append(decision.Reasons, "healthz_http_code: "+itoa(int(v))) } } } for _, service := range state.Services { if service.Status != "running" { decision.Reasons = append(decision.Reasons, "сервис "+service.Type+" имеет статус "+service.Status) } } decision.Eligible = len(decision.Reasons) == 0 return decision } func PublishDecisions(nodes []Node, states map[string]*NodeState) map[string]PublishDecision { decisions := make(map[string]PublishDecision, len(nodes)) for _, node := range nodes { decisions[node.ID] = PublishDecisionForNode(node, states[node.ID]) } return decisions } func itoa(v int) string { return strconv.Itoa(v) } func BuildCatalogV2(nodes []Node, states map[string]*NodeState) *models.CatalogV2 { result := &models.CatalogV2{ Version: "2", Nodes: make([]models.CatalogNode, 0, len(nodes)), } for _, node := range nodes { publicHost := node.Host if state := states[node.ID]; state != nil && state.PublicHost != "" { publicHost = state.PublicHost } else if node.Domain != "" { publicHost = node.Domain } catalogNode := models.CatalogNode{ ID: node.ID, Name: node.Name, Provider: node.Provider, Region: node.Region, Host: node.Host, Domain: node.Domain, PublicHost: publicHost, Tags: node.Tags, Metadata: map[string]any{}, Protocols: make([]models.CatalogProtocol, 0, len(node.Protocols)), } if state := states[node.ID]; state != nil { catalogNode.Status = state.BootstrapStatus for k, v := range state.Metadata { catalogNode.Metadata[k] = v } } for _, protocol := range node.Protocols { if !protocol.Enabled { continue } if err := ensureRealityProfile(&protocol); err != nil { continue } item := models.CatalogProtocol{ Type: protocol.Type, Enabled: protocol.Enabled, Port: protocol.Port, Extra: protocol.Extra, } if protocol.TLS != nil { item.TLS = &models.TLS{ Enabled: protocol.TLS.Enabled, ServerName: protocol.TLS.ServerName, Insecure: false, } } if protocol.Type == "vless-reality" && protocol.Reality != nil { item.TLS = &models.TLS{ Enabled: true, ServerName: protocol.Reality.ServerName, Reality: &models.Reality{ Enabled: true, PublicKey: protocol.Reality.PublicKey, ShortID: protocol.Reality.ShortID, Fingerprint: protocol.Reality.Fingerprint, }, } } if protocol.Type == "hysteria2" { if item.TLS == nil { item.TLS = &models.TLS{} } item.TLS.Enabled = true item.TLS.Insecure = true if len(item.TLS.ALPN) == 0 { item.TLS.ALPN = []string{defaultHysteria2ALPN} } if item.TLS.MinVersion == "" { item.TLS.MinVersion = "1.3" } if item.TLS.MaxVersion == "" { item.TLS.MaxVersion = "1.3" } } if protocol.Auth != nil { item.Auth = &models.CatalogAuth{ UUID: protocol.Auth.UUID, Method: protocol.Auth.Method, Password: protocol.Auth.Password, } } catalogNode.Protocols = append(catalogNode.Protocols, item) } result.Nodes = append(result.Nodes, catalogNode) } return result } func WriteCatalogV2(path string, nodes []Node, states map[string]*NodeState) error { catalog := BuildCatalogV2(nodes, states) data, err := json.MarshalIndent(catalog, "", " ") if err != nil { return err } data = append(data, '\n') if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, data, 0o644); err != nil { return err } return os.Rename(tmpPath, path) } func StaticCatalogNodesFromLegacy(servers []models.Server) []models.CatalogNode { nodes := make([]models.CatalogNode, 0, len(servers)) for _, server := range servers { node := models.CatalogNode{ ID: server.Tag, Name: server.Tag, Region: server.Region, Host: server.Server, PublicHost: server.Server, Status: "static", Metadata: map[string]any{ "static_legacy": true, }, Protocols: []models.CatalogProtocol{ { Type: server.Type, Enabled: true, Port: server.ServerPort, Auth: &models.CatalogAuth{ UUID: server.UUID, Method: server.Method, Password: server.Password, }, TLS: server.TLS, Extra: map[string]any{}, }, }, } if server.UDPOverTCP { node.Protocols[0].Extra["udp_over_tcp"] = true } if server.ObfsPassword != "" { node.Protocols[0].Extra["obfs_password"] = server.ObfsPassword } if server.UpMbps > 0 { node.Protocols[0].Extra["up_mbps"] = server.UpMbps } if server.DownMbps > 0 { node.Protocols[0].Extra["down_mbps"] = server.DownMbps } if server.Transport != nil { node.Protocols[0].Extra["transport_type"] = server.Transport.Type if server.Transport.Path != "" { node.Protocols[0].Extra["path"] = server.Transport.Path } } nodes = append(nodes, node) } return nodes } func MergeCatalogNodes(primary, secondary []models.CatalogNode) []models.CatalogNode { merged := make([]models.CatalogNode, 0, len(primary)+len(secondary)) seen := make(map[string]struct{}, len(primary)+len(secondary)) for _, item := range primary { if _, ok := seen[item.ID]; ok { continue } seen[item.ID] = struct{}{} merged = append(merged, item) } for _, item := range secondary { if _, ok := seen[item.ID]; ok { continue } seen[item.ID] = struct{}{} merged = append(merged, item) } return merged } func PublishLegacyCatalog(ctx context.Context, nodes []Node, targetPath string, remoteNode *Node) error { if remoteNode == nil { return WriteLegacyCatalog(targetPath, nodes) } tmpDir, err := os.MkdirTemp("", "vpnem-publish-*") if err != nil { return err } defer os.RemoveAll(tmpDir) localPath := filepath.Join(tmpDir, "servers.json") if err := WriteLegacyCatalog(localPath, nodes); err != nil { return err } return CopyFileOverSCP(ctx, *remoteNode, localPath, targetPath) }