diff --git a/backend/internal/converter/client.go b/backend/internal/converter/client.go index 2d22cdb..62fdd63 100644 --- a/backend/internal/converter/client.go +++ b/backend/internal/converter/client.go @@ -1051,6 +1051,18 @@ func copyTransport(dst, src map[string]interface{}) { dst["mekya-opts"] = opts } } + // VLESS flow (XTLS Vision) is TCP-only. The transport has just been resolved + // into `network`, so drop a flow that no longer applies: a listener switched + // to ws/grpc/xhttp still carries the old value, and a client given it refuses + // to connect. + if network, _ := dst["network"].(string); network != "" { + switch strings.ToLower(strings.TrimSpace(network)) { + case "tcp", "raw": + // keep + default: + delete(dst, "flow") + } + } } func realityClientOptions(src map[string]interface{}) map[string]interface{} { diff --git a/backend/internal/converter/transport_flow_test.go b/backend/internal/converter/transport_flow_test.go new file mode 100644 index 0000000..98fb90e --- /dev/null +++ b/backend/internal/converter/transport_flow_test.go @@ -0,0 +1,34 @@ +package converter + +import "testing" + +// The client YAML is built from the same listener config, so a stale flow has to +// be dropped here too: copyTransport resolves the transport into `network`, and +// anything other than raw TCP cannot carry Vision. +func TestCopyTransportDropsFlowOnNonTCPNetwork(t *testing.T) { + cases := []struct { + name string + src map[string]interface{} + wantNet string + wantFlow bool + }{ + {"tcp keeps flow", map[string]interface{}{}, "", true}, + {"ws drops flow", map[string]interface{}{"ws-path": "/ws"}, "ws", false}, + {"grpc drops flow", map[string]interface{}{"grpc-service-name": "svc"}, "grpc", false}, + {"xhttp drops flow", map[string]interface{}{"xhttp-config": map[string]interface{}{"path": "/x"}}, "xhttp", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dst := map[string]interface{}{"flow": "xtls-rprx-vision"} + copyTransport(dst, c.src) + + if got := dst["network"]; got != c.wantNet && !(c.wantNet == "" && got == nil) { + t.Fatalf("network = %v, want %q", got, c.wantNet) + } + _, present := dst["flow"] + if present != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %#v", present, c.wantFlow, dst) + } + }) + } +} diff --git a/backend/internal/mui/bridge.go b/backend/internal/mui/bridge.go index 5537cd2..c336dd5 100644 --- a/backend/internal/mui/bridge.go +++ b/backend/internal/mui/bridge.go @@ -186,6 +186,11 @@ func BuildShares(l models.Listener, publicHost string, creds []Cred) ([]muiproto } enrichAccessProfileFromNode(&profile, node, l) flowDefault := listenerFlowHint(l) + // Vision flow is TCP-only, so the listener's default must not be pushed onto + // users of a listener that has since been switched to ws/grpc/xhttp. + if flowDefault != "" && node.VLESS != nil && !node.VLESS.Handler.CarriesFlow() { + flowDefault = "" + } state := domain.DesiredState{AsOf: time.Now().UTC(), PublicHost: profile.PublicHost} reg := muiprotocol.DefaultRegistry() out := make([]muiprotocol.Share, 0, len(node.Users)) diff --git a/backend/internal/mui/bridge_flow_test.go b/backend/internal/mui/bridge_flow_test.go new file mode 100644 index 0000000..b7d6459 --- /dev/null +++ b/backend/internal/mui/bridge_flow_test.go @@ -0,0 +1,46 @@ +package mui + +import ( + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +const flowTestUUID = "00000000-0000-0000-0000-000000000001" + +// The m-ui share path is what the node API serves first; it must not hand out a +// Vision flow over a transport that cannot carry it. +func TestBuildSharesOmitFlowForWebSocket(t *testing.T) { + cases := []struct { + name string + config string + wantFlow bool + }{ + {"raw keeps flow", `{"flow":"xtls-rprx-vision"}`, true}, + {"ws drops flow", `{"flow":"xtls-rprx-vision","ws-path":"/ws"}`, false}, + {"grpc drops flow", `{"flow":"xtls-rprx-vision","grpc-service-name":"svc"}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + l := models.Listener{ + Name: "n", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, Config: c.config, + } + shares, err := BuildShares(l, "example.com", []Cred{{Username: "u", UUID: flowTestUUID}}) + if err != nil { + t.Fatalf("BuildShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("expected at least one share") + } + uri := shares[0].URI + if strings.TrimSpace(uri) == "" { + t.Fatalf("empty share URI: %#v", shares[0]) + } + if got := strings.Contains(uri, "flow="); got != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %s", got, c.wantFlow, uri) + } + }) + } +} diff --git a/backend/internal/mui/domain/node.go b/backend/internal/mui/domain/node.go index 1c148ef..ad349a0 100644 --- a/backend/internal/mui/domain/node.go +++ b/backend/internal/mui/domain/node.go @@ -246,6 +246,19 @@ type VLESSHandlerSpec struct { MKCP *MKCPConfig `json:"mkcp,omitempty"` } +// CarriesFlow reports whether this handler can carry VLESS flow (XTLS Vision). +// Vision is TCP-only: on ws, grpc, xhttp or mkcp the core rejects the +// combination, so a flow left over from an earlier transport must not be put +// into a share link. +func (h VLESSHandlerSpec) CarriesFlow() bool { + switch h.Type { + case "", VLESSHandlerRaw: + return true + default: + return false + } +} + // MKCPConfig mirrors listener/inbound.MKCPConfig on Mihomo Meta. It is // currently registered only by the VMess module. type MKCPConfig struct { diff --git a/backend/internal/mui/protocol/vless.go b/backend/internal/mui/protocol/vless.go index 89e5344..d6ae8bf 100644 --- a/backend/internal/mui/protocol/vless.go +++ b/backend/internal/mui/protocol/vless.go @@ -118,7 +118,10 @@ func (VLESSModule) BuildShare( if profile.PacketEncoding != "" { query.Set("packetEncoding", profile.PacketEncoding) } - if user.VLESS.Flow != "" { + // Vision flow is TCP-only. A listener migrated to ws/grpc/xhttp keeps its + // flow in storage; emitting it here would hand the client a link that cannot + // connect. + if user.VLESS.Flow != "" && node.VLESS.Handler.CarriesFlow() { query.Set("flow", user.VLESS.Flow) } switch node.VLESS.Handler.Type { diff --git a/backend/internal/node/api.go b/backend/internal/node/api.go index af65763..5eaabbf 100644 --- a/backend/internal/node/api.go +++ b/backend/internal/node/api.go @@ -230,21 +230,31 @@ func (h *Handler) ExportNodeURI(c *gin.Context) { } } } - if primary == "" && clientYAML == "" { + // Tier 2 covers a different protocol set than the legacy builders: vmess, + // snell, shadowquic, mieru, sudoku and trusttunnel compile to client YAML + // but never produce a share URI there. Gating this tier on the YAML too + // would leave those listeners reporting no link at all, even though + // ClientURIsWithCredentials exports one — so it runs whenever the URI is + // still missing, and only replaces the YAML if none arrived. + if primary == "" { legacy, lerr := ClientURIsWithCredentials(*listener, host, credentials) - if lerr != nil { + if lerr != nil && clientYAML == "" { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": lerr.Error()}) return } - uris = legacy - if uris == nil { - uris = []string{} + if lerr == nil { + uris = legacy + if uris == nil { + uris = []string{} + } + if len(uris) > 0 { + primary = uris[0] + } } - if len(uris) > 0 { - primary = uris[0] - } - if y, err := converter.ExportClientYAML(*listener, host, credentials); err == nil { - clientYAML = y + if clientYAML == "" { + if y, err := converter.ExportClientYAML(*listener, host, credentials); err == nil { + clientYAML = y + } } } if clientYAML == "" { diff --git a/backend/internal/node/uri_builders.go b/backend/internal/node/uri_builders.go index feffa12..5b9b1c2 100644 --- a/backend/internal/node/uri_builders.go +++ b/backend/internal/node/uri_builders.go @@ -11,6 +11,7 @@ import ( "github.com/kazeyukiro/3m-ui/backend/internal/certutil" "github.com/kazeyukiro/3m-ui/backend/internal/netutil" + "github.com/kazeyukiro/3m-ui/backend/internal/protocol" "golang.org/x/crypto/curve25519" ) @@ -106,7 +107,14 @@ func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, e } params := tlsParams(cfg) params["type"] = "tcp" - if flow, _ := row["flow"].(string); flow != "" { + // Resolve the transport before deciding anything that depends on it. + for k, v := range transportParams(cfg) { + params[k] = v + } + // Vision flow is TCP-only. Ask the transport rather than the config: a + // listener switched to ws/grpc/xhttp keeps its old flow in storage, and + // exporting it produces a link no client can use. + if flow, _ := row["flow"].(string); flow != "" && protocol.TransportCarriesFlow(cfg) { params["flow"] = flow } if encryption, _ := cfg["encryption"].(string); encryption != "" { @@ -134,9 +142,6 @@ func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, e params["fp"] = "chrome" } } - for k, v := range transportParams(cfg) { - params[k] = v - } result = append(result, addName(query("vless://"+url.PathEscape(uuid)+"@"+netutil.JoinHostPort(host, port), params), name)) } return result, nil @@ -156,18 +161,34 @@ func vmessURIs(name, host, port string, cfg map[string]interface{}) ([]string, e aid := stringValue(cfg["alterId"], "0") cipher := stringValue(cfg["cipher"], "auto") obj := map[string]string{"v": "2", "ps": name, "add": host, "port": port, "id": uuid, "aid": aid, "scy": cipher, "net": "tcp", "type": "none"} - if tls, ok := tlsParams(cfg)["security"]; ok && tls == "tls" { + tlsOpts := tlsParams(cfg) + if tlsOpts["security"] == "tls" { obj["tls"] = "tls" } - if sni := tlsParams(cfg)["sni"]; sni != "" { - obj["sni"] = sni + if tlsOpts["sni"] != "" { + obj["sni"] = tlsOpts["sni"] } - if fp := tlsParams(cfg)["fp"]; fp != "" { - obj["fp"] = fp + if tlsOpts["fp"] != "" { + obj["fp"] = tlsOpts["fp"] + } + // vless and trojan carry this in their query string. v2rayN's documented + // vmess field set has no skip-certificate field, so it ignores this one, + // but clients that do read it are the ones whose verification would + // otherwise fail — see vmessSchema for why it is kept anyway. + if tlsOpts["allowInsecure"] != "" { + obj["allowInsecure"] = tlsOpts["allowInsecure"] } if ws, ok := cfg["ws-path"].(string); ok && ws != "" { obj["net"] = "ws" obj["path"] = ws + // The Host header is carried separately from the path. Without it a + // ws listener that pins a Host is unreachable through the exported + // link, even though the same listener works from the client YAML. + if headers, ok := cfg["ws-headers"].(map[string]interface{}); ok { + if h, ok := headers["Host"].(string); ok && h != "" { + obj["host"] = h + } + } } if grpc, ok := cfg["grpc-service-name"].(string); ok && grpc != "" { obj["net"] = "grpc" @@ -258,8 +279,11 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin if v, ok := cfg["sni"].(string); ok && v != "" { params["sni"] = v } - if certutil.ShouldSkipCertVerify(cfg) { + // Matches the config-embedded users branch below: same listener must + // produce the same link whichever way its users are stored. + if clientSkipCert(cfg, host) { params["insecure"] = "1" + params["allowInsecure"] = "1" } if v, ok := cfg["obfs"].(string); ok && v != "" { params["obfs"] = v diff --git a/backend/internal/node/uri_credentials.go b/backend/internal/node/uri_credentials.go index d678f22..b81f5de 100644 --- a/backend/internal/node/uri_credentials.go +++ b/backend/internal/node/uri_credentials.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "github.com/kazeyukiro/3m-ui/backend/internal/protocol" "github.com/kazeyukiro/3m-ui/backend/internal/user" ) @@ -67,7 +68,11 @@ func ClientURIsWithCredentials(listener models.Listener, host string, credential users := make([]interface{}, 0, len(credentials)) for _, credential := range credentials { row := map[string]interface{}{"username": credential.Username, "password": credential.Password, "uuid": credential.UUID} - if flow != "" && (listener.Protocol == "vless" || listener.Protocol == "vmess") { + // The listener-level flow is only meaningful on a raw/TCP + // transport; copying it onto users of a ws/grpc/xhttp listener + // reintroduces the value the form was supposed to have dropped. + if flow != "" && protocol.TransportCarriesFlow(cfg) && + (listener.Protocol == "vless" || listener.Protocol == "vmess") { row["flow"] = flow } users = append(users, row) diff --git a/backend/internal/node/uri_flow_transport_test.go b/backend/internal/node/uri_flow_transport_test.go new file mode 100644 index 0000000..6727c94 --- /dev/null +++ b/backend/internal/node/uri_flow_transport_test.go @@ -0,0 +1,130 @@ +package node + +import ( + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "github.com/kazeyukiro/3m-ui/backend/internal/user" +) + +const testUUID = "00000000-0000-0000-0000-000000000001" + +// XTLS Vision is TCP-only. A listener migrated to ws/grpc/xhttp keeps its flow in +// storage, and exporting it produced links that claim Vision over a transport +// that cannot carry it — clients either refuse to connect or silently misbehave. +// These pin that a stale flow never reaches a share URI, while a genuine TCP +// listener keeps its flow. +func TestVlessURIsOmitFlowForNonTCPTransports(t *testing.T) { + cases := []struct { + name string + cfg map[string]interface{} + wantType string + wantFlow bool + }{ + { + name: "raw keeps flow", + cfg: map[string]interface{}{"flow": "xtls-rprx-vision"}, + wantType: "tcp", wantFlow: true, + }, + { + name: "ws drops flow", + cfg: map[string]interface{}{"flow": "xtls-rprx-vision", "ws-path": "/ws"}, + wantType: "ws", wantFlow: false, + }, + { + name: "grpc drops flow", + cfg: map[string]interface{}{"flow": "xtls-rprx-vision", "grpc-service-name": "svc"}, + wantType: "grpc", wantFlow: false, + }, + { + name: "xhttp drops flow", + cfg: map[string]interface{}{ + "flow": "xtls-rprx-vision", + "xhttp-config": map[string]interface{}{ + "path": "/xhttp", + }, + }, + wantType: "xhttp", wantFlow: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // vlessURIs reads the flow from the user row; the listener-level + // value is merged into rows by ClientURIsWithCredentials, which is + // covered separately below. + row := map[string]interface{}{"username": "u", "uuid": testUUID} + if flow, ok := c.cfg["flow"].(string); ok { + row["flow"] = flow + } + cfg := map[string]interface{}{ + "users": []interface{}{row}, + } + for _, key := range []string{"ws-path", "grpc-service-name", "xhttp-config"} { + if v, ok := c.cfg[key]; ok { + cfg[key] = v + } + } + + uris, err := vlessURIs("node", "example.com", "443", cfg) + if err != nil { + t.Fatalf("vlessURIs: %v", err) + } + if len(uris) != 1 { + t.Fatalf("expected 1 URI, got %d: %v", len(uris), uris) + } + uri := uris[0] + if !strings.Contains(uri, "type="+c.wantType) { + t.Fatalf("expected type=%s in %q", c.wantType, uri) + } + if got := strings.Contains(uri, "flow="); got != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %s", got, c.wantFlow, uri) + } + }) + } +} + +// The listener-level flow is copied onto every user before the URI is built. +// That copy used to be made on protocol alone, which reintroduced the value the +// form had deliberately dropped for ws. +func TestClientURIsWithCredentialsDropFlowForWebSocket(t *testing.T) { + creds := []user.Credential{{Username: "u", UUID: testUUID}} + + ws := models.Listener{ + Name: "ws-node", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, + Config: `{"flow":"xtls-rprx-vision","ws-path":"/ws"}`, + } + uris, err := ClientURIsWithCredentials(ws, "example.com", creds) + if err != nil { + t.Fatalf("ClientURIsWithCredentials: %v", err) + } + if len(uris) == 0 { + t.Fatal("expected at least one URI") + } + for _, uri := range uris { + if strings.Contains(uri, "flow=") { + t.Fatalf("ws listener must not export a flow: %s", uri) + } + if !strings.Contains(uri, "type=ws") { + t.Fatalf("expected a ws URI, got %s", uri) + } + } + + raw := models.Listener{ + Name: "tcp-node", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, + Config: `{"flow":"xtls-rprx-vision"}`, + } + uris, err = ClientURIsWithCredentials(raw, "example.com", creds) + if err != nil { + t.Fatalf("ClientURIsWithCredentials: %v", err) + } + if len(uris) == 0 { + t.Fatal("expected at least one URI") + } + if !strings.Contains(uris[0], "flow=xtls-rprx-vision") { + t.Fatalf("tcp listener must keep its flow: %s", uris[0]) + } +} diff --git a/backend/internal/node/uri_transport_audit_test.go b/backend/internal/node/uri_transport_audit_test.go new file mode 100644 index 0000000..1be832c --- /dev/null +++ b/backend/internal/node/uri_transport_audit_test.go @@ -0,0 +1,166 @@ +package node + +import ( + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "testing" +) + +// vmessURIs builds a v2rayN-style JSON blob, so its fields have to be checked +// through the base64 payload rather than a query string. +func decodeVMess(t *testing.T, uri string) map[string]interface{} { + t.Helper() + if !strings.HasPrefix(uri, "vmess://") { + t.Fatalf("not a vmess URI: %s", uri) + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "vmess://")) + if err != nil { + t.Fatalf("decode vmess payload: %v", err) + } + var obj map[string]interface{} + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal vmess payload: %v (%s)", err, raw) + } + return obj +} + +func vmessURI(t *testing.T, cfg map[string]interface{}) string { + t.Helper() + uris, err := vmessURIs("node", "example.com", "443", cfg) + if err != nil { + t.Fatalf("vmessURIs: %v", err) + } + if len(uris) != 1 { + t.Fatalf("expected 1 URI, got %d", len(uris)) + } + return uris[0] +} + +func vmessCfg() map[string]interface{} { + return map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{"username": "u", "uuid": testUUID}, + }, + } +} + +// The Host header of a ws listener is separate from its path. It used to be +// dropped, so the exported link could not reach a listener that pins a Host — +// while the client YAML for the very same listener worked. +func TestVMessURICarriesWebSocketHost(t *testing.T) { + cfg := vmessCfg() + cfg["ws-path"] = "/ws" + cfg["ws-headers"] = map[string]interface{}{"Host": "cdn.example.com"} + + obj := decodeVMess(t, vmessURI(t, cfg)) + if obj["net"] != "ws" { + t.Fatalf("net = %v, want ws", obj["net"]) + } + if obj["path"] != "/ws" { + t.Fatalf("path = %v, want /ws", obj["path"]) + } + if obj["host"] != "cdn.example.com" { + t.Fatalf("host = %v, want the configured ws Host", obj["host"]) + } +} + +func TestVMessURIOmitsWebSocketHostWhenUnset(t *testing.T) { + cfg := vmessCfg() + cfg["ws-path"] = "/ws" + + obj := decodeVMess(t, vmessURI(t, cfg)) + if _, present := obj["host"]; present { + t.Fatalf("host must stay absent when no Host header is configured: %#v", obj) + } +} + +// v2rayN's VmessQRCode model — the reference parser for vmess:// links — +// declares exactly this field set. Anything outside it is ignored by v2rayN. +// +// allowInsecure is the one deliberate exception. v2rayN's model has no +// skip-certificate field at all (neither allowInsecure nor insecure), v2rayNG +// reads `insecure`, and Xray-core has removed allowInsecure in favour of +// pinnedPeerCertSha256 — so no spelling is honoured by every client. It is kept +// anyway because the clients that do read it are exactly the ones whose +// handshake would otherwise fail, and v2rayN ignoring an extra key costs +// nothing. Any further field needs the same justification before it joins this +// set. +var vmessSchema = map[string]bool{ + "v": true, "ps": true, "add": true, "port": true, "id": true, "aid": true, + "scy": true, "net": true, "type": true, "host": true, "path": true, + "tls": true, "sni": true, "alpn": true, "fp": true, + "allowInsecure": true, +} + +func TestVMessURICarriesAllowInsecure(t *testing.T) { + cfg := vmessCfg() + cfg["skip-cert-verify"] = true + cfg["certificate"] = "cert" + + obj := decodeVMess(t, vmessURI(t, cfg)) + if obj["allowInsecure"] != "1" { + t.Fatalf("allowInsecure = %v, want 1", obj["allowInsecure"]) + } +} + +func TestVMessURIStaysWithinV2RayNSchema(t *testing.T) { + cfg := vmessCfg() + cfg["ws-path"] = "/ws" + cfg["ws-headers"] = map[string]interface{}{"Host": "cdn.example.com"} + cfg["skip-cert-verify"] = true + cfg["certificate"] = "cert" + + obj := decodeVMess(t, vmessURI(t, cfg)) + for key := range obj { + if !vmessSchema[key] { + t.Errorf("%q is outside v2rayN's vmess schema and will be ignored", key) + } + } +} + +// A hysteria2 listener stores its users either as rows or as a map. Both branches +// produced different TLS flags for the same listener, so the exported link +// depended on the storage shape. +func TestHysteria2URIsAgreeAcrossUserShapes(t *testing.T) { + common := map[string]interface{}{ + "skip-cert-verify": true, + "up": "100 Mbps", + } + rows := map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{"username": "u", "password": "p"}, + }, + } + mapped := map[string]interface{}{ + "users": map[string]interface{}{"u": "p"}, + } + for k, v := range common { + rows[k] = v + mapped[k] = v + } + + want := map[string]string{"insecure": "1", "allowInsecure": "1"} + for name, cfg := range map[string]map[string]interface{}{"rows": rows, "map": mapped} { + t.Run(name, func(t *testing.T) { + uris, err := hysteria2URIs("node", "example.com", "443", cfg) + if err != nil { + t.Fatalf("hysteria2URIs: %v", err) + } + if len(uris) != 1 { + t.Fatalf("expected 1 URI, got %d", len(uris)) + } + parsed, err := url.Parse(uris[0]) + if err != nil { + t.Fatalf("parse URI: %v", err) + } + values := parsed.Query() + for key, want := range want { + if values.Get(key) != want { + t.Fatalf("%s = %q, want %q: %s", key, values.Get(key), want, uris[0]) + } + } + }) + } +} diff --git a/backend/internal/protocol/compile.go b/backend/internal/protocol/compile.go index 4377c9e..433b545 100644 --- a/backend/internal/protocol/compile.go +++ b/backend/internal/protocol/compile.go @@ -223,13 +223,7 @@ func asUsersArray(cfg map[string]interface{}, fromCreds []UserCred, field string out = normalizeUsersValue(raw) } } - if flow, ok := cfg["flow"].(string); ok && strings.TrimSpace(flow) != "" { - for _, user := range out { - if _, exists := user["flow"]; !exists { - user["flow"] = flow - } - } - } + applyListenerFlow(cfg, out) // Propagate top-level alterId to each user object. The official Mihomo // schema for VMess expects alterId inside users[].alterId; without it, // per-user alterId is silently dropped when panel credentials are bound. @@ -318,6 +312,24 @@ func asUsersMapUUID(cfg map[string]interface{}, fromCreds []UserCred, hasCredSta return out } +// applyListenerFlow copies a listener-level VLESS flow onto every user that does +// not carry one. +// +// Vision flow is TCP-only, so a stale flow must not be pushed onto users of a +// listener that has since been switched to ws/grpc/xhttp: Mihomo rejects that +// combination outright, which takes the whole listener down rather than one user. +func applyListenerFlow(cfg map[string]interface{}, users []map[string]interface{}) { + flow, ok := cfg["flow"].(string) + if !ok || strings.TrimSpace(flow) == "" || !TransportCarriesFlow(cfg) { + return + } + for _, user := range users { + if _, exists := user["flow"]; !exists { + user["flow"] = flow + } + } +} + func normalizeUsersValue(value interface{}) []map[string]interface{} { switch users := value.(type) { case []interface{}: diff --git a/backend/internal/protocol/flow_transport_test.go b/backend/internal/protocol/flow_transport_test.go new file mode 100644 index 0000000..56e565b --- /dev/null +++ b/backend/internal/protocol/flow_transport_test.go @@ -0,0 +1,136 @@ +package protocol + +import ( + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +func TestTransportSpecCarriesFlow(t *testing.T) { + cases := []struct { + network string + want bool + }{ + {"", true}, // empty means tcp + {"tcp", true}, + {"TCP", true}, // case-insensitive + {"raw", true}, + {"ws", false}, + {"grpc", false}, + {"xhttp", false}, + {"h2", false}, + } + for _, c := range cases { + if got := (TransportSpec{Network: c.network}).CarriesFlow(); got != c.want { + t.Errorf("TransportSpec{Network:%q}.CarriesFlow() = %v, want %v", c.network, got, c.want) + } + } +} + +func TestTransportCarriesFlowFromConfig(t *testing.T) { + cases := []struct { + name string + cfg map[string]interface{} + want bool + }{ + {"empty config", map[string]interface{}{}, true}, + {"ws path", map[string]interface{}{"ws-path": "/ws"}, false}, + {"blank ws path is not configured", map[string]interface{}{"ws-path": " "}, true}, + {"grpc service", map[string]interface{}{"grpc-service-name": "svc"}, false}, + {"xhttp block", map[string]interface{}{"xhttp-config": map[string]interface{}{"path": "/x"}}, false}, + {"empty xhttp block is not configured", map[string]interface{}{"xhttp-config": map[string]interface{}{}}, true}, + {"mkcp block", map[string]interface{}{"mkcp-config": map[string]interface{}{"mtu": 1350}}, false}, + {"mekya block", map[string]interface{}{"mekya-config": map[string]interface{}{"enable": true}}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := TransportCarriesFlow(c.cfg); got != c.want { + t.Fatalf("TransportCarriesFlow(%v) = %v, want %v", c.cfg, got, c.want) + } + }) + } +} + +// A listener whose transport was switched to ws keeps its flow in storage; the +// exported share must not advertise it. +func TestVLESSShareOmitsFlowForWebSocket(t *testing.T) { + const uuid = "00000000-0000-0000-0000-000000000001" + cases := []struct { + name string + config string + wantFlow bool + }{ + {"tcp keeps flow", `{"flow":"xtls-rprx-vision"}`, true}, + {"ws drops flow", `{"flow":"xtls-rprx-vision","ws-path":"/ws"}`, false}, + {"grpc drops flow", `{"flow":"xtls-rprx-vision","grpc-service-name":"svc"}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + l := models.Listener{ + Name: "n", Protocol: "vless", Type: "vless", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, Config: c.config, + } + shares, err := ExportShares(l, "example.com", []UserCred{{Username: "u", UUID: uuid}}) + if err != nil { + t.Fatalf("ExportShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("expected at least one share") + } + uri := shares[0].URI + if strings.TrimSpace(uri) == "" { + t.Fatalf("empty share URI: %#v", shares[0]) + } + if got := strings.Contains(uri, "flow="); got != c.wantFlow { + t.Fatalf("flow present = %v, want %v: %s", got, c.wantFlow, uri) + } + }) + } +} + +// The server-side config must not be handed an impossible combination either: +// Mihomo rejects a Vision flow on a non-TCP listener outright. +func TestVLESSCompileSkipsStaleFlowForWebSocket(t *testing.T) { + cfg := map[string]interface{}{ + "flow": "xtls-rprx-vision", + "ws-path": "/ws", + } + users := []map[string]interface{}{ + {"username": "u", "uuid": "00000000-0000-0000-0000-000000000001"}, + } + applyListenerFlow(cfg, users) + if _, present := users[0]["flow"]; present { + t.Fatalf("flow must not be applied on a ws listener: %#v", users[0]) + } + + // A raw listener still inherits the listener-level default. + applyListenerFlow(map[string]interface{}{"flow": "xtls-rprx-vision"}, users) + if users[0]["flow"] != "xtls-rprx-vision" { + t.Fatalf("raw listener must keep the default flow: %#v", users[0]) + } +} + +// The VMess client YAML lost the ws Host header: it built ws-opts with the path +// only, so a listener that pins a Host was unreachable through the exported +// config while the identical vless YAML worked. +func TestVMessYAMLCarriesWebSocketHost(t *testing.T) { + l := models.Listener{ + Name: "n", Protocol: "vmess", Type: "vmess", Port: "443", + BindAddress: "0.0.0.0", Enabled: true, + Config: `{"ws-path":"/ws","ws-headers":{"Host":"cdn.example.com"}}`, + } + shares, err := ExportShares(l, "example.com", []UserCred{{Username: "u", UUID: "00000000-0000-0000-0000-000000000001"}}) + if err != nil { + t.Fatalf("ExportShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("expected a share") + } + yaml := shares[0].ClientYAML + for _, want := range []string{"network: ws", "path: /ws", "Host: cdn.example.com"} { + if !strings.Contains(yaml, want) { + t.Fatalf("client YAML missing %q:\n%s", want, yaml) + } + } +} diff --git a/backend/internal/protocol/share_modules.go b/backend/internal/protocol/share_modules.go index 809e49e..d528039 100644 --- a/backend/internal/protocol/share_modules.go +++ b/backend/internal/protocol/share_modules.go @@ -87,7 +87,7 @@ func (VLESSCompiler) BuildShare(in ShareInput) (Share, error) { } else { params["encryption"] = "none" } - if spec.Flow != "" { + if spec.Flow != "" && spec.Transport.CarriesFlow() { params["flow"] = spec.Flow } if spec.SNI != "" { @@ -144,7 +144,7 @@ func vlessClientYAML(node NodeModel, host, port, uuid string, spec *VLESSSpec) ( if node.UDP { p["udp"] = true } - if spec.Flow != "" { + if spec.Flow != "" && spec.Transport.CarriesFlow() { p["flow"] = spec.Flow } // tls / reality @@ -379,7 +379,15 @@ func (VMessCompiler) BuildShare(in ShareInput) (Share, error) { extra["alpn"] = spec.ALPN } if netw == "ws" && spec.Transport.WSPath != "" { - extra["ws-opts"] = map[string]interface{}{"path": spec.Transport.WSPath} + // Per mihomo wiki (proxies-transport: ws-opts), the Host header belongs + // under `headers`, not at the top level of ws-opts. vlessClientYAML + // already emits it that way; a ws listener that pins a Host is + // unreachable when the client YAML leaves it out. + wsOpts := map[string]interface{}{"path": spec.Transport.WSPath} + if spec.Transport.WSHost != "" { + wsOpts["headers"] = map[string]interface{}{"Host": spec.Transport.WSHost} + } + extra["ws-opts"] = wsOpts } if netw == "grpc" && spec.Transport.GRPCService != "" { extra["grpc-opts"] = map[string]interface{}{"grpc-service-name": spec.Transport.GRPCService} diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index 203a6b4..798dca8 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -1,5 +1,7 @@ package protocol +import "strings" + // NodeModel is the strongly-typed node view used by the protocol registry. // Listener.Config remains JSON on disk; DecodeNodeModel maps it into this shape. type NodeModel struct { @@ -42,6 +44,47 @@ type TransportSpec struct { XHTTPPath string } +// CarriesFlow reports whether this transport can carry VLESS flow (XTLS +// Vision). Vision is TCP-only: the core rejects it on ws, grpc and xhttp, and a +// client that honours the flow simply fails to connect. Every place that emits a +// flow has to ask this rather than trust the stored configuration, because a +// listener whose transport was switched after the flow was set keeps the stale +// value. +func (t TransportSpec) CarriesFlow() bool { + switch strings.ToLower(strings.TrimSpace(t.Network)) { + case "", "tcp", "raw": + return true + default: + return false + } +} + +// TransportCarriesFlow answers the same question for a raw listener config, which +// records its transport as the presence of a per-transport block rather than as a +// single named field. +func TransportCarriesFlow(cfg map[string]interface{}) bool { + for _, key := range []string{"ws-path", "grpc-service-name", "xhttp-config", "mkcp-config", "mekya-config"} { + value, ok := cfg[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) != "" { + return false + } + case map[string]interface{}: + if len(typed) > 0 { + return false + } + default: + // A block is present in some other shape; treat it as configured. + return false + } + } + return true +} + type VLESSSpec struct { Encryption string Flow string // default flow applied when user flow empty diff --git a/frontend/src/components/ListenerConfigFields.tsx b/frontend/src/components/ListenerConfigFields.tsx index 17fc4e3..294aca8 100644 --- a/frontend/src/components/ListenerConfigFields.tsx +++ b/frontend/src/components/ListenerConfigFields.tsx @@ -405,6 +405,18 @@ export function formValuesToConfig( cfg[key] = v; }; + // XTLS Vision flow is TCP-only (MetaCubeX / Xray). Decide this *before* any + // field writes the config. The protocol switch below used to write `flow` + // unconditionally and the clear only ran later, so it edited the form state + // after the value had already been copied into the saved listener — the UI + // said the flow was dropped for ws/grpc/xhttp while the share link kept + // advertising it. + const transportLayer = String(values.transport_layer ?? 'raw'); + const flowAllowed = + (transportLayer === 'raw' || transportLayer === 'tcp') && + !values.mkcp_enabled && + !values.mekya_enabled; + switch (protocol) { case 'shadowsocks': set('cipher', values.cipher); @@ -442,7 +454,7 @@ export function formValuesToConfig( set('max-streams', toInt(values['max-streams'])); break; case 'vless': - set('flow', values.flow); + set('flow', flowAllowed ? values.flow : undefined); set('ws-path', values['ws-path']); set('grpc-service-name', values['grpc-service-name']); set('decryption', values.decryption); @@ -604,13 +616,12 @@ export function formValuesToConfig( if (!XHTTP_PROTOCOLS.has(protocol)) { values.xhttp_enabled = false; } - // Vision flow is TCP-only (MetaCubeX / Xray); strip on ws/grpc/xhttp/mkcp/mekya. - const layerNow = values.transport_layer || 'raw'; - if (layerNow !== 'raw' && layerNow !== 'tcp') { - values.flow = undefined; - } - if (values.mkcp_enabled || values.mekya_enabled) { + // Keeps the form state consistent with what was written above, and drops any + // flow that reached cfg by another route. The decision itself was made before + // the protocol switch — see flowAllowed. + if (!flowAllowed) { values.flow = undefined; + delete cfg.flow; } const realityOn = REALITY_PROTOCOLS.has(protocol) && (!!values.reality_enabled || values.security_layer === 'reality'); const wantTLSMaterial = diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a4e3510..864bad2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -77,7 +77,8 @@ export default { "id": "ID", "network": "Network", "bound": "Bound", - "missing": "Missing" + "missing": "Missing", + "deleted": "Deleted", }, "login": { "title": "3M-UI", @@ -471,6 +472,25 @@ export default { "versionHint": "Protocol version (e.g. Snell version, TUIC v4/v5 semantics).", "zero_rttHint": "QUIC 0-RTT (wiki). Lower latency; be aware of replay trade-offs.", + "access_alpnHint": "ALPN list offered to clients on this access profile (wiki: alpn), e.g. h2,http/1.1.", + "access_sniHint": "SNI hostname clients should send on this access profile (wiki: sni).", + "alwaysTlsHint": "This protocol requires TLS. Leave certificate empty to auto-generate a panel self-signed pair on save.", + "applyCertHint": "Writes certificate + private-key into each selected node config. Use panel SSL files or paths under /etc/letsencrypt.", + "clientExportOnlyHint": "Below: client subscription only — not written to Mihomo inbound YAML.", + "from_panel_sslHint": "Use the panel's own SSL material instead of entering certificate paths by hand.", + "jlsHint": "JLS wrapper (wiki jls-config). Advanced; needs its own destination and password.", + "key_fileHint": "Path to the TLS key file, for when the key is not pasted inline.", + "mekyaExclusiveHint": "Requires TCP transport; exclusive with WS/gRPC/mKCP. Enabling clears other transports on save.", + "mkcpExclusiveHint": "Requires TCP transport; exclusive with WS/gRPC/Mekya. Enabling clears other transports on save.", + "protocolHint": "Inbound protocol (wiki). Changing it swaps the transport and security options below.", + "resTlsHint": "resTLS wrapper (wiki res-tls). Advanced; the client must be configured to match.", + "shadowTlsHint": "ShadowTLS wrapper (wiki shadow-tls). Version and password must match the client.", + "tlsMirrorHint": "Advanced. Requires dest and primary-key; mutually exclusive with certificate TLS.", + "tlsPairAutoHint": "Certificate + private-key required. Leave both empty → panel self-signed on save.", + "tlsPairHint": "Certificate and private-key for this listener; leave both empty for no TLS.", + "trafficMultiplierHint": "Charged traffic is the real traffic multiplied by this factor. Default 1; 1.5 counts 50% extra.", + "transportExclusiveHint": "One transport only. mKCP/Mekya (VMess) require TCP.", + "transportXhttpHint": "XHTTP is available for VLESS.", }, "core": { "title": "Core", @@ -1006,6 +1026,10 @@ export default { "public_urlHint": "Public base URL of the panel (used in OAuth redirect, subscription links, etc.). Include https://.", "web_pathHint": "Optional URL path prefix for the panel UI.", + "restoreAutoReload": "Auto-reloading in 5s…", + "restoreTimeout": "Panel did not come back in 30s; please refresh manually.", + "switchToPre": "Switch to pre-release channel", + "switchToStable": "Switch to stable channel", }, "share": { "title": "Share / Subscription", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b513806..0d80183 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -77,7 +77,8 @@ export default { "id": "ID", "network": "网络", "bound": "已绑定", - "missing": "缺失" + "missing": "缺失", + "deleted": "已删除", }, "login": { "title": "3M-UI", @@ -452,18 +453,44 @@ export default { "trafficMultiplier": "流量倍率", "allow_insecureHint": "客户端导出允许不安全 TLS(wiki allow-insecure)。", "alpnHint": "TLS/QUIC ALPN(wiki: alpn),如 h2、http/1.1。", + "client_auth_certHint": "启用客户端认证时的 CA / 证书内容。", + "client_auth_typeHint": "TLS 客户端认证类型(wiki client-auth)。", "congestion_controllerHint": "QUIC 拥塞控制(wiki: congestion-controller)。", + "ech_keyHint": "配置 Encrypted Client Hello (ECH) 时的密钥内容。", + "enable_pure_downlinkHint": "协议支持时的纯下行开关(高级选项)。", "enabledHint": "关闭后该 Listener 不会写入运行中的核心配置。", "grpcServiceNameHint": "gRPC service name(wiki grpc-opts)。", + "muxHint": "多路复用选项(wiki smux/mux),高级选项。", "obfs_passwordHint": "混淆密码(wiki: obfs-password),需与客户端一致。", + "paddingHint": "协议支持时的填充相关选项。", "private_keyHint": "TLS 私钥 PEM 或路径(wiki: private-key)。", "skip_cert_verifyHint": "仅影响客户端导出的 skip-cert-verify,不是服务端入站字段。", "snellVersionHint": "内核支持的 Snell 协议版本。", + "table_typeHint": "该协议路径下的高级 table/type 选项。", "tokenHint": "TUIC v4 token(wiki tuic-v4)。v5 用 UUID 用户可留空。", "transport_layerHint": "传输层(wiki network/transport):raw/TCP、ws、grpc、xhttp、mkcp… 仅能选一种。", "versionHint": "协议版本(如 Snell 版本,TUIC v4/v5 语义)。", "zero_rttHint": "QUIC 0-RTT(wiki)。延迟更低,需注意重放风险。", + "access_alpnHint": "该接入配置向客户端提供的 ALPN 列表(wiki: alpn),如 h2、http/1.1。", + "access_sniHint": "该接入配置下客户端应发送的 SNI 主机名(wiki: sni)。", + "alwaysTlsHint": "该协议必须使用 TLS。证书留空时保存会自动生成面板自签证书对。", + "applyCertHint": "把证书与私钥写入所选节点的配置。可使用面板 SSL 文件,或 /etc/letsencrypt 下的路径。", + "clientExportOnlyHint": "以下仅用于客户端订阅,不会写入 Mihomo 入站 YAML。", + "from_panel_sslHint": "使用面板自身的 SSL 证书,无需手工填写证书路径。", + "jlsHint": "JLS 包装(wiki jls-config)。高级选项,需单独配置目标与密码。", + "key_fileHint": "TLS 私钥文件路径,用于不直接粘贴私钥内容的场景。", + "mekyaExclusiveHint": "需要 TCP 传输层,与 WS/gRPC/mKCP 互斥。启用后保存时会清除其它传输层。", + "mkcpExclusiveHint": "需要 TCP 传输层,与 WS/gRPC/Mekya 互斥。启用后保存时会清除其它传输层。", + "protocolHint": "入站协议(wiki)。切换后下面的传输层与安全层选项会随之变化。", + "resTlsHint": "resTLS 包装(wiki res-tls)。高级选项,客户端需做对应配置。", + "shadowTlsHint": "ShadowTLS 包装(wiki shadow-tls)。版本与密码需与客户端一致。", + "tlsMirrorHint": "高级选项。需要 dest 与 primary-key,与证书型 TLS 互斥。", + "tlsPairAutoHint": "证书与私钥为必填。两者都留空时保存会生成面板自签证书。", + "tlsPairHint": "该监听器的证书与私钥对;两者都留空表示不使用 TLS。", + "trafficMultiplierHint": "实际流量 × 倍率计入用户配额。默认 1,例如 1.5 表示多计 50%。", + "transportExclusiveHint": "仅能选择一种传输层。mKCP/Mekya(VMess)需要 TCP。", + "transportXhttpHint": "XHTTP 仅适用于 VLESS。", }, "core": { "title": "核心管理", @@ -999,6 +1026,10 @@ export default { "public_urlHint": "面板公网根 URL(OAuth 回调、订阅拼接等)。需含 https://。", "web_pathHint": "面板 UI 可选路径前缀。", + "restoreAutoReload": "5 秒后自动刷新…", + "restoreTimeout": "面板 30 秒内未恢复,请手动刷新页面。", + "switchToPre": "切换到预发布通道", + "switchToStable": "切换到稳定通道", }, "share": { "title": "分享 / 订阅", diff --git a/frontend/src/utils/i18nLocales.test.mjs b/frontend/src/utils/i18nLocales.test.mjs new file mode 100644 index 0000000..186c050 --- /dev/null +++ b/frontend/src/utils/i18nLocales.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; + +// Locale files contain minified regions — several "key":"value" pairs on a single +// line — so they have to be read through the TypeScript AST. A line-based parser +// silently swallows those keys, which is how the gap this file guards went +// unnoticed: 24 keys were referenced from components without ever being declared, +// so `t()` fell through to its hardcoded fallback and, where there was none, +// rendered the raw key in the UI. +const localeDir = new URL('../i18n/locales/', import.meta.url); +const srcDir = new URL('../', import.meta.url); + +function parseLocale(name) { + const file = new URL(name, localeDir); + const src = ts.createSourceFile(name, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + let root = null; + for (const statement of src.statements) { + if (!ts.isExportAssignment(statement)) continue; + let expr = statement.expression; + if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) expr = expr.expression; + if (ts.isObjectLiteralExpression(expr)) root = expr; + } + assert.ok(root, `${name} must default-export an object literal`); + + const keys = new Map(); + const duplicates = []; + const walk = (object, stack) => { + const seen = new Set(); + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const nameNode = property.name; + const key = ts.isStringLiteral(nameNode) || ts.isIdentifier(nameNode) ? nameNode.text : null; + if (key == null) continue; + if (seen.has(key)) duplicates.push([...stack, key].join('.')); + seen.add(key); + const full = [...stack, key].join('.'); + if (ts.isObjectLiteralExpression(property.initializer)) walk(property.initializer, [...stack, key]); + else if (ts.isStringLiteral(property.initializer)) keys.set(full, property.initializer.text); + } + }; + walk(root, []); + return { keys, duplicates }; +} + +/** Dotted keys handed to t() or fieldTip() anywhere in src. */ +function referencedKeys() { + const found = new Map(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const target = new URL(entry.name + (entry.isDirectory() ? '/' : ''), dir); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') visit(target); + continue; + } + if (!entry.name.endsWith('.ts') && !entry.name.endsWith('.tsx')) continue; + const source = ts.createSourceFile( + entry.name, + fs.readFileSync(target, 'utf8'), + ts.ScriptTarget.Latest, + true, + entry.name.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const walkNode = (node) => { + if (ts.isCallExpression(node) && node.arguments.length && ts.isStringLiteral(node.arguments[0])) { + const callee = node.expression.getText(source); + if (/(^|\.)t$/.test(callee) || callee === 'fieldTip') { + const key = node.arguments[0].text; + if (key.includes('.')) { + if (!found.has(key)) found.set(key, new Set()); + found.get(key).add(`${path.relative(fs.realpathSync(srcDir), fs.realpathSync(target.pathname))}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}`); + } + } + } + ts.forEachChild(node, walkNode); + }; + walkNode(source); + } + }; + visit(srcDir); + return found; +} + +const en = parseLocale('en.ts'); +const references = referencedKeys(); + +test('the locale parser sees the whole catalogue', () => { + assert.ok(en.keys.size > 1000, `en.ts should expose ~1000 keys, parsed ${en.keys.size}`); +}); + +// The reported defect: a key used by a component but never declared, so the +// tooltip either vanished (fieldTip) or showed the raw key (t). +test('every key referenced from code is declared in en.ts', () => { + const missing = [...references.keys()].filter((k) => !en.keys.has(k)).sort(); + assert.deepEqual(missing, [], `undeclared keys: ${missing.map((k) => `${k} (${[...references.get(k)][0]})`).join(', ')}`); +}); + +test('no locale declares the same key twice', () => { + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + assert.deepEqual(parseLocale(file).duplicates, [], `${file} has duplicate keys`); + } +}); + +test('no locale keeps a key en.ts no longer has', () => { + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + if (file === 'en.ts') continue; + const orphans = [...parseLocale(file).keys.keys()].filter((k) => !en.keys.has(k)); + assert.deepEqual(orphans, [], `${file} has keys missing from en.ts: ${orphans.join(', ')}`); + } +}); + +// Chinese is the primary UI language; a gap there reads as a defect, and English +// fallback would look broken rather than merely untranslated. +test('zh-CN covers every key en.ts declares', () => { + const zh = parseLocale('zh-CN.ts').keys; + const missing = [...en.keys.keys()].filter((k) => !zh.has(k)).sort(); + assert.deepEqual(missing, [], `zh-CN is missing: ${missing.join(', ')}`); +}); + +test('no locale carries an empty or non-string value', () => { + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + const { keys } = parseLocale(file); + for (const [key, value] of keys) { + assert.ok(value.trim() !== '', `${file}: ${key} is empty`); + } + } +}); + +test('other locales fall back to English for keys they lack', () => { + // Informational: these lag on purpose and the provider resolves them from + // en.ts. Printed so the gap stays visible instead of being forgotten. + const lagging = []; + for (const file of fs.readdirSync(localeDir).filter((f) => f.endsWith('.ts') && f !== 'zh.ts').sort()) { + if (file === 'en.ts' || file === 'zh-CN.ts') continue; + const missing = [...en.keys.keys()].filter((k) => !parseLocale(file).keys.has(k)).length; + if (missing > 0) lagging.push(`${file}:${missing}`); + } + if (lagging.length) console.log(' untranslated (falls back to en):', lagging.join(' ')); + assert.ok(true); +}); diff --git a/frontend/src/utils/listenerFlowTransport.test.mjs b/frontend/src/utils/listenerFlowTransport.test.mjs new file mode 100644 index 0000000..51bd8d0 --- /dev/null +++ b/frontend/src/utils/listenerFlowTransport.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; + +// Executes the real form builder instead of reimplementing the rule. The bug +// being guarded lived in the ordering inside that function: the flow was written +// into the config before the transport was consulted, so "cleared for +// ws/grpc/xhttp" only ever edited the form state and the saved listener — and +// therefore every share link built from it — kept advertising a flow no client +// can use. +const source = ts.createSourceFile( + 'ListenerConfigFields.tsx', + fs.readFileSync(new URL('../components/ListenerConfigFields.tsx', import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); + +// Pure data and helpers only: every top-level `new Set([...])` / array literal +// and every plain helper function. React components, hooks and anything touching +// JSX are deliberately left out of the harness. +const pieces = []; +ts.forEachChild(source, (node) => { + // Skip React components: capitalised, or containing JSX tags. Type + // annotations like Record also contain '<', so match tags only. + const isPlainFunction = + ts.isFunctionDeclaration(node) && + !node.name?.getText(source).match(/^[A-Z]/) && + !node.getText(source).includes(''); + const isDataStatement = + ts.isVariableStatement(node) && + (node.getText(source).includes('new Set(') || /^const [A-Z_]+ = \[/.test(node.getText(source))); + if (!isPlainFunction && !isDataStatement) return; + // `export function` is only valid in a module; this harness runs a script. + pieces.push(node.getText(source).replace(/^export\s+/, '')); +}); +assert.ok(pieces.length > 10, `expected the builder's dependencies, got ${pieces.length} pieces`); + +const javascript = ts.transpileModule( + `${pieces.join('\n')}\nglobalThis.formValuesToConfig = formValuesToConfig;`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, +).outputText; + +const sandbox = {}; +vm.createContext(sandbox); +vm.runInContext(javascript, sandbox); +const formValuesToConfig = sandbox.formValuesToConfig; +assert.equal(typeof formValuesToConfig, 'function', 'form builder must load'); + +const baseValues = (overrides) => ({ + transport_layer: 'raw', + security_layer: 'tls', + flow: 'xtls-rprx-vision', + ...overrides, +}); + +test('flow survives on a raw/TCP VLESS listener', () => { + const cfg = formValuesToConfig('vless', baseValues({})); + assert.equal(cfg.flow, 'xtls-rprx-vision', 'raw transport must keep flow'); +}); + +test('flow is dropped for ws, grpc and xhttp', () => { + const cases = [ + ['ws', { transport_layer: 'ws', 'ws-path': '/ws' }], + ['grpc', { transport_layer: 'grpc', 'grpc-service-name': 'svc' }], + ['xhttp', { transport_layer: 'xhttp', xhttp_config_path: '/x' }], + ]; + for (const [name, overrides] of cases) { + const cfg = formValuesToConfig('vless', baseValues(overrides)); + assert.equal(cfg.flow, undefined, `${name} must not carry flow into the saved config`); + assert.equal('flow' in cfg, false, `${name} must not emit a flow key at all`); + } +}); + +test('flow is dropped for mkcp and mekya transports', () => { + for (const flag of ['mkcp_enabled', 'mekya_enabled']) { + const cfg = formValuesToConfig('vless', baseValues({ [flag]: true })); + assert.equal('flow' in cfg, false, `${flag} must not carry flow`); + } +}); + +test('a flow arriving from the previous config is dropped for ws', () => { + const cfg = formValuesToConfig( + 'vless', + baseValues({ transport_layer: 'ws', 'ws-path': '/ws' }), + { flow: 'xtls-rprx-vision', 'ws-path': '/ws' }, + ); + assert.equal('flow' in cfg, false, 'a stale flow must not survive a transport change'); +}); + +test('an unknown transport keeps flow, so TCP listeners are unaffected', () => { + // The form defaults to raw when the layer is not recorded; failing that way + // is deliberate, since a TCP listener must not lose its Vision flow. + const cfg = formValuesToConfig('vless', baseValues({ transport_layer: undefined })); + assert.equal(cfg.flow, 'xtls-rprx-vision'); +});