summaryrefslogtreecommitdiff
path: root/internal/control/publish.go
diff options
context:
space:
mode:
authorsergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
committersergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
commit3d51aa455006903345f554a2dd90034993796114 (patch)
tree62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/control/publish.go
downloadvpnem-3d51aa455006903345f554a2dd90034993796114.tar.gz
vpnem-3d51aa455006903345f554a2dd90034993796114.tar.bz2
vpnem-3d51aa455006903345f554a2dd90034993796114.zip
vpnem: VPN infrastructure with load-balanced multi-protocol nodesHEADmain
- 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.go321
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)
+}