diff options
| author | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
|---|---|---|
| committer | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
| commit | 3d51aa455006903345f554a2dd90034993796114 (patch) | |
| tree | 62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/control/publish.go | |
| download | vpnem-main.tar.gz vpnem-main.tar.bz2 vpnem-main.zip | |
- Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5)
- Smart load balancing via recommendation API
- Windows/Linux client (Go + Wails + sing-box)
- Server API with RealIP detection and connection tracking
- Auto-deployment via vpnui control plane
- Silent Windows installer with UAC elevation
- Load-based server recommendation (no sticky sessions)
- Best Server one-click connection workflow
Diffstat (limited to 'internal/control/publish.go')
| -rw-r--r-- | internal/control/publish.go | 321 |
1 files changed, 321 insertions, 0 deletions
diff --git a/internal/control/publish.go b/internal/control/publish.go new file mode 100644 index 0000000..d05e98b --- /dev/null +++ b/internal/control/publish.go @@ -0,0 +1,321 @@ +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) +} |
