diff --git a/.gitignore b/.gitignore index d4c939d0..6630f428 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ /*.aar /*.xcframework/ .DS_Store +/config.d/ diff --git a/cmd/sing-box/cmd_check.go b/cmd/sing-box/cmd_check.go index dfe95514..c147c009 100644 --- a/cmd/sing-box/cmd_check.go +++ b/cmd/sing-box/cmd_check.go @@ -26,7 +26,7 @@ func init() { } func check() error { - options, err := readConfig() + options, err := readConfigAndMerge() if err != nil { return err } diff --git a/cmd/sing-box/cmd_format.go b/cmd/sing-box/cmd_format.go index 47b8a00a..10a5497c 100644 --- a/cmd/sing-box/cmd_format.go +++ b/cmd/sing-box/cmd_format.go @@ -33,6 +33,44 @@ func init() { } func format() error { + optionsList, err := readConfig() + if err != nil { + return err + } + for _, optionsEntry := range optionsList { + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(optionsEntry.options) + if err != nil { + return E.Cause(err, "encode config") + } + outputPath, _ := filepath.Abs(optionsEntry.path) + if !commandFormatFlagWrite { + if len(optionsList) > 1 { + os.Stdout.WriteString(outputPath + "\n") + } + os.Stdout.WriteString(buffer.String() + "\n") + continue + } + if bytes.Equal(optionsEntry.content, buffer.Bytes()) { + continue + } + output, err := os.Create(optionsEntry.path) + if err != nil { + return E.Cause(err, "open output") + } + _, err = output.Write(buffer.Bytes()) + output.Close() + if err != nil { + return E.Cause(err, "write output") + } + os.Stderr.WriteString(outputPath + "\n") + } + return nil +} + +func formatOne(configPath string) error { configContent, err := os.ReadFile(configPath) if err != nil { return E.Cause(err, "read config") diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index a0b02a2e..e37e9e20 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -5,10 +5,14 @@ import ( "io" "os" "os/signal" + "path/filepath" runtimeDebug "runtime/debug" + "sort" + "strings" "syscall" "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/common/badjsonmerge" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -31,29 +35,85 @@ func init() { mainCommand.AddCommand(commandRun) } -func readConfig() (option.Options, error) { +type OptionsEntry struct { + content []byte + path string + options option.Options +} + +func readConfigAt(path string) (*OptionsEntry, error) { var ( configContent []byte err error ) - if configPath == "stdin" { + if path == "stdin" { configContent, err = io.ReadAll(os.Stdin) } else { - configContent, err = os.ReadFile(configPath) + configContent, err = os.ReadFile(path) } if err != nil { - return option.Options{}, E.Cause(err, "read config") + return nil, E.Cause(err, "read config at ", path) } var options option.Options err = options.UnmarshalJSON(configContent) if err != nil { - return option.Options{}, E.Cause(err, "decode config") + return nil, E.Cause(err, "decode config at ", path) } - return options, nil + return &OptionsEntry{ + content: configContent, + path: path, + options: options, + }, nil +} + +func readConfig() ([]*OptionsEntry, error) { + var optionsList []*OptionsEntry + for _, path := range configPaths { + optionsEntry, err := readConfigAt(path) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + for _, directory := range configDirectories { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, E.Cause(err, "read config directory at ", directory) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") || entry.IsDir() { + continue + } + optionsEntry, err := readConfigAt(filepath.Join(directory, entry.Name())) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + } + sort.Slice(optionsList, func(i, j int) bool { + return optionsList[i].path < optionsList[j].path + }) + return optionsList, nil +} + +func readConfigAndMerge() (option.Options, error) { + optionsList, err := readConfig() + if err != nil { + return option.Options{}, err + } + var mergedOptions option.Options + for _, options := range optionsList { + mergedOptions, err = badjsonmerge.MergeOptions(options.options, mergedOptions) + if err != nil { + return option.Options{}, E.Cause(err, "merge config at ", options.path) + } + } + return mergedOptions, nil } func create() (*box.Box, context.CancelFunc, error) { - options, err := readConfig() + options, err := readConfigAndMerge() if err != nil { return nil, nil, err } diff --git a/cmd/sing-box/main.go b/cmd/sing-box/main.go index 322fb720..1c4c2df3 100644 --- a/cmd/sing-box/main.go +++ b/cmd/sing-box/main.go @@ -11,9 +11,10 @@ import ( ) var ( - configPath string - workingDir string - disableColor bool + configPaths []string + configDirectories []string + workingDir string + disableColor bool ) var mainCommand = &cobra.Command{ @@ -22,7 +23,8 @@ var mainCommand = &cobra.Command{ } func init() { - mainCommand.PersistentFlags().StringVarP(&configPath, "config", "c", "config.json", "set configuration file path") + mainCommand.PersistentFlags().StringArrayVarP(&configPaths, "config", "c", nil, "set configuration file path") + mainCommand.PersistentFlags().StringArrayVarP(&configDirectories, "config-directory", "C", nil, "set configuration directory path") mainCommand.PersistentFlags().StringVarP(&workingDir, "directory", "D", "", "set working directory") mainCommand.PersistentFlags().BoolVarP(&disableColor, "disable-color", "", false, "disable color output") } @@ -42,4 +44,7 @@ func preRun(cmd *cobra.Command, args []string) { log.Fatal(err) } } + if len(configPaths) == 0 && len(configDirectories) == 0 { + configPaths = append(configPaths, "config.json") + } } diff --git a/common/badjsonmerge/merge.go b/common/badjsonmerge/merge.go new file mode 100644 index 00000000..39635e66 --- /dev/null +++ b/common/badjsonmerge/merge.go @@ -0,0 +1,80 @@ +package badjsonmerge + +import ( + "encoding/json" + "reflect" + + "github.com/sagernet/sing-box/common/badjson" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func MergeOptions(source option.Options, destination option.Options) (option.Options, error) { + rawSource, err := json.Marshal(source) + if err != nil { + return option.Options{}, E.Cause(err, "marshal source") + } + rawDestination, err := json.Marshal(destination) + if err != nil { + return option.Options{}, E.Cause(err, "marshal destination") + } + rawMerged, err := MergeJSON(rawSource, rawDestination) + if err != nil { + return option.Options{}, E.Cause(err, "merge options") + } + var merged option.Options + err = json.Unmarshal(rawMerged, &merged) + if err != nil { + return option.Options{}, E.Cause(err, "unmarshal merged options") + } + return merged, nil +} + +func MergeJSON(rawSource json.RawMessage, rawDestination json.RawMessage) (json.RawMessage, error) { + source, err := badjson.Decode(rawSource) + if err != nil { + return nil, E.Cause(err, "decode source") + } + destination, err := badjson.Decode(rawDestination) + if err != nil { + return nil, E.Cause(err, "decode destination") + } + merged, err := mergeJSON(source, destination) + if err != nil { + return nil, err + } + return json.Marshal(merged) +} + +func mergeJSON(anySource any, anyDestination any) (any, error) { + switch destination := anyDestination.(type) { + case badjson.JSONArray: + switch source := anySource.(type) { + case badjson.JSONArray: + destination = append(destination, source...) + default: + destination = append(destination, source) + } + return destination, nil + case *badjson.JSONObject: + switch source := anySource.(type) { + case *badjson.JSONObject: + for _, entry := range source.Entries() { + oldValue, loaded := destination.Get(entry.Key) + if loaded { + var err error + entry.Value, err = mergeJSON(entry.Value, oldValue) + if err != nil { + return nil, E.Cause(err, "merge object item ", entry.Key) + } + } + destination.Put(entry.Key, entry.Value) + } + default: + return nil, E.New("cannot merge json object into ", reflect.TypeOf(destination)) + } + return destination, nil + default: + return destination, nil + } +} diff --git a/common/badjsonmerge/merge_test.go b/common/badjsonmerge/merge_test.go new file mode 100644 index 00000000..d1714cd6 --- /dev/null +++ b/common/badjsonmerge/merge_test.go @@ -0,0 +1,59 @@ +package badjsonmerge + +import ( + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + N "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" +) + +func TestMergeJSON(t *testing.T) { + t.Parallel() + options := option.Options{ + Log: &option.LogOptions{ + Level: "info", + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Network: N.NetworkTCP, + Outbound: "direct", + }, + }, + }, + }, + } + anotherOptions := option.Options{ + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + Tag: "direct", + }, + }, + } + thirdOptions := option.Options{ + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Network: N.NetworkUDP, + Outbound: "direct", + }, + }, + }, + }, + } + mergeOptions, err := MergeOptions(options, anotherOptions) + require.NoError(t, err) + mergeOptions, err = MergeOptions(thirdOptions, mergeOptions) + require.NoError(t, err) + require.Equal(t, "info", mergeOptions.Log.Level) + require.Equal(t, 2, len(mergeOptions.Route.Rules)) + require.Equal(t, C.TypeDirect, mergeOptions.Outbounds[0].Type) +} diff --git a/go.mod b/go.mod index ef1969d1..009a416f 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/sagernet/gomobile v0.0.0-20221130124640-349ebaa752ca github.com/sagernet/quic-go v0.0.0-20230202071646-a8c8afb18b32 github.com/sagernet/reality v0.0.0-20230312150606-35ea9af0e0b8 - github.com/sagernet/sing v0.2.1-0.20230318083058-18cd006d266e + github.com/sagernet/sing v0.2.1-0.20230318094614-4bbf5f2c3046 github.com/sagernet/sing-dns v0.1.4 github.com/sagernet/sing-shadowsocks v0.1.2-0.20230221080503-769c01d6bba9 github.com/sagernet/sing-shadowtls v0.1.0 diff --git a/go.sum b/go.sum index 10292a39..6c020a82 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/sagernet/reality v0.0.0-20230312150606-35ea9af0e0b8 h1:4M3+0/kqvJuTsi github.com/sagernet/reality v0.0.0-20230312150606-35ea9af0e0b8/go.mod h1:B8lp4WkQ1PwNnrVMM6KyuFR20pU8jYBD+A4EhJovEXU= github.com/sagernet/sing v0.0.0-20220817130738-ce854cda8522/go.mod h1:QVsS5L/ZA2Q5UhQwLrn0Trw+msNd/NPGEhBKR/ioWiY= github.com/sagernet/sing v0.1.8/go.mod h1:jt1w2u7lJQFFSGLiRrRIs5YWmx4kAPfWuOejuDW9qMk= -github.com/sagernet/sing v0.2.1-0.20230318083058-18cd006d266e h1:KDaZ0GIlpdhCVn2vf7YL2r/8E5kSZiMMeMgn5CF7eJU= -github.com/sagernet/sing v0.2.1-0.20230318083058-18cd006d266e/go.mod h1:9uHswk2hITw8leDbiLS/xn0t9nzBcbePxzm9PJhwdlw= +github.com/sagernet/sing v0.2.1-0.20230318094614-4bbf5f2c3046 h1:/+ZWbxRvQmco9ES2qT5Eh/x/IiQRjAcUyRG/vQ4dpxc= +github.com/sagernet/sing v0.2.1-0.20230318094614-4bbf5f2c3046/go.mod h1:9uHswk2hITw8leDbiLS/xn0t9nzBcbePxzm9PJhwdlw= github.com/sagernet/sing-dns v0.1.4 h1:7VxgeoSCiiazDSaXXQVcvrTBxFpOePPq/4XdgnUDN+0= github.com/sagernet/sing-dns v0.1.4/go.mod h1:1+6pCa48B1AI78lD+/i/dLgpw4MwfnsSpZo0Ds8wzzk= github.com/sagernet/sing-shadowsocks v0.1.2-0.20230221080503-769c01d6bba9 h1:qS39eA4C7x+zhEkySbASrtmb6ebdy5v0y2M6mgkmSO0= diff --git a/route/router.go b/route/router.go index 32299ebb..6dbb1922 100644 --- a/route/router.go +++ b/route/router.go @@ -169,6 +169,9 @@ func NewRouter( } else { tag = F.ToString(i) } + if transportTagMap[tag] { + return nil, E.New("duplicate dns server tag: ", tag) + } transportTags[i] = tag transportTagMap[tag] = true } @@ -241,6 +244,9 @@ func NewRouter( }), func(index int, server option.DNSServerOptions) string { return transportTags[index] }) + if len(unresolvedTags) == 0 { + panic(F.ToString("unexpected unresolved dns servers: ", len(transports), " ", len(dummyTransportMap), " ", len(transportMap))) + } return nil, E.New("found circular reference in dns servers: ", strings.Join(unresolvedTags, " ")) } var defaultTransport dns.Transport