feat(sub): opt-in dns and sniffer from remote Clash rules (#6868)

Public Mihomo templates ship rules, rule-providers and proxy-groups together
with a dns block those rules depend on (fake-ip ranges, nameserver policy).
Pasting a template inline keeps its dns, but pointing the global routing rules
at its URL dropped it, so URL users had to give up auto-refresh to keep it.

A new switch, subClashRemoteDNS (off by default), also takes the remote dns and
sniffer maps. It is opt-in because a remote dns block decides where clients send
every lookup. A malformed dns or sniffer value is skipped, not the whole template.
The component-test teardown now outlasts AntD Form's 10ms error debounce, which
otherwise fired after jsdom was gone.
This commit is contained in:
Duxxie
2026-10-24 05:21:04 +03:00
committed by GitHub
parent 3acdfd9dea
commit c3394fefed
44 changed files with 355 additions and 46 deletions
@@ -39,7 +39,7 @@ func TestClashExternalShadowsocksMatchesInboundPath(t *testing.T) {
},
}
svc := NewSubClashService(false, "", &SubService{})
svc := NewSubClashService(false, "", false, &SubService{})
client := model.Client{Password: "clientpw", Email: "user"}
for _, tc := range tests {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
const clashExternalVlessLink = "vless://22222222-2222-4222-8222-222222222222@198.51.100.9:443?type=tcp&security=reality&sni=example.com&pbk=test-public-key&sid=ab12&fp=chrome&flow=xtls-rprx-vision"
func TestClashExternalVlessEncryption(t *testing.T) {
svc := NewSubClashService(false, "", &SubService{})
svc := NewSubClashService(false, "", false, &SubService{})
for _, tc := range []struct {
name string
query string
+4 -4
View File
@@ -56,7 +56,7 @@ func TestSubClash_InfoNode_Active(t *testing.T) {
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
sub.subInfoNodeEnable = true
clash := NewSubClashService(false, "", sub)
clash := NewSubClashService(false, "", false, sub)
out, _, err := clash.GetClash("sub-clash", "sub.example.com")
if err != nil {
@@ -115,7 +115,7 @@ func TestSubClash_InfoNode_Expired(t *testing.T) {
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
clash := NewSubClashService(false, "", sub)
clash := NewSubClashService(false, "", false, sub)
out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com")
if err != nil {
@@ -178,7 +178,7 @@ func TestSubClash_InfoNode_Depleted(t *testing.T) {
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
clash := NewSubClashService(false, "", sub)
clash := NewSubClashService(false, "", false, sub)
out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com")
if err != nil {
@@ -225,7 +225,7 @@ func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) {
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}")
sub.subInfoNodeEnable = true
clash := NewSubClashService(false, "", sub)
clash := NewSubClashService(false, "", false, sub)
out, _, err := clash.GetClash("sub-clash", "sub.example.com")
if err != nil {
+162
View File
@@ -0,0 +1,162 @@
package sub
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
yaml "github.com/goccy/go-yaml"
)
const remoteClashTemplateWithDNS = `
dns:
enable: true
nameserver:
- https://8.8.8.8/dns-query
sniffer:
enable: true
rules:
- DOMAIN,remote.example,DIRECT
- MATCH,PROXY
`
var remoteClashTemplateDNS = map[string]any{
"enable": true,
"nameserver": []any{"https://8.8.8.8/dns-query"},
}
func remoteClashTestBase() map[string]any {
return map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
}
func TestMergeRemoteClashRulesClientConfigIsOptIn(t *testing.T) {
tests := []struct {
name string
allowClientConfig bool
wantDNS any
wantSniffer any
}{
{name: "off drops dns and sniffer", allowClientConfig: false},
{
name: "on copies dns and sniffer",
allowClientConfig: true,
wantDNS: remoteClashTemplateDNS,
wantSniffer: map[string]any{"enable": true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := remoteClashTestBase()
if err := mergeRemoteClashRulesYAML(base, remoteClashTemplateWithDNS, tt.allowClientConfig); err != nil {
t.Fatalf("merge: %v", err)
}
if !reflect.DeepEqual(base["dns"], tt.wantDNS) {
t.Fatalf("dns = %#v, want %#v", base["dns"], tt.wantDNS)
}
if rules, _ := asAnySlice(base["rules"]); len(rules) == 0 || rules[0] != "DOMAIN,remote.example,DIRECT" {
t.Fatalf("rules = %#v, want remote rules applied", base["rules"])
}
if !reflect.DeepEqual(base["sniffer"], tt.wantSniffer) {
t.Fatalf("sniffer = %#v, want %#v", base["sniffer"], tt.wantSniffer)
}
})
}
}
func TestRemoteClashMalformedClientConfigIsSkipped(t *testing.T) {
tests := []struct {
name string
remote string
}{
{name: "scalar dns", remote: "dns: 8.8.8.8\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"},
{name: "list dns", remote: "dns:\n - 8.8.8.8\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"},
{name: "null dns", remote: "dns:\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"},
{name: "scalar sniffer", remote: "sniffer: true\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, _, err := normalizeClashRouting([]byte(tt.remote)); err != nil {
t.Fatalf("fetch-time err=%v, want nil", err)
}
base := remoteClashTestBase()
if err := mergeRemoteClashRulesYAML(base, tt.remote, true); err != nil {
t.Fatalf("merge err=%v, want nil", err)
}
if _, ok := base["dns"]; ok {
t.Fatalf("dns = %#v, want absent", base["dns"])
}
if _, ok := base["sniffer"]; ok {
t.Fatalf("sniffer = %#v, want absent", base["sniffer"])
}
if rules, _ := asAnySlice(base["rules"]); len(rules) == 0 || rules[0] != "DOMAIN,remote.example,DIRECT" {
t.Fatalf("rules = %#v, want remote rules applied", base["rules"])
}
})
}
}
func TestNormalizeClashRoutingRequiresRouteGraphKey(t *testing.T) {
_, _, err := normalizeClashRouting([]byte("dns:\n enable: true\nsniffer:\n enable: true\n"))
if err == nil || err.Error() != "Clash routing response has no supported routing keys" {
t.Fatalf("err=%v, want no supported routing keys", err)
}
}
func TestClashSubscriptionRemoteDNSFollowsSetting(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "rdns", 4601, 1, wsTLSStream)
gin.SetMode(gin.TestMode)
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusOK, remoteClashTemplateWithDNS), nil
}), false)
const source = "https://example.com/mihomo/default.yaml"
primeRemoteRouting(t, routingSourceResolver, remoteRoutingClash, source)
tests := []struct {
name string
remoteDNS bool
wantDNS any
}{
{name: "off keeps client dns", remoteDNS: false},
{name: "on takes remote dns", remoteDNS: true, wantDNS: remoteClashTemplateDNS},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
NewSUBController(
router.Group("/"),
WithSUBClashEnabled(true),
WithSUBClashEnableRouting(true),
WithSUBClashRules(source),
WithSUBClashRemoteDNS(tt.remoteDNS),
)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash/s1", nil))
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
var config map[string]any
if err := yaml.Unmarshal(resp.Body.Bytes(), &config); err != nil {
t.Fatalf("decode clash yaml: %v\n%s", err, resp.Body.String())
}
if !reflect.DeepEqual(config["dns"], tt.wantDNS) {
t.Fatalf("dns = %#v, want %#v\n%s", config["dns"], tt.wantDNS, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "DOMAIN,remote.example,DIRECT") {
t.Fatalf("remote rules were not applied:\n%s", resp.Body.String())
}
})
}
}
+45 -19
View File
@@ -20,15 +20,16 @@ import (
)
type SubClashService struct {
enableRouting bool
clashRules string
SubService *SubService
enableRouting bool
clashRules string
clashRemoteDNS bool
SubService *SubService
}
var errNoLegacyClashProxies = errors.New("no Clash for Windows-compatible proxies found; use the Mihomo subscription for modern proxy types")
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
func NewSubClashService(enableRouting bool, clashRules string, clashRemoteDNS bool, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, clashRemoteDNS: clashRemoteDNS, SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -170,7 +171,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
if remote {
if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
if err := mergeRemoteClashRules(config, remoteDocument, s.clashRemoteDNS); err != nil {
return "", "", err
}
} else if err := mergeClashRulesYAML(config, resolved); err != nil {
@@ -1296,17 +1297,51 @@ func mergeClashRulesYAML(base map[string]any, raw string) error {
return nil
}
// mergeRemoteClashRules lets remote update only the route graph (see
// remoteClashAllowedKey) and never mutates remote: cached documents are shared.
func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
type remoteClashKeyClass int
const (
remoteClashRouteGraph remoteClashKeyClass = iota + 1
remoteClashClientConfig
)
// Client config keys steer the client's own resolver, so a remote URL may set
// them only when the admin opts in (subClashRemoteDNS).
var remoteClashKeyClasses = map[string]remoteClashKeyClass{
"proxy-groups": remoteClashRouteGraph,
"rule-providers": remoteClashRouteGraph,
"rules": remoteClashRouteGraph,
"dns": remoteClashClientConfig,
"sniffer": remoteClashClientConfig,
}
func remoteClashKeyAllowed(key string, allowClientConfig bool) bool {
switch remoteClashKeyClasses[key] {
case remoteClashRouteGraph:
return true
case remoteClashClientConfig:
return allowClientConfig
default:
return false
}
}
// mergeRemoteClashRules copies only keys listed in remoteClashKeyClasses and
// never mutates remote: cached documents are shared.
func mergeRemoteClashRules(base map[string]any, remote map[string]any, allowClientConfig bool) error {
if len(remote) == 0 {
return fmt.Errorf("remote Clash routing source must be a YAML map")
}
for key, value := range remote {
if !remoteClashAllowedKey(key) {
if !remoteClashKeyAllowed(key, allowClientConfig) {
continue
}
if remoteClashKeyClasses[key] == remoteClashClientConfig {
// A malformed dns or sniffer must not cost the client its routing.
if _, ok := value.(map[string]any); !ok {
continue
}
}
if err := validateRemoteClashValue(key, value); err != nil {
return err
}
@@ -1382,15 +1417,6 @@ func validateRemoteClashValue(key string, value any) error {
return nil
}
func remoteClashAllowedKey(key string) bool {
switch key {
case "proxy-groups", "rule-providers", "rules":
return true
default:
return false
}
}
func validateClashRouteGraph(config map[string]any) error {
known := map[string]struct{}{
"DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
+1 -1
View File
@@ -167,7 +167,7 @@ func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
}
func TestClashRealityMLKEMAcrossSources(t *testing.T) {
svc := NewSubClashService(false, "", &SubService{})
svc := NewSubClashService(false, "", false, &SubService{})
for _, security := range []string{"reality", "tls", "none"} {
for _, fingerprint := range []string{"", "chrome", "firefox"} {
t.Run(security+"/"+fingerprint, func(t *testing.T) {
+6 -1
View File
@@ -115,6 +115,7 @@ type subControllerConfig struct {
subJsonObservatory string
subClashEnableRouting bool
subClashRules string
subClashRemoteDNS bool
subTitle string
subSupportURL string
@@ -217,6 +218,10 @@ func WithSUBClashRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashRules = value }
}
func WithSUBClashRemoteDNS(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashRemoteDNS = value }
}
func WithSUBTitle(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subTitle = value }
}
@@ -319,7 +324,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
subService: sub,
subJsonService: subJsonSvc,
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, config.subClashRemoteDNS, sub),
subTemplateCache: map[string]*cachedSubTemplate{},
}
+1 -1
View File
@@ -155,7 +155,7 @@ func TestLinkDisplayName(t *testing.T) {
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
svc := NewSubClashService(false, "", NewSubService(""))
svc := NewSubClashService(false, "", false, NewSubService(""))
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
if proxy == nil {
t.Fatal("expected a clash proxy, got nil")
+1 -1
View File
@@ -56,7 +56,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
}
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
clashOut, _, err := NewSubClashService(false, "", false, base).GetClash("ext-only", "sub.example.com")
if err != nil {
t.Fatalf("GetClash err = %v", err)
}
+4 -4
View File
@@ -260,7 +260,7 @@ func TestSub_HostAllowInsecure(t *testing.T) {
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
}
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(false, "", false, NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
@@ -281,7 +281,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
HostHeader: "cdn.example.com", Path: "/ws-path",
})
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(false, "", false, NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
@@ -507,7 +507,7 @@ func TestSub_HostRealitySniJSONAndClash(t *testing.T) {
t.Fatalf("server-side serverNames %v leaked into the json reality client:\n%s", names, out)
}
yaml, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com")
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
@@ -551,7 +551,7 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) {
t.Fatalf("host not excluded from raw should appear in GetSubs")
}
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(false, "", false, NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
+1 -1
View File
@@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
}
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(false, "", false, NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
+2 -2
View File
@@ -450,7 +450,7 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) {
}
hasSupportedKey := false
for key := range document {
if remoteClashAllowedKey(key) {
if remoteClashKeyClasses[key] == remoteClashRouteGraph {
hasSupportedKey = true
break
}
@@ -465,7 +465,7 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) {
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, document); err != nil {
if err := mergeRemoteClashRules(base, document, false); err != nil {
return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err)
}
return text, document, nil
+6 -6
View File
@@ -20,12 +20,12 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
)
func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
func mergeRemoteClashRulesYAML(base map[string]any, raw string, allowClientConfig bool) error {
var remote map[string]any
if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil {
return err
}
return mergeRemoteClashRules(base, remote)
return mergeRemoteClashRules(base, remote, allowClientConfig)
}
type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error)
@@ -475,7 +475,7 @@ rules:
- RULE-SET,roscom,PROXY
- MATCH,PROXY
`
if err := mergeRemoteClashRulesYAML(base, remote); err != nil {
if err := mergeRemoteClashRulesYAML(base, remote, false); err != nil {
t.Fatalf("merge: %v", err)
}
proxies, ok := base["proxies"].([]map[string]any)
@@ -520,7 +520,7 @@ func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T)
proxies: [PROXY]
rules:
- MATCH,PROXY
`); err != nil {
`, false); err != nil {
t.Fatalf("merge: %v", err)
}
groups, ok := asAnySlice(base["proxy-groups"])
@@ -692,7 +692,7 @@ func TestRemoteClashRouteGraphValidation(t *testing.T) {
}},
"rules": []string{"MATCH,PROXY"},
}
err := mergeRemoteClashRulesYAML(base, tt.remote)
err := mergeRemoteClashRulesYAML(base, tt.remote, false)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err=%v, want %q", err, tt.wantErr)
}
@@ -741,7 +741,7 @@ rules:
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, entry.Clash); err != nil {
if err := mergeRemoteClashRules(base, entry.Clash, false); err != nil {
t.Fatalf("merge cached document: %v", err)
}
if requests.Load() != 1 {
@@ -54,7 +54,7 @@ func TestExcludedInboundHidesLinksButKeepsUsage(t *testing.T) {
})
t.Run("clash", func(t *testing.T) {
out, header, err := NewSubClashService(false, "", NewSubService("")).GetClash("sub-excl", "req.example.com")
out, header, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("sub-excl", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
+6
View File
@@ -180,6 +180,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubClashRules = ""
}
SubClashRemoteDNS, err := s.settingService.GetSubClashRemoteDNS()
if err != nil {
SubClashRemoteDNS = false
}
SubTitle, err := s.settingService.GetSubTitle()
if err != nil {
SubTitle = ""
@@ -361,6 +366,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBJsonObservatory(SubJsonObservatory),
WithSUBClashEnableRouting(SubClashEnableRouting),
WithSUBClashRules(SubClashRules),
WithSUBClashRemoteDNS(SubClashRemoteDNS),
WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl),
+2 -2
View File
@@ -99,7 +99,7 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com")
out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("pin1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
@@ -122,7 +122,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
t.Fatal("GetJson returned empty for an externalProxy entry missing port")
}
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
clashOut, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("extp1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) {
ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(false, "", false, NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)