fix(core): keep very large profiles from crashing the app
Changelog: Large profiles no longer crash; downloads trust core CAs
This commit is contained in:
+15
-2
@@ -63,6 +63,7 @@ func handleSetupConfig(params *SetupParams) error {
|
||||
}
|
||||
|
||||
func handleGetConfig(path string) (*config.RawConfig, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -100,11 +101,23 @@ func loadConfig(path string) (*config.Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return withPlatformLimits(cfg), nil
|
||||
}
|
||||
|
||||
func withPlatformLimits(cfg *config.Config) *config.Config {
|
||||
if cfg.NTP.WriteToSystem && !systemTimeWritable {
|
||||
log.Warnln("ntp write-to-system ignored: Android does not let apps set the system time")
|
||||
cfg.NTP.WriteToSystem = false
|
||||
}
|
||||
return cfg, nil
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadSetupConfig(params *SetupParams) (*config.Config, error) {
|
||||
path := filepath.Join(constant.Path.HomeDir(), "config.yaml")
|
||||
if params.Base != nil {
|
||||
return loadConfigWithBase(params.Base, path)
|
||||
}
|
||||
return loadConfig(path)
|
||||
}
|
||||
|
||||
func applyConfig(params *SetupParams) error {
|
||||
@@ -114,7 +127,7 @@ func applyConfig(params *SetupParams) error {
|
||||
|
||||
setTestURL(params.TestURL)
|
||||
skipCertVerify.Store(params.SkipCertVerify)
|
||||
cfg, err := loadConfig(filepath.Join(constant.Path.HomeDir(), "config.yaml"))
|
||||
cfg, err := loadSetupConfig(params)
|
||||
if err != nil {
|
||||
// The fallback is what keeps the listeners serving while the host
|
||||
// reports the error, but it applies a config with no proxies in it.
|
||||
|
||||
@@ -31,6 +31,7 @@ func handleInitClash(params *InitParams) bool {
|
||||
sdkVersion.Store(int32(params.Version))
|
||||
constant.SetHomeDir(params.HomeDir)
|
||||
initOwnership(params.HomeDir)
|
||||
clearSpilledResponses()
|
||||
isInit.Store(true)
|
||||
return true
|
||||
}
|
||||
|
||||
+5
-1
@@ -29,6 +29,7 @@ type MethodResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Result any `json:"result"`
|
||||
Error *MethodError `json:"error,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
}
|
||||
|
||||
var methodCallSeq atomic.Uint64
|
||||
@@ -75,7 +76,7 @@ func handleMethodCall(call *MethodCall) (data []byte) {
|
||||
if err != nil {
|
||||
return encodeResponse(MethodResponse{ID: call.ID, Error: asMethodError(err)})
|
||||
}
|
||||
return encodeResponse(MethodResponse{ID: call.ID, Result: result})
|
||||
return spillResponse(call.ID, encodeResponse(MethodResponse{ID: call.ID, Result: result}))
|
||||
}
|
||||
|
||||
func encodeResponse(response MethodResponse) []byte {
|
||||
@@ -189,6 +190,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
validateConfigMethod: withMessage(handleValidateConfig),
|
||||
decryptConfigMethod: withFallible(handleDecryptConfig),
|
||||
getConfigMethod: withFallible(handleGetConfig),
|
||||
getProfileBaseMethod: withFallible(handleGetProfileBase),
|
||||
getProfileOutboundsMethod: withFallible(handleGetProfileOutbounds),
|
||||
buildProfileMethod: withFallible(handleBuildProfile),
|
||||
setupConfigMethod: withMessage(handleSetupConfig),
|
||||
updateConfigMethod: withMessage(updateConfig),
|
||||
validateProxiesMethod: withArguments(handleValidateProxies),
|
||||
|
||||
@@ -5,3 +5,5 @@ package core
|
||||
func initOwnership(homeDir string) {}
|
||||
|
||||
func scheduleReclaimOwnership() {}
|
||||
|
||||
func reclaimDirOwnership(path string) {}
|
||||
|
||||
@@ -153,6 +153,22 @@ func reclaimOwnership(homeDir string) {
|
||||
log.Infoln("[APP] reclaimed %d of %d elevated entries under %s", reclaimed, len(targets), homeDir)
|
||||
}
|
||||
|
||||
func reclaimDirOwnership(path string) {
|
||||
homeDir, _ := reclaimHomeDir.Load().(string)
|
||||
if !isElevated() || !canReclaim(homeDir, realUid) {
|
||||
return
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.IsDir() || !shouldReclaimEntry(info, realUid) {
|
||||
return
|
||||
}
|
||||
stat := info.Sys().(*syscall.Stat_t)
|
||||
target := reclaimTarget{path: path, dev: uint64(stat.Dev), ino: uint64(stat.Ino)}
|
||||
if _, err := reclaimEntry(target, realUid, realGid); err != nil {
|
||||
log.Warnln("[APP] reclaim %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func initOwnership(homeDir string) {
|
||||
reclaimHomeDir.Store(homeDir)
|
||||
reclaimOwnership(homeDir)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/metacubex/mihomo/component/age"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// What makes a profile large stays in the Core; the overlay the host sends
|
||||
// back replaces every other section.
|
||||
var profileBaseSections = []string{"proxies", "proxy-groups", "sub-rules"}
|
||||
|
||||
func handleGetProfileBase(path string) (*ProfileBase, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256(buf)
|
||||
raw, err := unmarshalRawConfig(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := &ProfileBase{
|
||||
Digest: hex.EncodeToString(digest[:]),
|
||||
MatchRules: []string{},
|
||||
Proxies: len(raw.Proxy),
|
||||
ProxyGroups: len(raw.ProxyGroup),
|
||||
Rules: len(raw.Rule),
|
||||
}
|
||||
for _, rule := range raw.Rule {
|
||||
if isMatchRule(rule) {
|
||||
base.MatchRules = append(base.MatchRules, rule)
|
||||
}
|
||||
}
|
||||
raw.Proxy, raw.ProxyGroup, raw.Rule, raw.SubRules = nil, nil, nil, nil
|
||||
base.Config = raw
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func handleGetProfileOutbounds(path string) (*ProfileOutbounds, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := unmarshalRawConfig(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileOutbounds{Proxies: outboundsOf(raw.Proxy), ProxyGroups: outboundsOf(raw.ProxyGroup)}, nil
|
||||
}
|
||||
|
||||
func outboundsOf(mappings []map[string]any) []ProfileOutbound {
|
||||
outbounds := make([]ProfileOutbound, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
name, _ := mapping["name"].(string)
|
||||
kind, _ := mapping["type"].(string)
|
||||
outbounds = append(outbounds, ProfileOutbound{Name: name, Type: kind})
|
||||
}
|
||||
return outbounds
|
||||
}
|
||||
|
||||
// The merged config goes to the spill directory, so the host reads it as text
|
||||
// and never decodes it.
|
||||
func handleBuildProfile(base *SetupBase) (string, error) {
|
||||
defer scheduleLoadMemoryRelease()
|
||||
document, err := mergeProfileBase(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir, err := makeSpilledResponseDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := writeDocumentFile(dir, "*.yaml", document)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheduleReclaimOwnership()
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Mirrors Rule.parse in Dart, which reads the type from the first field.
|
||||
func isMatchRule(rule string) bool {
|
||||
kind, _, _ := strings.Cut(rule, ",")
|
||||
return strings.EqualFold(strings.TrimSpace(kind), "MATCH")
|
||||
}
|
||||
|
||||
func loadConfigWithBase(base *SetupBase, configPath string) (*config.Config, error) {
|
||||
document, err := mergeProfileBase(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := config.DefaultRawConfig()
|
||||
if err := document.Decode(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeConfigDocument(configPath, document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := config.ParseRawConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return withPlatformLimits(cfg), nil
|
||||
}
|
||||
|
||||
func mergeProfileBase(base *SetupBase) (*yaml.Node, error) {
|
||||
buf, err := os.ReadFile(base.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buf, err = age.DecryptBytes(buf); err != nil {
|
||||
return nil, fmt.Errorf("decrypt config error: %w", err)
|
||||
}
|
||||
retag := bytes.Contains(buf, []byte("short-id"))
|
||||
var profileDocument, overlayDocument yaml.Node
|
||||
if err := yaml.Unmarshal(buf, &profileDocument); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(base.Overlay), &overlayDocument); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile, err := documentMapping(&profileDocument)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := documentMapping(&overlayDocument)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range profileBaseSections {
|
||||
if value := mappingValue(profile, key); value != nil {
|
||||
setMappingValue(root, key, value)
|
||||
}
|
||||
}
|
||||
rules, err := appendRules(mappingValue(root, "rules"), mappingValue(profile, "rules"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rules != nil {
|
||||
setMappingValue(root, "rules", rules)
|
||||
}
|
||||
if retag {
|
||||
retagShortIDs(root)
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}, nil
|
||||
}
|
||||
|
||||
func documentMapping(document *yaml.Node) (*yaml.Node, error) {
|
||||
node := document
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = resolveAlias(node.Content[0])
|
||||
}
|
||||
switch {
|
||||
case node.Kind == yaml.MappingNode:
|
||||
return node, nil
|
||||
case node.Kind == 0, node.Kind == yaml.DocumentNode, isNull(node):
|
||||
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("line %d: a config must be a mapping", node.Line)
|
||||
}
|
||||
|
||||
// A key the mapping lacks is looked up in its merge keys, as the decoder does.
|
||||
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
|
||||
var sources []*yaml.Node
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
name, value := mapping.Content[i], mapping.Content[i+1]
|
||||
if name.Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
if name.ShortTag() == "!!merge" {
|
||||
sources = append(sources, value)
|
||||
} else if name.Value == key {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, source := range sources {
|
||||
source = resolveAlias(source)
|
||||
candidates := []*yaml.Node{source}
|
||||
if source.Kind == yaml.SequenceNode {
|
||||
candidates = source.Content
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate = resolveAlias(candidate); candidate.Kind == yaml.MappingNode {
|
||||
if value := mappingValue(candidate, key); value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setMappingValue(mapping *yaml.Node, key string, value *yaml.Node) {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if name := mapping.Content[i]; name.Kind == yaml.ScalarNode && name.Value == key {
|
||||
mapping.Content[i+1] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
mapping.Content = append(
|
||||
mapping.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
// The overlay's rules are the ones the host adds in front of the profile's.
|
||||
func appendRules(added, profile *yaml.Node) (*yaml.Node, error) {
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
added, profile = resolveAlias(added), resolveAlias(profile)
|
||||
if added == nil || isNull(added) || (added.Kind == yaml.SequenceNode && len(added.Content) == 0) {
|
||||
return profile, nil
|
||||
}
|
||||
if isNull(profile) {
|
||||
return added, nil
|
||||
}
|
||||
if added.Kind != yaml.SequenceNode || profile.Kind != yaml.SequenceNode {
|
||||
return nil, fmt.Errorf("line %d: rules must be a list", profile.Line)
|
||||
}
|
||||
content := make([]*yaml.Node, 0, len(added.Content)+len(profile.Content))
|
||||
content = append(append(content, added.Content...), profile.Content...)
|
||||
return &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: content}, nil
|
||||
}
|
||||
|
||||
func resolveAlias(node *yaml.Node) *yaml.Node {
|
||||
for node != nil && node.Kind == yaml.AliasNode && node.Alias != nil {
|
||||
node = node.Alias
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func isNull(node *yaml.Node) bool {
|
||||
return node.Kind == yaml.ScalarNode && node.ShortTag() == "!!null"
|
||||
}
|
||||
|
||||
// config.yaml is read again without this process: by the Android service
|
||||
// starting on its own and by mihomo's own reload through the controller.
|
||||
func writeConfigDocument(path string, document *yaml.Node) error {
|
||||
temporary, err := writeDocumentFile(filepath.Dir(path), ".config-*.yaml", document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
_ = os.Remove(temporary)
|
||||
return err
|
||||
}
|
||||
scheduleReclaimOwnership()
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDocumentFile(dir, pattern string, document *yaml.Node) (string, error) {
|
||||
inlineAliases(document)
|
||||
file, err := os.CreateTemp(dir, pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
writer := bufio.NewWriterSize(file, 1<<20)
|
||||
err = errors.Join(
|
||||
encodeConfigMapping(writer, document.Content[0]),
|
||||
writer.Flush(),
|
||||
file.Close(),
|
||||
os.Chmod(file.Name(), 0o644),
|
||||
)
|
||||
if err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return "", err
|
||||
}
|
||||
return file.Name(), nil
|
||||
}
|
||||
|
||||
// One Encode call keeps every event it emitted until it returns, several times
|
||||
// the size of a large profile, so a long top-level list goes out in slices.
|
||||
const encodeSliceLength = 4096
|
||||
|
||||
// The mapping is drained as it is written, so the parse that follows does not
|
||||
// share the heap with the whole tree.
|
||||
func encodeConfigMapping(w io.Writer, mapping *yaml.Node) error {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
key, value := mapping.Content[i], mapping.Content[i+1]
|
||||
mapping.Content[i+1] = nil
|
||||
if value.Kind != yaml.SequenceNode || len(value.Content) <= encodeSliceLength {
|
||||
pair := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{key, value}}
|
||||
if err := encodeYamlPiece(w, pair, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := encodeYamlPiece(w, key, []byte(":\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
for start := 0; start < len(value.Content); start += encodeSliceLength {
|
||||
items := value.Content[start:min(start+encodeSliceLength, len(value.Content))]
|
||||
slice := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: items}
|
||||
if err := encodeYamlPiece(w, slice, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
clear(items)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Each piece is a document of its own; an end marker left between two of them
|
||||
// would end config.yaml there.
|
||||
func encodeYamlPiece(w io.Writer, node *yaml.Node, suffix []byte) error {
|
||||
var buffer bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buffer)
|
||||
encoder.SetIndent(2)
|
||||
if err := errors.Join(encoder.Encode(node), encoder.Close()); err != nil {
|
||||
return err
|
||||
}
|
||||
piece := bytes.TrimSuffix(buffer.Bytes(), []byte("...\n"))
|
||||
if suffix != nil {
|
||||
piece = append(bytes.TrimSuffix(piece, []byte("\n")), suffix...)
|
||||
}
|
||||
_, err := w.Write(piece)
|
||||
return err
|
||||
}
|
||||
|
||||
// An anchor may sit in a section the overlay replaced, so every alias is
|
||||
// written out in full, as the host's own encoder always did.
|
||||
func inlineAliases(root *yaml.Node) {
|
||||
visited := map[*yaml.Node]struct{}{}
|
||||
var walk func(node *yaml.Node)
|
||||
walk = func(node *yaml.Node) {
|
||||
if _, seen := visited[node]; seen {
|
||||
return
|
||||
}
|
||||
if node.Anchor != "" {
|
||||
visited[node] = struct{}{}
|
||||
node.Anchor = ""
|
||||
}
|
||||
for i, child := range node.Content {
|
||||
child = resolveAlias(child)
|
||||
node.Content[i] = child
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const baseProfile = `x-defaults: &socks {type: socks5, server: 127.0.0.1, port: 1080}
|
||||
mixed-port: 7000
|
||||
dns:
|
||||
enable: true
|
||||
nameserver: [223.5.5.5]
|
||||
proxies:
|
||||
- {<<: *socks, name: a}
|
||||
- name: b
|
||||
type: vless
|
||||
server: 127.0.0.1
|
||||
port: 443
|
||||
uuid: 2a70e3a9-b4ce-4f20-908d-e99950a0a611
|
||||
network: tcp
|
||||
tls: true
|
||||
reality-opts: {public-key: SbVKOEMjK0sIlbwg4akyBg5mL5KZwwB-ed4eEE7YnRc, short-id: 0123}
|
||||
proxy-groups:
|
||||
- {name: g, type: select, proxies: [a, b]}
|
||||
sub-rules:
|
||||
sub: ["MATCH,g"]
|
||||
rules:
|
||||
- DOMAIN-SUFFIX,example.com,g
|
||||
- match,DIRECT
|
||||
- MATCH,g
|
||||
`
|
||||
|
||||
func writeBaseProfile(t *testing.T, profile string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile.yaml")
|
||||
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// The overlay is the host's patch of what getProfileBase returned.
|
||||
func overlayOf(t *testing.T, base *ProfileBase, rules []string, patch map[string]any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(base.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal base: %v", err)
|
||||
}
|
||||
var overlay map[string]any
|
||||
if err := json.Unmarshal(data, &overlay); err != nil {
|
||||
t.Fatalf("unmarshal base: %v", err)
|
||||
}
|
||||
delete(overlay, "rule")
|
||||
overlay["rules"] = rules
|
||||
for key, value := range patch {
|
||||
overlay[key] = value
|
||||
}
|
||||
data, err = json.Marshal(overlay)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal overlay: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func ruleStrings(cfg *config.Config) []string {
|
||||
rules := make([]string, 0, len(cfg.Rules))
|
||||
for _, rule := range cfg.Rules {
|
||||
rules = append(rules, rule.RuleType().String()+","+rule.Payload()+","+rule.Adapter())
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func TestHandleGetProfileBaseLeavesTheBulkSectionsInTheCore(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
|
||||
base, err := handleGetProfileBase(path)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
raw := base.Config
|
||||
if raw.Proxy != nil || raw.ProxyGroup != nil || raw.Rule != nil || raw.SubRules != nil {
|
||||
t.Errorf("config kept a bulk section: %+v", raw)
|
||||
}
|
||||
if raw.MixedPort != 7000 || !raw.DNS.Enable {
|
||||
t.Errorf("config dropped a section the host patches: mixed-port %d, dns %t", raw.MixedPort, raw.DNS.Enable)
|
||||
}
|
||||
if base.Proxies != 2 || base.ProxyGroups != 1 || base.Rules != 3 {
|
||||
t.Errorf("counts = %d/%d/%d, want 2/1/3", base.Proxies, base.ProxyGroups, base.Rules)
|
||||
}
|
||||
if want := []string{"match,DIRECT", "MATCH,g"}; !reflect.DeepEqual(base.MatchRules, want) {
|
||||
t.Errorf("match rules = %v, want %v", base.MatchRules, want)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(baseProfile))
|
||||
if base.Digest != hex.EncodeToString(digest[:]) {
|
||||
t.Errorf("digest = %s, want the sha256 of the file", base.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetProfileOutboundsListsOnlyNamesAndTypes(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
|
||||
outbounds, err := handleGetProfileOutbounds(path)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileOutbounds = %v", err)
|
||||
}
|
||||
want := &ProfileOutbounds{
|
||||
Proxies: []ProfileOutbound{{Name: "a", Type: "socks5"}, {Name: "b", Type: "vless"}},
|
||||
ProxyGroups: []ProfileOutbound{{Name: "g", Type: "select"}},
|
||||
}
|
||||
if !reflect.DeepEqual(outbounds, want) {
|
||||
t.Errorf("outbounds = %+v, want %+v", outbounds, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBuildProfileWritesTheMergedConfigWhereTheHostReadsSpills(t *testing.T) {
|
||||
withSpillHome(t, responseSpillThreshold)
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
base, err := handleGetProfileBase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
overlay := overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, nil)
|
||||
|
||||
built, err := handleBuildProfile(&SetupBase{Path: path, Overlay: overlay})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("handleBuildProfile = %v", err)
|
||||
}
|
||||
if filepath.Dir(built) != spilledResponseDir() {
|
||||
t.Errorf("built %s, want it in %s", built, spilledResponseDir())
|
||||
}
|
||||
cfg, err := loadConfig(built)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig(built) = %v", err)
|
||||
}
|
||||
if got := ruleStrings(cfg); len(got) != 4 || got[0] != "Domain,added.test,DIRECT" {
|
||||
t.Errorf("rules = %v, want the added rule before the profile's three", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithBasePutsTheAddedRulesFirstAndTheOverlayOverTheRest(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
base, err := handleGetProfileBase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
overlay := overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, map[string]any{
|
||||
"mixed-port": 7890,
|
||||
"dns": map[string]any{"enable": true, "nameserver": []string{"1.1.1.1"}},
|
||||
})
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
cfg, err := loadConfigWithBase(&SetupBase{Path: path, Overlay: overlay}, configPath)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfigWithBase = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"Domain,added.test,DIRECT",
|
||||
"DomainSuffix,example.com,g",
|
||||
"Match,,DIRECT",
|
||||
"Match,,g",
|
||||
}
|
||||
if got := ruleStrings(cfg); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("rules = %v, want %v", got, want)
|
||||
}
|
||||
if cfg.General.MixedPort != 7890 {
|
||||
t.Errorf("mixed-port = %d, want the overlay's", cfg.General.MixedPort)
|
||||
}
|
||||
if len(cfg.DNS.NameServer) != 1 || !strings.Contains(cfg.DNS.NameServer[0].Addr, "1.1.1.1") {
|
||||
t.Errorf("nameservers = %+v, want only the overlay's", cfg.DNS.NameServer)
|
||||
}
|
||||
for _, name := range []string{"a", "b", "g"} {
|
||||
if cfg.Proxies[name] == nil {
|
||||
t.Errorf("proxy %s is missing", name)
|
||||
}
|
||||
}
|
||||
if len(cfg.SubRules["sub"]) != 1 {
|
||||
t.Errorf("sub-rules = %v, want the profile's", cfg.SubRules)
|
||||
}
|
||||
}
|
||||
|
||||
// The Android service and mihomo's controller reload config.yaml without the
|
||||
// profile, and the anchor it was written with sits in a section the overlay drops.
|
||||
func TestLoadConfigWithBaseWritesAConfigThatLoadsAlone(t *testing.T) {
|
||||
path := writeBaseProfile(t, baseProfile)
|
||||
base, err := handleGetProfileBase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetProfileBase = %v", err)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
cfg, err := loadConfigWithBase(
|
||||
&SetupBase{Path: path, Overlay: overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, nil)},
|
||||
configPath,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfigWithBase = %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := loadConfig(configPath)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig(config.yaml) = %v", err)
|
||||
}
|
||||
if got, want := ruleStrings(reloaded), ruleStrings(cfg); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("reloaded rules = %v, want %v", got, want)
|
||||
}
|
||||
if len(reloaded.Proxies) != len(cfg.Proxies) {
|
||||
t.Errorf("reloaded %d proxies, want %d", len(reloaded.Proxies), len(cfg.Proxies))
|
||||
}
|
||||
raw, err := handleGetConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetConfig(config.yaml) = %v", err)
|
||||
}
|
||||
for _, proxy := range raw.Proxy {
|
||||
if proxy["name"] != "b" {
|
||||
continue
|
||||
}
|
||||
if got := proxy["reality-opts"].(map[string]any)["short-id"]; got != "0123" {
|
||||
t.Errorf("short-id = %#v, want the literal 0123", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithBaseKeepsAnEmptyProfileOnTheDefaults(t *testing.T) {
|
||||
path := writeBaseProfile(t, "")
|
||||
|
||||
cfg, err := loadConfigWithBase(
|
||||
&SetupBase{Path: path, Overlay: `{"rules": ["MATCH,DIRECT"]}`},
|
||||
filepath.Join(t.TempDir(), "config.yaml"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfigWithBase = %v", err)
|
||||
}
|
||||
if got := ruleStrings(cfg); !reflect.DeepEqual(got, []string{"Match,,DIRECT"}) {
|
||||
t.Errorf("rules = %v, want the added rule alone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithBaseRejectsAProfileThatIsNotAMapping(t *testing.T) {
|
||||
path := writeBaseProfile(t, "- a\n- b\n")
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
if _, err := loadConfigWithBase(&SetupBase{Path: path, Overlay: "{}"}, configPath); err == nil {
|
||||
t.Fatal("loadConfigWithBase accepted a list as a config")
|
||||
}
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
t.Errorf("stat config.yaml = %v, want the failed apply to leave it alone", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSetupConfigReadsConfigYamlWithoutABase(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
previous := constant.Path.HomeDir()
|
||||
constant.SetHomeDir(home)
|
||||
t.Cleanup(func() { constant.SetHomeDir(previous) })
|
||||
profile := "rules:\n - MATCH,REJECT\n"
|
||||
if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadSetupConfig(defaultSetupParams())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadSetupConfig = %v", err)
|
||||
}
|
||||
if got := ruleStrings(cfg); !reflect.DeepEqual(got, []string{"Match,,REJECT"}) {
|
||||
t.Errorf("rules = %v, want config.yaml's", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeConfigMappingKeepsEveryKeyAfterAnOpenEndedScalar(t *testing.T) {
|
||||
rules := make([]string, encodeSliceLength*2+1)
|
||||
for i := range rules {
|
||||
rules[i] = fmt.Sprintf("DOMAIN,%d.test,DIRECT", i)
|
||||
}
|
||||
var mapping yaml.Node
|
||||
if err := mapping.Encode(map[string]any{"rules": rules}); err != nil {
|
||||
t.Fatalf("encode rules: %v", err)
|
||||
}
|
||||
note := []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "a-note"},
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "kept\n\n", Style: yaml.LiteralStyle},
|
||||
}
|
||||
mapping.Content = append(note, mapping.Content...)
|
||||
var out bytes.Buffer
|
||||
|
||||
if err := encodeConfigMapping(&out, &mapping); err != nil {
|
||||
t.Fatalf("encodeConfigMapping = %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Note string `yaml:"a-note"`
|
||||
Rules []string `yaml:"rules"`
|
||||
}
|
||||
if err := yaml.Unmarshal(out.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode = %v", err)
|
||||
}
|
||||
if decoded.Note != "kept\n\n" {
|
||||
t.Errorf("note = %q, want its trailing lines kept", decoded.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded.Rules, rules) {
|
||||
t.Errorf("decoded %d rules, want all %d in order", len(decoded.Rules), len(rules))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/metacubex/http"
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
P "github.com/metacubex/mihomo/component/process"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
@@ -56,6 +57,9 @@ const (
|
||||
crashMethod CoreMethod = "crash"
|
||||
setupConfigMethod CoreMethod = "setupConfig"
|
||||
getConfigMethod CoreMethod = "getConfig"
|
||||
getProfileBaseMethod CoreMethod = "getProfileBase"
|
||||
getProfileOutboundsMethod CoreMethod = "getProfileOutbounds"
|
||||
buildProfileMethod CoreMethod = "buildProfile"
|
||||
dumpRuleSetMethod CoreMethod = "dumpRuleSet"
|
||||
compileRuleSetMethod CoreMethod = "compileRuleSet"
|
||||
clearEffectMethod CoreMethod = "clearEffect"
|
||||
@@ -95,6 +99,31 @@ type SetupParams struct {
|
||||
SelectedMap map[string]string `json:"selected-map"`
|
||||
TestURL string `json:"test-url"`
|
||||
SkipCertVerify bool `json:"skip-cert-verify"`
|
||||
Base *SetupBase `json:"base,omitempty"`
|
||||
}
|
||||
|
||||
type SetupBase struct {
|
||||
Path string `json:"path"`
|
||||
Overlay string `json:"overlay"`
|
||||
}
|
||||
|
||||
type ProfileBase struct {
|
||||
Config *config.RawConfig `json:"config"`
|
||||
Digest string `json:"digest"`
|
||||
MatchRules []string `json:"match-rules"`
|
||||
Proxies int `json:"proxies"`
|
||||
ProxyGroups int `json:"proxy-groups"`
|
||||
Rules int `json:"rules"`
|
||||
}
|
||||
|
||||
type ProfileOutbounds struct {
|
||||
Proxies []ProfileOutbound `json:"proxies"`
|
||||
ProxyGroups []ProfileOutbound `json:"proxy-groups"`
|
||||
}
|
||||
|
||||
type ProfileOutbound struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type UpdateParams struct {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
)
|
||||
|
||||
// Android copies a reply into the Java heap three times and the desktop IPC
|
||||
// frame stops at maxIPCFrameSize. The directory name is mirrored in Dart.
|
||||
const (
|
||||
responseSpillThreshold = 1 << 20
|
||||
spilledResponseDirName = "responses"
|
||||
)
|
||||
|
||||
var spillThreshold = responseSpillThreshold
|
||||
|
||||
func spilledResponseDir() string {
|
||||
return filepath.Join(constant.Path.HomeDir(), spilledResponseDirName)
|
||||
}
|
||||
|
||||
// The host deletes each file as soon as it reads it, which a directory an
|
||||
// elevated Core just made would refuse until the debounced reclaim.
|
||||
func makeSpilledResponseDir() (string, error) {
|
||||
dir := spilledResponseDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
reclaimDirOwnership(dir)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func spillResponse(id string, data []byte) []byte {
|
||||
if len(data) <= spillThreshold {
|
||||
return data
|
||||
}
|
||||
path, err := writeSpilledResponse(data)
|
||||
if err != nil {
|
||||
logError("spill response %s: %v", id, err)
|
||||
return data
|
||||
}
|
||||
return encodeResponse(MethodResponse{ID: id, File: path})
|
||||
}
|
||||
|
||||
func writeSpilledResponse(data []byte) (path string, err error) {
|
||||
dir, err := makeSpilledResponseDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
file, err := os.CreateTemp(dir, "*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = file.Name()
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
// An elevated Core still answers a host running as the app user.
|
||||
if err := file.Chmod(0o644); err != nil {
|
||||
_ = file.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheduleReclaimOwnership()
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func clearSpilledResponses() {
|
||||
entries, err := os.ReadDir(spilledResponseDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
_ = os.Remove(filepath.Join(spilledResponseDir(), entry.Name()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
)
|
||||
|
||||
func withSpillHome(t *testing.T, threshold int) string {
|
||||
t.Helper()
|
||||
previousHome := constant.Path.HomeDir()
|
||||
previousThreshold := spillThreshold
|
||||
home := t.TempDir()
|
||||
constant.SetHomeDir(home)
|
||||
spillThreshold = threshold
|
||||
t.Cleanup(func() {
|
||||
constant.SetHomeDir(previousHome)
|
||||
spillThreshold = previousThreshold
|
||||
})
|
||||
return home
|
||||
}
|
||||
|
||||
func getConfigCall(t *testing.T, id string) *MethodCall {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile.yaml")
|
||||
if err := os.WriteFile(path, []byte("mode: rule\nrules:\n - MATCH,DIRECT\n"), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
arguments, _ := json.Marshal(path)
|
||||
return &MethodCall{ID: id, Method: getConfigMethod, Arguments: arguments}
|
||||
}
|
||||
|
||||
func TestHandleMethodCallSpillsALargeResultToAFile(t *testing.T) {
|
||||
home := withSpillHome(t, 64)
|
||||
|
||||
frame := handleMethodCall(getConfigCall(t, "7"))
|
||||
|
||||
var envelope MethodResponse
|
||||
if err := json.Unmarshal(frame, &envelope); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if envelope.ID != "7" || envelope.Result != nil || envelope.Error != nil {
|
||||
t.Fatalf("envelope = %+v, want only the id and the file", envelope)
|
||||
}
|
||||
if filepath.Dir(envelope.File) != filepath.Join(home, spilledResponseDirName) {
|
||||
t.Fatalf("file = %s, want it under the home directory's spill directory", envelope.File)
|
||||
}
|
||||
data, err := os.ReadFile(envelope.File)
|
||||
if err != nil {
|
||||
t.Fatalf("read spilled response: %v", err)
|
||||
}
|
||||
var spilled struct {
|
||||
ID string `json:"id"`
|
||||
Result map[string]any `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &spilled); err != nil {
|
||||
t.Fatalf("spilled response is not valid JSON: %v", err)
|
||||
}
|
||||
if spilled.ID != "7" || spilled.Result["mode"] != "rule" {
|
||||
t.Errorf("spilled response = %+v, want the full answer to call 7", spilled)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, _ := os.Stat(envelope.File)
|
||||
if info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode = %v, want the host to be able to read it", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMethodCallAnswersInlineUnderTheThreshold(t *testing.T) {
|
||||
home := withSpillHome(t, responseSpillThreshold)
|
||||
|
||||
frame := handleMethodCall(getConfigCall(t, "8"))
|
||||
|
||||
var envelope MethodResponse
|
||||
if err := json.Unmarshal(frame, &envelope); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if envelope.File != "" || envelope.Result == nil {
|
||||
t.Errorf("envelope = %+v, want the result inline", envelope)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, spilledResponseDirName)); !os.IsNotExist(err) {
|
||||
t.Errorf("stat spill directory = %v, want nothing written", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSpilledResponsesKeepsTheDirectory(t *testing.T) {
|
||||
home := withSpillHome(t, 64)
|
||||
handleMethodCall(getConfigCall(t, "9"))
|
||||
|
||||
clearSpilledResponses()
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(home, spilledResponseDirName))
|
||||
if err != nil {
|
||||
t.Fatalf("read spill directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("entries = %v, want none left", entries)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user