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/catalog.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/catalog.go')
| -rw-r--r-- | internal/control/catalog.go | 229 |
1 files changed, 229 insertions, 0 deletions
diff --git a/internal/control/catalog.go b/internal/control/catalog.go new file mode 100644 index 0000000..9ef3c35 --- /dev/null +++ b/internal/control/catalog.go @@ -0,0 +1,229 @@ +package control + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "vpnem/internal/models" +) + +func BuildLegacyCatalog(nodes []Node) (*models.ServersResponse, error) { + servers := make([]models.Server, 0) + + for _, node := range nodes { + if !node.Enabled { + continue + } + + publicHost := node.Host + if strings.TrimSpace(node.Domain) != "" { + publicHost = node.Domain + } + + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + if err := ensureRealityProfile(&protocol); err != nil { + return nil, err + } + + server, err := legacyServerFromNode(node, publicHost, protocol) + if err != nil { + return nil, err + } + servers = append(servers, server) + } + } + + sort.Slice(servers, func(i, j int) bool { + return servers[i].Tag < servers[j].Tag + }) + + return &models.ServersResponse{Servers: servers}, nil +} + +func WriteLegacyCatalog(path string, nodes []Node) error { + resp, err := BuildLegacyCatalog(nodes) + if err != nil { + return err + } + staticResp, err := LoadStaticLegacyCatalog(filepath.Join(filepath.Dir(path), "static-servers.json")) + if err != nil { + return err + } + resp.Servers = MergeLegacyServers(staticResp.Servers, resp.Servers) + + data, err := json.MarshalIndent(resp, "", " ") + 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 LoadStaticLegacyCatalog(path string) (*models.ServersResponse, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &models.ServersResponse{Servers: nil}, nil + } + return nil, err + } + + var resp models.ServersResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func MergeLegacyServers(primary, secondary []models.Server) []models.Server { + merged := make([]models.Server, 0, len(primary)+len(secondary)) + seen := make(map[string]struct{}, len(primary)+len(secondary)) + for _, item := range primary { + if _, ok := seen[item.Tag]; ok { + continue + } + seen[item.Tag] = struct{}{} + merged = append(merged, item) + } + for _, item := range secondary { + if _, ok := seen[item.Tag]; ok { + continue + } + seen[item.Tag] = struct{}{} + merged = append(merged, item) + } + sort.Slice(merged, func(i, j int) bool { + return merged[i].Tag < merged[j].Tag + }) + return merged +} + +func legacyServerFromNode(node Node, publicHost string, protocol ProtocolProfile) (models.Server, error) { + switch protocol.Type { + case "socks", "socks5": + return models.Server{ + Tag: node.ID + "-socks5", + Region: node.Region, + Type: "socks", + Server: publicHost, + ServerPort: protocol.Port, + }, nil + case "vless": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return models.Server{}, fmt.Errorf("node %s protocol vless requires auth.uuid", node.ID) + } + server := models.Server{ + Tag: node.ID + "-vless", + Region: node.Region, + Type: "vless", + Server: publicHost, + ServerPort: protocol.Port, + UUID: protocol.Auth.UUID, + } + if protocol.TLS != nil { + server.TLS = &models.TLS{ + Enabled: protocol.TLS.Enabled, + ServerName: protocol.TLS.ServerName, + Insecure: false, + } + } + if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" { + server.Transport = &models.Transport{ + Type: transportType, + Path: stringFromExtra(protocol.Extra, "path"), + } + } + return server, nil + case "vless-reality": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return models.Server{}, fmt.Errorf("node %s protocol vless-reality requires auth.uuid", node.ID) + } + if protocol.Reality == nil { + return models.Server{}, fmt.Errorf("node %s protocol vless-reality requires reality settings", node.ID) + } + server := models.Server{ + Tag: node.ID + "-vless-reality", + Region: node.Region, + Type: "vless-reality", + Server: publicHost, + ServerPort: protocol.Port, + UUID: protocol.Auth.UUID, + 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, + }, + }, + } + return server, nil + case "shadowsocks": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" { + return models.Server{}, fmt.Errorf("node %s protocol shadowsocks requires auth.method and auth.password", node.ID) + } + return models.Server{ + Tag: node.ID + "-shadowsocks", + Region: node.Region, + Type: "shadowsocks", + Server: publicHost, + ServerPort: protocol.Port, + Method: protocol.Auth.Method, + Password: protocol.Auth.Password, + }, nil + case "hysteria2": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { + return models.Server{}, fmt.Errorf("node %s protocol hysteria2 requires auth.password", node.ID) + } + server := models.Server{ + Tag: node.ID + "-hysteria2", + Region: node.Region, + Type: "hysteria2", + Server: publicHost, + ServerPort: protocol.Port, + Password: protocol.Auth.Password, + ObfsPassword: stringFromExtra(protocol.Extra, "obfs_password"), + UpMbps: intFromExtra(protocol.Extra, "up_mbps", 0), + DownMbps: intFromExtra(protocol.Extra, "down_mbps", 0), + TLS: &models.TLS{ + Enabled: true, + Insecure: true, + ServerName: "", + ALPN: []string{defaultHysteria2ALPN}, + MinVersion: "1.3", + MaxVersion: "1.3", + }, + } + if protocol.TLS != nil && protocol.TLS.ServerName != "" { + server.TLS.ServerName = protocol.TLS.ServerName + } + return server, nil + default: + return models.Server{}, fmt.Errorf("node %s uses unsupported legacy protocol %q", node.ID, protocol.Type) + } +} + +func stringFromExtra(extra map[string]any, key string) string { + if extra == nil { + return "" + } + value, _ := extra[key].(string) + return value +} |
