package render import ( "encoding/json" "strings" "github.com/peterqiu0516/sub-store/internal/model" "github.com/peterqiu0516/sub-store/internal/util" ) // RenderSingBoxWithTemplate renders proxies as a sing-box JSON config, // applying the Clash-style routing template (proxy-groups, rules, DNS, rule-providers). // If template is nil or empty, falls back to the default sing-box config (no template). func RenderSingBoxWithTemplate(proxies []model.ProxyNode, template map[string]any) string { if template == nil || len(template) == 0 { return RenderSingBoxJson(proxies) } // Parse proxies into node outbounds and WireGuard endpoints var nodeOutbounds []map[string]any var wireGuardEndpoints []map[string]any for _, p := range proxies { if wg := ToSingBoxWireGuardEndpoint(p); wg != nil { wireGuardEndpoints = append(wireGuardEndpoints, wg) continue } if out := ToSingBoxOutbound(p); out != nil { nodeOutbounds = append(nodeOutbounds, out) } } // Collect node tags nodeTags := make([]string, 0, len(nodeOutbounds)) nodeTagSet := make(map[string]bool) for _, out := range nodeOutbounds { if t, ok := out["tag"].(string); ok { nodeTags = append(nodeTags, t) nodeTagSet[t] = true } } // Build outbounds from template proxy-groups groupOutbounds, groupNames := buildSingBoxOutboundsFromTemplate(template, nodeTags) allOutbounds := append([]any{}, groupOutbounds...) for _, out := range nodeOutbounds { allOutbounds = append(allOutbounds, out) } // Always add DIRECT and REJECT (block) outbounds. // REJECT is kept as a block-type outbound because Clash proxy-groups reference it // as a member (e.g. "🛑 全球拦截" → ["REJECT", "DIRECT"]). // While sing-box 1.11+ deprecated block as a route rule action, a block outbound // is still valid when referenced directly by selector/urltest groups. allOutbounds = append(allOutbounds, map[string]any{"type": "direct", "tag": "DIRECT"}, map[string]any{"type": "block", "tag": "REJECT"}, ) // Determine final outbound finalOutbound := "PROXY" if len(groupNames) > 0 { finalOutbound = groupNames[0] } // Build route rules from template rules, skipping rules that reference // skipped rule-sets (those with empty .srs mappings). skippedRuleSets := getSkippedRuleSets(template) routeRules := buildSingBoxRouteRules(template, groupNames, nodeTagSet, skippedRuleSets) // Always add sniff action first allRules := []any{ map[string]any{"action": "sniff"}, } allRules = append(allRules, routeRules...) // Build the document mixedPort := firstInt(template, "mixedPort", "mixed-port") if mixedPort == 0 { mixedPort = 7890 } logLevel := firstStr(template, "logLevel", "log-level") if logLevel == "" { logLevel = "info" } doc := map[string]any{ "log": map[string]any{"level": logLevel}, "inbounds": []any{ map[string]any{ "type": "mixed", "tag": "mixed-in", "listen": "0.0.0.0", "listen_port": mixedPort, }, }, "outbounds": allOutbounds, "route": map[string]any{ "auto_detect_interface": true, "final": finalOutbound, "rules": allRules, "default_domain_resolver": map[string]any{"server": "local-dns"}, }, } // Add WireGuard endpoints if len(wireGuardEndpoints) > 0 { doc["endpoints"] = wireGuardEndpoints } // Add DNS if configured if dnsCfg := buildSingBoxDNS(template); dnsCfg != nil { doc["dns"] = dnsCfg } // Add rule_set if rule-providers configured ruleSets := buildSingBoxRuleSets(template) // Also add auto-generated rule_sets for GEOIP/GEOSITE rules geoRuleSets := collectGeoRuleSets(template) allRuleSets := append(ruleSets, geoRuleSets...) if len(allRuleSets) > 0 { route := doc["route"].(map[string]any) route["rule_set"] = allRuleSets } data, _ := jsonMarshalIndent(doc) return data } // buildSingBoxOutboundsFromTemplate converts Clash proxy-groups to sing-box outbounds. func buildSingBoxOutboundsFromTemplate(template map[string]any, nodeTags []string) ([]any, []string) { groups := extractGroupTemplates(template) if len(groups) == 0 { // Fallback: default PROXY + AUTO proxyOutbounds := append([]string{"AUTO"}, nodeTags...) return []any{ map[string]any{ "type": "selector", "tag": "PROXY", "outbounds": proxyOutbounds, "default": "AUTO", "interrupt_exist_connections": false, }, map[string]any{ "type": "urltest", "tag": "AUTO", "outbounds": nodeTags, "url": model.TestURL, "interval": "5m", "tolerance": 50, "interrupt_exist_connections": false, }, }, []string{"PROXY", "AUTO"} } // Expand group proxies and build sing-box outbounds expandedGroups := expandGroupsForSingBox(groups, nodeTags) var outbounds []any var groupNames []string groupNameSet := make(map[string]bool) for _, g := range expandedGroups { if len(g.proxies) == 0 { continue } name := getString(g.template, "name") groupNames = append(groupNames, name) groupNameSet[name] = true clashType := getString(g.template, "type") outbound := buildSingBoxGroupOutbound(name, clashType, g.proxies, g.template, groupNameSet) if outbound != nil { outbounds = append(outbounds, outbound) } } return outbounds, groupNames } type expandedGroup struct { template map[string]any proxies []string } // expandGroupsForSingBox expands $all and filter regex for each group, // then filters references to non-existent nodes/groups. func expandGroupsForSingBox(groups []map[string]any, nodeTags []string) []expandedGroup { // First pass: expand all groups var expanded []expandedGroup for _, group := range groups { expanded = append(expanded, expandedGroup{ template: group, proxies: ExpandGroupProxies(group, nodeTags), }) } // Collect all valid group names groupNames := make(map[string]bool) for _, g := range expanded { if len(g.proxies) > 0 { groupNames[getString(g.template, "name")] = true } } // Second pass: filter references allowedLiterals := map[string]bool{"DIRECT": true, "REJECT": true, "PASS": true} nodeSet := make(map[string]bool) for _, t := range nodeTags { nodeSet[t] = true } for i := range expanded { var filtered []string seen := make(map[string]bool) for _, name := range expanded[i].proxies { if seen[name] { continue } seen[name] = true if nodeSet[name] || groupNames[name] || allowedLiterals[name] { filtered = append(filtered, name) } } expanded[i].proxies = filtered } return expanded } // buildSingBoxGroupOutbound converts a single Clash proxy-group to a sing-box outbound. func buildSingBoxGroupOutbound(name, clashType string, proxies []string, template map[string]any, groupNames map[string]bool) map[string]any { // Map DIRECT/REJECT to sing-box equivalents // sing-box uses "direct" outbound tag and route rule "reject" action sbProxies := make([]string, 0, len(proxies)) for _, p := range proxies { sbProxies = append(sbProxies, p) } switch clashType { case "select": return util.StripUndefined(map[string]any{ "type": "selector", "tag": name, "outbounds": sbProxies, "interrupt_exist_connections": false, }) case "url-test": interval := getString(template, "interval") if interval == "" { interval = "5m" } // Clash interval is in seconds; convert to sing-box duration string if n := toInt(interval); n > 0 { interval = formatDuration(n) } tolerance := toInt(getString(template, "tolerance")) if tolerance == 0 { tolerance = 50 } return util.StripUndefined(map[string]any{ "type": "urltest", "tag": name, "outbounds": sbProxies, "url": strOr(template, "url", model.TestURL), "interval": interval, "tolerance": tolerance, "interrupt_exist_connections": false, }) case "fallback": // sing-box has no fallback type; use urltest as closest equivalent interval := getString(template, "interval") if interval == "" { interval = "5m" } if n := toInt(interval); n > 0 { interval = formatDuration(n) } return util.StripUndefined(map[string]any{ "type": "urltest", "tag": name, "outbounds": sbProxies, "url": strOr(template, "url", model.TestURL), "interval": interval, "tolerance": 50, "interrupt_exist_connections": false, }) case "load-balance": // sing-box has no load-balance type; use selector as closest equivalent strategy := getString(template, "strategy") _ = strategy // not directly mappable return util.StripUndefined(map[string]any{ "type": "selector", "tag": name, "outbounds": sbProxies, "interrupt_exist_connections": false, }) default: // Unknown type: default to selector return util.StripUndefined(map[string]any{ "type": "selector", "tag": name, "outbounds": sbProxies, "interrupt_exist_connections": false, }) } } // buildSingBoxRouteRules converts Clash rules to sing-box route rules. // Rules referencing skipped rule-sets (no sing-box equivalent) are skipped. func buildSingBoxRouteRules(template map[string]any, groupNames []string, nodeTagSet map[string]bool, skippedRuleSets map[string]bool) []any { rules, _ := template["rules"].([]any) if len(rules) == 0 { return []any{ map[string]any{"outbound": "PROXY"}, } } groupSet := make(map[string]bool) for _, g := range groupNames { groupSet[g] = true } var result []any for _, r := range rules { ruleStr, ok := r.(string) if !ok || ruleStr == "" { continue } // Skip RULE-SET rules that reference skipped rule-sets parts := strings.Split(ruleStr, ",") if len(parts) >= 2 && strings.TrimSpace(parts[0]) == "RULE-SET" { rsName := strings.TrimSpace(parts[1]) if skippedRuleSets[rsName] { continue } } sbRule := convertClashRuleToSingBox(ruleStr, groupSet, nodeTagSet) if sbRule != nil { result = append(result, sbRule) } } return result } // getSkippedRuleSets returns the set of rule-provider names that have empty // mappings in clashRuleSetMappings (i.e., no sing-box equivalent exists). func getSkippedRuleSets(template map[string]any) map[string]bool { skipped := make(map[string]bool) rpRaw, ok := template["ruleProviders"] if !ok { rpRaw, ok = template["rule-providers"] } if !ok || rpRaw == nil { return skipped } rp, ok := rpRaw.(map[string]any) if !ok { return skipped } for name := range rp { if srsURL, mapped := clashRuleSetMappings[name]; mapped && srsURL == "" { skipped[name] = true } } return skipped } // collectGeoRuleSets scans Clash rules for GEOIP/GEOSITE references and returns // auto-generated rule_set definitions pointing to SagerNet's sing-geoip/sing-geosite repos. func collectGeoRuleSets(template map[string]any) []any { rules, _ := template["rules"].([]any) seen := make(map[string]bool) var result []any for _, r := range rules { ruleStr, ok := r.(string) if !ok { continue } parts := strings.Split(ruleStr, ",") if len(parts) < 2 { continue } ruleType := strings.TrimSpace(parts[0]) code := strings.ToLower(strings.TrimSpace(parts[1])) var tag, url string switch ruleType { case "GEOIP": tag = "geoip-" + code url = "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-" + code + ".srs" case "GEOSITE": tag = "geosite-" + code url = "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-" + code + ".srs" default: continue } if seen[tag] { continue } seen[tag] = true result = append(result, map[string]any{ "tag": tag, "type": "remote", "format": "binary", "url": url, "download_detour": "DIRECT", }) } return result } // convertClashRuleToSingBox converts a single Clash rule line to a sing-box route rule. func convertClashRuleToSingBox(rule string, groupSet, nodeSet map[string]bool) map[string]any { parts := strings.Split(rule, ",") if len(parts) < 2 { return nil } ruleType := strings.TrimSpace(parts[0]) // Determine the outbound (policy) var policy string if ruleType == "MATCH" { if len(parts) >= 2 { policy = strings.TrimSpace(parts[1]) } } else { if len(parts) >= 3 { policy = strings.TrimSpace(parts[2]) } } // Map policy to sing-box outbound outbound := mapPolicyToSingBox(policy, groupSet, nodeSet) switch ruleType { case "DOMAIN": return map[string]any{"domain": []string{parts[1]}, "outbound": outbound} case "DOMAIN-SUFFIX": return map[string]any{"domain_suffix": []string{parts[1]}, "outbound": outbound} case "DOMAIN-KEYWORD": return map[string]any{"domain_keyword": []string{parts[1]}, "outbound": outbound} case "IP-CIDR", "IP-CIDR6": opts := []string{} for i := 3; i < len(parts); i++ { opt := strings.TrimSpace(parts[i]) if opt == "no-resolve" { opts = append(opts, "no_resolve") } } rule := map[string]any{"ip_cidr": []string{parts[1]}, "outbound": outbound} if len(opts) > 0 { rule["ip_cidr_no_resolve"] = true } return rule case "GEOIP": geoip := strings.TrimSpace(parts[1]) // sing-box 1.12+ removed legacy geoip database; use rule_set instead. // Auto-map common geoip codes to remote rule_sets. return map[string]any{"rule_set": "geoip-" + strings.ToLower(geoip), "outbound": outbound} case "GEOSITE": geosite := strings.TrimSpace(parts[1]) // sing-box 1.12+ removed legacy geosite database; use rule_set instead. return map[string]any{"rule_set": "geosite-" + strings.ToLower(geosite), "outbound": outbound} case "PROCESS-NAME": return map[string]any{"process_name": []string{parts[1]}, "outbound": outbound} case "DST-PORT": return map[string]any{"port": parts[1], "outbound": outbound} case "SRC-PORT": return map[string]any{"source_port": parts[1], "outbound": outbound} case "RULE-SET": // RULE-SET,provider-name,policy → rule_set + outbound return map[string]any{"rule_set": parts[1], "outbound": outbound} case "MATCH": // MATCH is the final catch-all; handled by route.final return nil default: return nil } } // mapPolicyToSingBox maps a Clash policy name to the sing-box outbound tag. func mapPolicyToSingBox(policy string, groupSet, nodeSet map[string]bool) string { switch policy { case "DIRECT": return "DIRECT" case "REJECT": return "REJECT" case "PASS": return "DIRECT" case "": return "PROXY" default: // It's a group name or node name if groupSet[policy] || nodeSet[policy] { return policy } return "PROXY" } } // buildSingBoxDNS converts Clash DNS config to sing-box DNS config. func buildSingBoxDNS(template map[string]any) map[string]any { dnsRaw, ok := template["dns"] if !ok || dnsRaw == nil { return nil } dns, ok := dnsRaw.(map[string]any) if !ok { return nil } // Build sing-box DNS servers from nameserver list var servers []any if nameservers, ok := dns["nameserver"].([]any); ok { for _, ns := range nameservers { nsStr, ok := ns.(string) if !ok || nsStr == "" { continue } servers = append(servers, convertDNSServer(nsStr)) } } if len(servers) == 0 { return nil } // sing-box 1.12+ requires a domain resolver for DNS servers that use domain addresses. // Add a local UDP resolver as the first server to resolve other servers' domain names. localServer := map[string]any{"type": "local", "tag": "local-dns"} servers = append([]any{localServer}, servers...) // Add domain_resolver to each non-local server so they can resolve their own domain address for i, s := range servers { sm, ok := s.(map[string]any) if !ok { continue } stype, _ := sm["type"].(string) if stype == "local" || stype == "fakeip" { continue } sm["domain_resolver"] = "local-dns" servers[i] = sm } result := map[string]any{ "servers": servers, } // Set the local resolver as the default domain resolver result["final"] = "local-dns" // Handle fake-ip mode (sing-box 1.12+ format) if mode := getString(dns, "enhanced-mode"); mode == "fake-ip" { // Add a fakeip DNS server as the last server fakeipServer := map[string]any{ "type": "fakeip", "tag": "fakeip", } servers = append(servers, fakeipServer) // Add DNS rule to route A/AAAA queries to fakeip rules := []any{ map[string]any{ "query_type": []string{"A", "AAAA"}, "server": "fakeip", }, } result["rules"] = rules } // Handle IPv6 setting if ipv6, ok := dns["ipv6"].(bool); ok && !ipv6 { result["strategy"] = "prefer_ipv4" } return result } // convertDNSServer converts a Clash nameserver URL to a sing-box DNS server. func convertDNSServer(ns string) map[string]any { // Clash formats: "https://doh.pub/dns-query", "https://dns.alidns.com/dns-query", // "tls://8.8.8.8", "quic://dns.adguard.com", "system", "localhost" switch { case strings.HasPrefix(ns, "https://"): return map[string]any{"type": "https", "server": extractHost(ns, "https://")} case strings.HasPrefix(ns, "tls://"): return map[string]any{"type": "tls", "server": strings.TrimPrefix(ns, "tls://")} case strings.HasPrefix(ns, "quic://"): return map[string]any{"type": "quic", "server": strings.TrimPrefix(ns, "quic://")} case strings.HasPrefix(ns, "h3://"): return map[string]any{"type": "h3", "server": strings.TrimPrefix(ns, "h3://")} case strings.HasPrefix(ns, "tcp://"): return map[string]any{"type": "tcp", "server": strings.TrimPrefix(ns, "tcp://")} case ns == "system": return map[string]any{"type": "local"} case ns == "localhost": return map[string]any{"type": "local"} default: // Assume it's a plain IP/UDP server return map[string]any{"type": "udp", "server": ns} } } // clashRuleSetMappings maps common Clash rule-provider names to sing-box // community-maintained .srs binary rule-set URLs. // When a Clash rule-provider name has a known sing-box equivalent, // the .srs URL is used instead of the Clash .list/.yaml URL. var clashRuleSetMappings = map[string]string{ // ACL4SSR rule names → SagerNet sing-geosite/sing-geoip "LocalAreaNetwork": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs", "UnBan": "", "BanAD": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", "BanProgramAD": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", "GoogleCN": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs", "SteamCN": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-steam.srs", "Microsoft": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-microsoft.srs", "Apple": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs", "Telegram": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-telegram.srs", "YouTube": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-youtube.srs", "Netflix": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-netflix.srs", "DisneyPlus": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-disney.srs", "ProxyGFWlist": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs", "ChinaDomain": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", "ChinaCompanyIp": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", "ChinaIp": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", "Download": "", // Loyalsoldier rule names → SagerNet sing-geosite/sing-geoip "reject": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", "icloud": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-icloud.srs", "apple": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs", "google": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs", "proxy": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs", "direct": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", "private": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs", "gfw": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-gfw.srs", "greatfire": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-greatfire.srs", "tld-not-cn": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-tld-!cn.srs", "telegramcidr": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-telegram.srs", "cncidr": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", "lancidr": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs", "applications": "", // blackmatrix7 rule names → SagerNet sing-geosite "OpenAI": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-openai.srs", "Claude": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-anthropic.srs", "Gemini": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs", "Disney": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-disney.srs", "Spotify": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-spotify.srs", "GitHub": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-github.srs", "China": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", // Common service names "Google": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs", "Twitter": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-twitter.srs", "Facebook": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-facebook.srs", "TikTok": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-tiktok.srs", "Bilibili": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-bilibili.srs", "PayPal": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-paypal.srs", } // buildSingBoxRuleSets converts Clash rule-providers to sing-box rule_set. // When a Clash rule-provider name has a known sing-box .srs equivalent in // clashRuleSetMappings, the binary .srs URL is used. Otherwise the original // Clash URL is kept with source format (may not work for all Clash-format files). func buildSingBoxRuleSets(template map[string]any) []any { rpRaw, ok := template["ruleProviders"] if !ok { rpRaw, ok = template["rule-providers"] } if !ok || rpRaw == nil { return nil } rp, ok := rpRaw.(map[string]any) if !ok { return nil } var result []any for name, cfgRaw := range rp { cfg, ok := cfgRaw.(map[string]any) if !ok { continue } // Check if this rule-provider name has a sing-box .srs mapping if srsURL, mapped := clashRuleSetMappings[name]; mapped { if srsURL == "" { // Empty mapping = skip this rule-set (no sing-box equivalent) continue } rs := map[string]any{ "tag": name, "type": "remote", "format": "binary", "url": srsURL, "download_detour": "DIRECT", } if interval := toInt(getString(cfg, "interval")); interval > 0 { rs["update_interval"] = formatDuration(interval) } result = append(result, rs) continue } // Fallback: use the original Clash URL with source format url := getString(cfg, "url") if url == "" { continue } rs := map[string]any{ "tag": name, "type": "remote", "format": "source", "url": url, "download_detour": "DIRECT", } if interval := toInt(getString(cfg, "interval")); interval > 0 { rs["update_interval"] = formatDuration(interval) } result = append(result, rs) } return result } // --- helpers --- func toInt(s string) int { n := 0 for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return 0 } n = n*10 + int(s[i]-'0') } return n } func formatDuration(seconds int) string { if seconds <= 0 { return "5m" } if seconds%3600 == 0 { return itoa(seconds/3600) + "h" } if seconds%60 == 0 { return itoa(seconds/60) + "m" } return itoa(seconds) + "s" } func extractHost(url, prefix string) string { s := strings.TrimPrefix(url, prefix) // Remove path if idx := strings.Index(s, "/"); idx >= 0 { s = s[:idx] } return s } func jsonMarshalIndent(v any) (string, error) { data, err := json.MarshalIndent(v, "", " ") return string(data), err }