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 }