diff --git a/.github/update.log b/.github/update.log index 8bd4df99a3..437dd958e5 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1209,3 +1209,4 @@ Update On Mon Dec 8 19:42:31 CET 2025 Update On Tue Dec 9 19:39:21 CET 2025 Update On Wed Dec 10 19:41:17 CET 2025 Update On Thu Dec 11 19:44:07 CET 2025 +Update On Fri Dec 12 19:42:07 CET 2025 diff --git a/clash-meta/component/ech/echparser/echparser.go b/clash-meta/component/ech/echparser/echparser.go new file mode 100644 index 0000000000..4d3b213f9a --- /dev/null +++ b/clash-meta/component/ech/echparser/echparser.go @@ -0,0 +1,147 @@ +package echparser + +import ( + "errors" + "fmt" + + "golang.org/x/crypto/cryptobyte" +) + +// export from std's crypto/tls/ech.go + +const extensionEncryptedClientHello = 0xfe0d + +type ECHCipher struct { + KDFID uint16 + AEADID uint16 +} + +type ECHExtension struct { + Type uint16 + Data []byte +} + +type ECHConfig struct { + raw []byte + + Version uint16 + Length uint16 + + ConfigID uint8 + KemID uint16 + PublicKey []byte + SymmetricCipherSuite []ECHCipher + + MaxNameLength uint8 + PublicName []byte + Extensions []ECHExtension +} + +var ErrMalformedECHConfigList = errors.New("tls: malformed ECHConfigList") + +type EchConfigErr struct { + field string +} + +func (e *EchConfigErr) Error() string { + if e.field == "" { + return "tls: malformed ECHConfig" + } + return fmt.Sprintf("tls: malformed ECHConfig, invalid %s field", e.field) +} + +func ParseECHConfig(enc []byte) (skip bool, ec ECHConfig, err error) { + s := cryptobyte.String(enc) + ec.raw = []byte(enc) + if !s.ReadUint16(&ec.Version) { + return false, ECHConfig{}, &EchConfigErr{"version"} + } + if !s.ReadUint16(&ec.Length) { + return false, ECHConfig{}, &EchConfigErr{"length"} + } + if len(ec.raw) < int(ec.Length)+4 { + return false, ECHConfig{}, &EchConfigErr{"length"} + } + ec.raw = ec.raw[:ec.Length+4] + if ec.Version != extensionEncryptedClientHello { + s.Skip(int(ec.Length)) + return true, ECHConfig{}, nil + } + if !s.ReadUint8(&ec.ConfigID) { + return false, ECHConfig{}, &EchConfigErr{"config_id"} + } + if !s.ReadUint16(&ec.KemID) { + return false, ECHConfig{}, &EchConfigErr{"kem_id"} + } + if !s.ReadUint16LengthPrefixed((*cryptobyte.String)(&ec.PublicKey)) { + return false, ECHConfig{}, &EchConfigErr{"public_key"} + } + var cipherSuites cryptobyte.String + if !s.ReadUint16LengthPrefixed(&cipherSuites) { + return false, ECHConfig{}, &EchConfigErr{"cipher_suites"} + } + for !cipherSuites.Empty() { + var c ECHCipher + if !cipherSuites.ReadUint16(&c.KDFID) { + return false, ECHConfig{}, &EchConfigErr{"cipher_suites kdf_id"} + } + if !cipherSuites.ReadUint16(&c.AEADID) { + return false, ECHConfig{}, &EchConfigErr{"cipher_suites aead_id"} + } + ec.SymmetricCipherSuite = append(ec.SymmetricCipherSuite, c) + } + if !s.ReadUint8(&ec.MaxNameLength) { + return false, ECHConfig{}, &EchConfigErr{"maximum_name_length"} + } + var publicName cryptobyte.String + if !s.ReadUint8LengthPrefixed(&publicName) { + return false, ECHConfig{}, &EchConfigErr{"public_name"} + } + ec.PublicName = publicName + var extensions cryptobyte.String + if !s.ReadUint16LengthPrefixed(&extensions) { + return false, ECHConfig{}, &EchConfigErr{"extensions"} + } + for !extensions.Empty() { + var e ECHExtension + if !extensions.ReadUint16(&e.Type) { + return false, ECHConfig{}, &EchConfigErr{"extensions type"} + } + if !extensions.ReadUint16LengthPrefixed((*cryptobyte.String)(&e.Data)) { + return false, ECHConfig{}, &EchConfigErr{"extensions data"} + } + ec.Extensions = append(ec.Extensions, e) + } + + return false, ec, nil +} + +// ParseECHConfigList parses a draft-ietf-tls-esni-18 ECHConfigList, returning a +// slice of parsed ECHConfigs, in the same order they were parsed, or an error +// if the list is malformed. +func ParseECHConfigList(data []byte) ([]ECHConfig, error) { + s := cryptobyte.String(data) + var length uint16 + if !s.ReadUint16(&length) { + return nil, ErrMalformedECHConfigList + } + if length != uint16(len(data)-2) { + return nil, ErrMalformedECHConfigList + } + var configs []ECHConfig + for len(s) > 0 { + if len(s) < 4 { + return nil, errors.New("tls: malformed ECHConfig") + } + configLen := uint16(s[2])<<8 | uint16(s[3]) + skip, ec, err := ParseECHConfig(s) + if err != nil { + return nil, err + } + s = s[configLen+4:] + if !skip { + configs = append(configs, ec) + } + } + return configs, nil +} diff --git a/clash-meta/component/ech/key_test.go b/clash-meta/component/ech/key_test.go new file mode 100644 index 0000000000..d6097aa031 --- /dev/null +++ b/clash-meta/component/ech/key_test.go @@ -0,0 +1,30 @@ +package ech + +import ( + "encoding/base64" + "testing" + + "github.com/metacubex/mihomo/component/ech/echparser" +) + +func TestGenECHConfig(t *testing.T) { + domain := "www.example.com" + configBase64, _, err := GenECHConfig(domain) + if err != nil { + t.Error(err) + } + echConfigList, err := base64.StdEncoding.DecodeString(configBase64) + if err != nil { + t.Error(err) + } + echConfigs, err := echparser.ParseECHConfigList(echConfigList) + if err != nil { + t.Error(err) + } + if len(echConfigs) == 0 { + t.Error("no ech config") + } + if publicName := string(echConfigs[0].PublicName); publicName != domain { + t.Error("ech config domain error, expect ", domain, " got", publicName) + } +} diff --git a/clash-meta/dns/resolver.go b/clash-meta/dns/resolver.go index f7d4d42968..fd51507dfb 100644 --- a/clash-meta/dns/resolver.go +++ b/clash-meta/dns/resolver.go @@ -165,11 +165,9 @@ func (r *Resolver) ExchangeContext(ctx context.Context, m *D.Msg) (msg *D.Msg, e q := m.Question[0] domain := msgToDomain(m) - _, qTypeStr := msgToQtype(m) cacheM, expireTime, hit := r.cache.GetWithExpire(q.String()) if hit { - ips := msgToIP(cacheM) - log.Debugln("[DNS] cache hit %s --> %s %s, expire at %s", domain, ips, qTypeStr, expireTime.Format("2006-01-02 15:04:05")) + log.Debugln("[DNS] cache hit %s --> %s, expire at %s", domain, msgToLogString(cacheM), expireTime.Format("2006-01-02 15:04:05")) now := time.Now() msg = cacheM.Copy() if expireTime.Before(now) { diff --git a/clash-meta/dns/util.go b/clash-meta/dns/util.go index 12dd59b4ab..e960a8e198 100644 --- a/clash-meta/dns/util.go +++ b/clash-meta/dns/util.go @@ -9,6 +9,7 @@ import ( "time" "github.com/metacubex/mihomo/common/picker" + "github.com/metacubex/mihomo/component/ech/echparser" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/log" @@ -226,6 +227,81 @@ func msgToQtype(msg *D.Msg) (uint16, string) { return 0, "" } +func msgToHTTPSRRInfo(msg *D.Msg) string { + var alpns []string + var publicName string + var hasIPv4, hasIPv6 bool + + collect := func(rrs []D.RR) { + for _, rr := range rrs { + httpsRR, ok := rr.(*D.HTTPS) + if !ok { + continue + } + + for _, kv := range httpsRR.Value { + switch v := kv.(type) { + case *D.SVCBAlpn: + if len(alpns) == 0 && len(v.Alpn) > 0 { + alpns = append(alpns, v.Alpn...) + } + case *D.SVCBIPv4Hint: + if len(v.Hint) > 0 { + hasIPv4 = true + } + case *D.SVCBIPv6Hint: + if len(v.Hint) > 0 { + hasIPv6 = true + } + case *D.SVCBECHConfig: + if publicName == "" && len(v.ECH) > 0 { + if cfgs, err := echparser.ParseECHConfigList(v.ECH); err == nil && len(cfgs) > 0 { + publicName = string(cfgs[0].PublicName) + } + } + } + } + } + } + + collect(msg.Answer) + + //TODO: Do we need to process the data in msg.Extra? + // If so, do we need to validate whether the domain names within it match our request? + // To simplify the problem, let's ignore it for now. + //collect(msg.Extra) + + if len(alpns) == 0 && publicName == "" && !hasIPv4 && !hasIPv6 { + return "" + } + + var parts []string + if len(alpns) > 0 { + parts = append(parts, "alpn:"+strings.Join(alpns, ",")) + } + if publicName != "" { + parts = append(parts, "pn:"+publicName) + } + if hasIPv4 { + parts = append(parts, "ipv4hint") + } + if hasIPv6 { + parts = append(parts, "ipv6hint") + } + + return strings.Join(parts, ";") +} + +func msgToLogString(msg *D.Msg) string { + qType, qTypeStr := msgToQtype(msg) + switch qType { + case D.TypeHTTPS: + return fmt.Sprintf("[%s] %s", msgToHTTPSRRInfo(msg), qTypeStr) + default: + return fmt.Sprintf("%s %s", msgToIP(msg), qTypeStr) + } +} + func batchExchange(ctx context.Context, clients []dnsClient, m *D.Msg) (msg *D.Msg, cache bool, err error) { cache = true fast, ctx := picker.WithTimeout[*D.Msg](ctx, resolver.DefaultDNSTimeout) @@ -248,8 +324,7 @@ func batchExchange(ctx context.Context, clients []dnsClient, m *D.Msg) (msg *D.M // so we would ignore RCode errors from RCode clients. return nil, errors.New("server failure: " + D.RcodeToString[m.Rcode]) } - ips := msgToIP(m) - log.Debugln("[DNS] %s --> %s %s from %s", domain, ips, qTypeStr, client.Address()) + log.Debugln("[DNS] %s --> %s from %s", domain, msgToLogString(m), client.Address()) return m, nil }) } diff --git a/clash-nyanpasu/backend/tauri/src/config/draft.rs b/clash-nyanpasu/backend/tauri/src/config/draft.rs index 2b5f9aeec9..e95f5ac87e 100644 --- a/clash-nyanpasu/backend/tauri/src/config/draft.rs +++ b/clash-nyanpasu/backend/tauri/src/config/draft.rs @@ -69,6 +69,16 @@ draft_define!(IClashTemp); draft_define!(IRuntime); draft_define!(IVerge); +impl Draft { + /// Reload configuration from file + pub fn reload(&self) { + let new_config = IClashTemp::new(); + let mut inner = self.inner.lock(); + inner.0 = new_config; + inner.1 = None; // Clear any draft + } +} + #[test] fn test_draft() { let verge = IVerge { diff --git a/clash-nyanpasu/backend/tauri/src/core/clash/core.rs b/clash-nyanpasu/backend/tauri/src/core/clash/core.rs index fa0919c27d..4df3ceae0f 100644 --- a/clash-nyanpasu/backend/tauri/src/core/clash/core.rs +++ b/clash-nyanpasu/backend/tauri/src/core/clash/core.rs @@ -457,6 +457,13 @@ impl CoreManager { } } + // Reload clash config from file to get latest user preferences (e.g., mode) + Config::clash().reload(); + log::debug!(target: "app", "reloaded clash config from file"); + + // Regenerate runtime config with the reloaded settings + Config::generate().await?; + // 检查端口是否可用 Config::clash() .latest() diff --git a/clash-nyanpasu/manifest/version.json b/clash-nyanpasu/manifest/version.json index 07792ee95d..8813a4b219 100644 --- a/clash-nyanpasu/manifest/version.json +++ b/clash-nyanpasu/manifest/version.json @@ -2,7 +2,7 @@ "manifest_version": 1, "latest": { "mihomo": "v1.19.17", - "mihomo_alpha": "alpha-2211789", + "mihomo_alpha": "alpha-b753a57", "clash_rs": "v0.9.3", "clash_premium": "2023-09-05-gdcc8d87", "clash_rs_alpha": "0.9.3-alpha+sha.a6538ac" @@ -69,5 +69,5 @@ "linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf" } }, - "updated_at": "2025-12-10T22:21:46.086Z" + "updated_at": "2025-12-11T22:21:52.302Z" } diff --git a/mihomo/component/ech/echparser/echparser.go b/mihomo/component/ech/echparser/echparser.go new file mode 100644 index 0000000000..4d3b213f9a --- /dev/null +++ b/mihomo/component/ech/echparser/echparser.go @@ -0,0 +1,147 @@ +package echparser + +import ( + "errors" + "fmt" + + "golang.org/x/crypto/cryptobyte" +) + +// export from std's crypto/tls/ech.go + +const extensionEncryptedClientHello = 0xfe0d + +type ECHCipher struct { + KDFID uint16 + AEADID uint16 +} + +type ECHExtension struct { + Type uint16 + Data []byte +} + +type ECHConfig struct { + raw []byte + + Version uint16 + Length uint16 + + ConfigID uint8 + KemID uint16 + PublicKey []byte + SymmetricCipherSuite []ECHCipher + + MaxNameLength uint8 + PublicName []byte + Extensions []ECHExtension +} + +var ErrMalformedECHConfigList = errors.New("tls: malformed ECHConfigList") + +type EchConfigErr struct { + field string +} + +func (e *EchConfigErr) Error() string { + if e.field == "" { + return "tls: malformed ECHConfig" + } + return fmt.Sprintf("tls: malformed ECHConfig, invalid %s field", e.field) +} + +func ParseECHConfig(enc []byte) (skip bool, ec ECHConfig, err error) { + s := cryptobyte.String(enc) + ec.raw = []byte(enc) + if !s.ReadUint16(&ec.Version) { + return false, ECHConfig{}, &EchConfigErr{"version"} + } + if !s.ReadUint16(&ec.Length) { + return false, ECHConfig{}, &EchConfigErr{"length"} + } + if len(ec.raw) < int(ec.Length)+4 { + return false, ECHConfig{}, &EchConfigErr{"length"} + } + ec.raw = ec.raw[:ec.Length+4] + if ec.Version != extensionEncryptedClientHello { + s.Skip(int(ec.Length)) + return true, ECHConfig{}, nil + } + if !s.ReadUint8(&ec.ConfigID) { + return false, ECHConfig{}, &EchConfigErr{"config_id"} + } + if !s.ReadUint16(&ec.KemID) { + return false, ECHConfig{}, &EchConfigErr{"kem_id"} + } + if !s.ReadUint16LengthPrefixed((*cryptobyte.String)(&ec.PublicKey)) { + return false, ECHConfig{}, &EchConfigErr{"public_key"} + } + var cipherSuites cryptobyte.String + if !s.ReadUint16LengthPrefixed(&cipherSuites) { + return false, ECHConfig{}, &EchConfigErr{"cipher_suites"} + } + for !cipherSuites.Empty() { + var c ECHCipher + if !cipherSuites.ReadUint16(&c.KDFID) { + return false, ECHConfig{}, &EchConfigErr{"cipher_suites kdf_id"} + } + if !cipherSuites.ReadUint16(&c.AEADID) { + return false, ECHConfig{}, &EchConfigErr{"cipher_suites aead_id"} + } + ec.SymmetricCipherSuite = append(ec.SymmetricCipherSuite, c) + } + if !s.ReadUint8(&ec.MaxNameLength) { + return false, ECHConfig{}, &EchConfigErr{"maximum_name_length"} + } + var publicName cryptobyte.String + if !s.ReadUint8LengthPrefixed(&publicName) { + return false, ECHConfig{}, &EchConfigErr{"public_name"} + } + ec.PublicName = publicName + var extensions cryptobyte.String + if !s.ReadUint16LengthPrefixed(&extensions) { + return false, ECHConfig{}, &EchConfigErr{"extensions"} + } + for !extensions.Empty() { + var e ECHExtension + if !extensions.ReadUint16(&e.Type) { + return false, ECHConfig{}, &EchConfigErr{"extensions type"} + } + if !extensions.ReadUint16LengthPrefixed((*cryptobyte.String)(&e.Data)) { + return false, ECHConfig{}, &EchConfigErr{"extensions data"} + } + ec.Extensions = append(ec.Extensions, e) + } + + return false, ec, nil +} + +// ParseECHConfigList parses a draft-ietf-tls-esni-18 ECHConfigList, returning a +// slice of parsed ECHConfigs, in the same order they were parsed, or an error +// if the list is malformed. +func ParseECHConfigList(data []byte) ([]ECHConfig, error) { + s := cryptobyte.String(data) + var length uint16 + if !s.ReadUint16(&length) { + return nil, ErrMalformedECHConfigList + } + if length != uint16(len(data)-2) { + return nil, ErrMalformedECHConfigList + } + var configs []ECHConfig + for len(s) > 0 { + if len(s) < 4 { + return nil, errors.New("tls: malformed ECHConfig") + } + configLen := uint16(s[2])<<8 | uint16(s[3]) + skip, ec, err := ParseECHConfig(s) + if err != nil { + return nil, err + } + s = s[configLen+4:] + if !skip { + configs = append(configs, ec) + } + } + return configs, nil +} diff --git a/mihomo/component/ech/key_test.go b/mihomo/component/ech/key_test.go new file mode 100644 index 0000000000..d6097aa031 --- /dev/null +++ b/mihomo/component/ech/key_test.go @@ -0,0 +1,30 @@ +package ech + +import ( + "encoding/base64" + "testing" + + "github.com/metacubex/mihomo/component/ech/echparser" +) + +func TestGenECHConfig(t *testing.T) { + domain := "www.example.com" + configBase64, _, err := GenECHConfig(domain) + if err != nil { + t.Error(err) + } + echConfigList, err := base64.StdEncoding.DecodeString(configBase64) + if err != nil { + t.Error(err) + } + echConfigs, err := echparser.ParseECHConfigList(echConfigList) + if err != nil { + t.Error(err) + } + if len(echConfigs) == 0 { + t.Error("no ech config") + } + if publicName := string(echConfigs[0].PublicName); publicName != domain { + t.Error("ech config domain error, expect ", domain, " got", publicName) + } +} diff --git a/mihomo/dns/resolver.go b/mihomo/dns/resolver.go index f7d4d42968..fd51507dfb 100644 --- a/mihomo/dns/resolver.go +++ b/mihomo/dns/resolver.go @@ -165,11 +165,9 @@ func (r *Resolver) ExchangeContext(ctx context.Context, m *D.Msg) (msg *D.Msg, e q := m.Question[0] domain := msgToDomain(m) - _, qTypeStr := msgToQtype(m) cacheM, expireTime, hit := r.cache.GetWithExpire(q.String()) if hit { - ips := msgToIP(cacheM) - log.Debugln("[DNS] cache hit %s --> %s %s, expire at %s", domain, ips, qTypeStr, expireTime.Format("2006-01-02 15:04:05")) + log.Debugln("[DNS] cache hit %s --> %s, expire at %s", domain, msgToLogString(cacheM), expireTime.Format("2006-01-02 15:04:05")) now := time.Now() msg = cacheM.Copy() if expireTime.Before(now) { diff --git a/mihomo/dns/util.go b/mihomo/dns/util.go index 12dd59b4ab..e960a8e198 100644 --- a/mihomo/dns/util.go +++ b/mihomo/dns/util.go @@ -9,6 +9,7 @@ import ( "time" "github.com/metacubex/mihomo/common/picker" + "github.com/metacubex/mihomo/component/ech/echparser" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/log" @@ -226,6 +227,81 @@ func msgToQtype(msg *D.Msg) (uint16, string) { return 0, "" } +func msgToHTTPSRRInfo(msg *D.Msg) string { + var alpns []string + var publicName string + var hasIPv4, hasIPv6 bool + + collect := func(rrs []D.RR) { + for _, rr := range rrs { + httpsRR, ok := rr.(*D.HTTPS) + if !ok { + continue + } + + for _, kv := range httpsRR.Value { + switch v := kv.(type) { + case *D.SVCBAlpn: + if len(alpns) == 0 && len(v.Alpn) > 0 { + alpns = append(alpns, v.Alpn...) + } + case *D.SVCBIPv4Hint: + if len(v.Hint) > 0 { + hasIPv4 = true + } + case *D.SVCBIPv6Hint: + if len(v.Hint) > 0 { + hasIPv6 = true + } + case *D.SVCBECHConfig: + if publicName == "" && len(v.ECH) > 0 { + if cfgs, err := echparser.ParseECHConfigList(v.ECH); err == nil && len(cfgs) > 0 { + publicName = string(cfgs[0].PublicName) + } + } + } + } + } + } + + collect(msg.Answer) + + //TODO: Do we need to process the data in msg.Extra? + // If so, do we need to validate whether the domain names within it match our request? + // To simplify the problem, let's ignore it for now. + //collect(msg.Extra) + + if len(alpns) == 0 && publicName == "" && !hasIPv4 && !hasIPv6 { + return "" + } + + var parts []string + if len(alpns) > 0 { + parts = append(parts, "alpn:"+strings.Join(alpns, ",")) + } + if publicName != "" { + parts = append(parts, "pn:"+publicName) + } + if hasIPv4 { + parts = append(parts, "ipv4hint") + } + if hasIPv6 { + parts = append(parts, "ipv6hint") + } + + return strings.Join(parts, ";") +} + +func msgToLogString(msg *D.Msg) string { + qType, qTypeStr := msgToQtype(msg) + switch qType { + case D.TypeHTTPS: + return fmt.Sprintf("[%s] %s", msgToHTTPSRRInfo(msg), qTypeStr) + default: + return fmt.Sprintf("%s %s", msgToIP(msg), qTypeStr) + } +} + func batchExchange(ctx context.Context, clients []dnsClient, m *D.Msg) (msg *D.Msg, cache bool, err error) { cache = true fast, ctx := picker.WithTimeout[*D.Msg](ctx, resolver.DefaultDNSTimeout) @@ -248,8 +324,7 @@ func batchExchange(ctx context.Context, clients []dnsClient, m *D.Msg) (msg *D.M // so we would ignore RCode errors from RCode clients. return nil, errors.New("server failure: " + D.RcodeToString[m.Rcode]) } - ips := msgToIP(m) - log.Debugln("[DNS] %s --> %s %s from %s", domain, ips, qTypeStr, client.Address()) + log.Debugln("[DNS] %s --> %s from %s", domain, msgToLogString(m), client.Address()) return m, nil }) } diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua index ec3c609878..03c8274e8b 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua @@ -9,6 +9,8 @@ if not arg[1] or not m:get(arg[1]) then luci.http.redirect(m.redirect) end +m:append(Template(appname .. "/cbi/nodes_multivalue_com")) + s = m:section(NamedSection, arg[1], "nodes", "") s.addremove = false s.dynamic = false diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua index 26867b1a0f..553177fd09 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/socks_config.lua @@ -9,6 +9,8 @@ if not arg[1] or not m:get(arg[1]) then luci.http.redirect(m.redirect) end +m:append(Template(appname .. "/cbi/nodes_multivalue_com")) + local has_singbox = api.finded_com("sing-box") local has_xray = api.finded_com("xray") @@ -94,7 +96,7 @@ o:depends("enable_autoswitch", true) o = s:option(MultiValue, "autoswitch_backup_node", translate("List of backup nodes")) o:depends("enable_autoswitch", true) o.widget = "checkbox" -o.template = appname .. "/cbi/nodes_multiselect" +o.template = appname .. "/cbi/nodes_multivalue" o.group = {} for i, v in pairs(nodes_table) do o:value(v.id, v.remark) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index 5691f9e0cb..a7383703a5 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -102,7 +102,7 @@ end) o = s:option(MultiValue, _n("balancing_node"), translate("Load balancing node list"), translate("Load balancing node list, document")) o:depends({ [_n("protocol")] = "_balancing" }) o.widget = "checkbox" -o.template = appname .. "/cbi/nodes_multiselect" +o.template = appname .. "/cbi/nodes_multivalue" o.group = {} for i, v in pairs(nodes_table) do o:value(v.id, v.remark) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua index 2452060385..9d5f121508 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua @@ -109,7 +109,7 @@ end) o = s:option(MultiValue, _n("urltest_node"), translate("URLTest node list"), translate("List of nodes to test, document")) o:depends({ [_n("protocol")] = "_urltest" }) o.widget = "checkbox" -o.template = appname .. "/cbi/nodes_multiselect" +o.template = appname .. "/cbi/nodes_multivalue" o.group = {} for i, v in pairs(nodes_table) do o:value(v.id, v.remark) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm new file mode 100644 index 0000000000..1cf52b61fb --- /dev/null +++ b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm @@ -0,0 +1,122 @@ +<%+cbi/valueheader%> +<% +-- Template Developers: +-- - lwb1978 +-- Copyright: copyright(c)2025–2027 +-- Description: Passwall(2) UI template +local json = require "luci.jsonc" +local cbid = "cbid." .. self.config .. "." .. section .. "." .. self.option + +-- 读取 MultiValue +local values = {} +for i, key in pairs(self.keylist) do + values[#values + 1] = { + key = key, + label = self.vallist[i] or key, + group = self.group and self.group[i] or nil + } +end + +-- 获取选中值 +local selected = {} +local cval = self:cfgvalue(section) +if type(cval) == "table" then + for _, v in pairs(cval) do + selected[v] = true + end +elseif type(cval) == "string" then + for v in cval:gmatch("%S+") do + selected[v] = true + end +end + +-- 按原顺序分组 +local groups = {} +local group_order = {} +for _, item in ipairs(values) do + local g = item.group + if not g or g == "" then + g = translate("default") + end + if not groups[g] then + groups[g] = {} + table.insert(group_order, g) + end + table.insert(groups[g], item) +end + +local total_count = #values +local selected_count = 0 +for _, item in ipairs(values) do + if selected[item.key] then + selected_count = selected_count + 1 + end +end +%> + +
+ + + +
+
    + <% for _, gname in ipairs(group_order) do local items = groups[gname] %> +
  • + +
    + + <%=gname%> + <% + local g_selected = 0 + for _, it in ipairs(items) do + if selected[it.key] then + g_selected = g_selected + 1 + end + end + %> + + (<%=g_selected%>/<%=#items%>) + +
    + + +
  • + <% end %> +
+
+ +
+ + + <%:Selected:%> <%=selected_count%>/<%=total_count%> +
+
+<%+cbi/valuefooter%> + + diff --git a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multiselect.htm b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue_com.htm similarity index 56% rename from openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multiselect.htm rename to openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue_com.htm index de951198df..a5efdfa688 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multiselect.htm +++ b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue_com.htm @@ -1,102 +1,10 @@ -<%+cbi/valueheader%> <% -- Template Developers: -- - lwb1978 -- Copyright: copyright(c)2025–2027 -- Description: Passwall(2) UI template - -local cbid = "cbid." .. self.config .. "." .. section .. "." .. self.option - --- 读取 MultiValue -local values = {} -for i, key in pairs(self.keylist) do - values[#values + 1] = { - key = key, - label = self.vallist[i] or key, - group = self.group and self.group[i] or nil - } -end - --- 获取选中值 -local selected = {} -local cval = self:cfgvalue(section) -if type(cval) == "table" then - for _, v in pairs(cval) do - selected[v] = true - end -elseif type(cval) == "string" then - for v in cval:gmatch("%S+") do - selected[v] = true - end -end - --- 按原顺序分组 -local groups = {} -local group_order = {} -for _, item in ipairs(values) do - local g = item.group - if not g or g == "" then - g = translate("default") - end - if not groups[g] then - groups[g] = {} - table.insert(group_order, g) - end - table.insert(groups[g], item) -end %> -
- - - -
-
    - <% for _, gname in ipairs(group_order) do %> - <% local items = groups[gname] %> -
  • - -
    - - <%=gname%> - - (0/<%=#items%>) - -
    - -
      - <% for _, item in ipairs(items) do %> -
    • -
      - /> - -
      -
    • - <% end %> -
    -
  • - <% end %> -
-
- -
- - - -
-
-<%+cbi/valuefooter%> - -<% --- 公共部分(只加载一次) -if not _G.__NODES_MULTIVALUE_CSS_JS__ then - _G.__NODES_MULTIVALUE_CSS_JS__ = true -%>