diff --git a/cmd/sing-box/cmd_merge.go b/cmd/sing-box/cmd_merge.go new file mode 100644 index 00000000..82c92a45 --- /dev/null +++ b/cmd/sing-box/cmd_merge.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/common/json" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/rw" + + "github.com/spf13/cobra" +) + +var commandMerge = &cobra.Command{ + Use: "merge [output]", + Short: "Merge configurations", + Run: func(cmd *cobra.Command, args []string) { + err := merge(args[0]) + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.ExactArgs(1), +} + +func init() { + mainCommand.AddCommand(commandMerge) +} + +func merge(outputPath string) error { + mergedOptions, err := readConfigAndMerge() + if err != nil { + return err + } + err = mergePathResources(&mergedOptions) + if err != nil { + return err + } + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(mergedOptions) + if err != nil { + return E.Cause(err, "encode config") + } + if existsContent, err := os.ReadFile(outputPath); err != nil { + if string(existsContent) == buffer.String() { + return nil + } + } + err = rw.WriteFile(outputPath, buffer.Bytes()) + if err != nil { + return err + } + outputPath, _ = filepath.Abs(outputPath) + os.Stderr.WriteString(outputPath + "\n") + return nil +} + +func mergePathResources(options *option.Options) error { + for index, inbound := range options.Inbounds { + switch inbound.Type { + case C.TypeHTTP: + inbound.HTTPOptions.TLS = mergeTLSInboundOptions(inbound.HTTPOptions.TLS) + case C.TypeMixed: + inbound.MixedOptions.TLS = mergeTLSInboundOptions(inbound.MixedOptions.TLS) + case C.TypeVMess: + inbound.VMessOptions.TLS = mergeTLSInboundOptions(inbound.VMessOptions.TLS) + case C.TypeTrojan: + inbound.TrojanOptions.TLS = mergeTLSInboundOptions(inbound.TrojanOptions.TLS) + case C.TypeNaive: + inbound.NaiveOptions.TLS = mergeTLSInboundOptions(inbound.NaiveOptions.TLS) + case C.TypeHysteria: + inbound.HysteriaOptions.TLS = mergeTLSInboundOptions(inbound.HysteriaOptions.TLS) + case C.TypeVLESS: + inbound.VLESSOptions.TLS = mergeTLSInboundOptions(inbound.VLESSOptions.TLS) + case C.TypeTUIC: + inbound.TUICOptions.TLS = mergeTLSInboundOptions(inbound.TUICOptions.TLS) + case C.TypeHysteria2: + inbound.Hysteria2Options.TLS = mergeTLSInboundOptions(inbound.Hysteria2Options.TLS) + default: + continue + } + options.Inbounds[index] = inbound + } + for index, outbound := range options.Outbounds { + switch outbound.Type { + case C.TypeHTTP: + outbound.HTTPOptions.TLS = mergeTLSOutboundOptions(outbound.HTTPOptions.TLS) + case C.TypeVMess: + outbound.VMessOptions.TLS = mergeTLSOutboundOptions(outbound.VMessOptions.TLS) + case C.TypeTrojan: + outbound.TrojanOptions.TLS = mergeTLSOutboundOptions(outbound.TrojanOptions.TLS) + case C.TypeHysteria: + outbound.HysteriaOptions.TLS = mergeTLSOutboundOptions(outbound.HysteriaOptions.TLS) + case C.TypeSSH: + outbound.SSHOptions = mergeSSHOutboundOptions(outbound.SSHOptions) + case C.TypeVLESS: + outbound.VLESSOptions.TLS = mergeTLSOutboundOptions(outbound.VLESSOptions.TLS) + case C.TypeTUIC: + outbound.TUICOptions.TLS = mergeTLSOutboundOptions(outbound.TUICOptions.TLS) + case C.TypeHysteria2: + outbound.Hysteria2Options.TLS = mergeTLSOutboundOptions(outbound.Hysteria2Options.TLS) + default: + continue + } + options.Outbounds[index] = outbound + } + return nil +} + +func mergeTLSInboundOptions(options *option.InboundTLSOptions) *option.InboundTLSOptions { + if options == nil { + return nil + } + if options.CertificatePath != "" { + if content, err := os.ReadFile(options.CertificatePath); err == nil { + options.Certificate = strings.Split(string(content), "\n") + } + } + if options.KeyPath != "" { + if content, err := os.ReadFile(options.KeyPath); err == nil { + options.Key = strings.Split(string(content), "\n") + } + } + if options.ECH != nil { + if options.ECH.KeyPath != "" { + if content, err := os.ReadFile(options.ECH.KeyPath); err == nil { + options.ECH.Key = strings.Split(string(content), "\n") + } + } + } + return options +} + +func mergeTLSOutboundOptions(options *option.OutboundTLSOptions) *option.OutboundTLSOptions { + if options == nil { + return nil + } + if options.CertificatePath != "" { + if content, err := os.ReadFile(options.CertificatePath); err == nil { + options.Certificate = strings.Split(string(content), "\n") + } + } + if options.ECH != nil { + if options.ECH.ConfigPath != "" { + if content, err := os.ReadFile(options.ECH.ConfigPath); err == nil { + options.ECH.Config = strings.Split(string(content), "\n") + } + } + } + return options +} + +func mergeSSHOutboundOptions(options option.SSHOutboundOptions) option.SSHOutboundOptions { + if options.PrivateKeyPath != "" { + if content, err := os.ReadFile(options.PrivateKeyPath); err == nil { + options.PrivateKey = strings.Split(string(content), "\n") + } + } + return options +} diff --git a/common/tls/ech_client.go b/common/tls/ech_client.go index 60560387..7f72b4d8 100644 --- a/common/tls/ech_client.go +++ b/common/tls/ech_client.go @@ -149,8 +149,8 @@ func NewECHClient(ctx context.Context, serverAddress string, options option.Outb } } var certificate []byte - if options.Certificate != "" { - certificate = []byte(options.Certificate) + if len(options.Certificate) > 0 { + certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { content, err := os.ReadFile(options.CertificatePath) if err != nil { diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 4aa50b94..90f51821 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -7,6 +7,7 @@ import ( "net" "net/netip" "os" + "strings" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -111,8 +112,8 @@ func NewSTDClient(ctx context.Context, serverAddress string, options option.Outb } } var certificate []byte - if options.Certificate != "" { - certificate = []byte(options.Certificate) + if len(options.Certificate) > 0 { + certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { content, err := os.ReadFile(options.CertificatePath) if err != nil { diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index d190a1f7..be81b32c 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -10,6 +10,7 @@ import ( "net" "net/netip" "os" + "strings" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -168,8 +169,8 @@ func NewUTLSClient(ctx context.Context, serverAddress string, options option.Out } } var certificate []byte - if options.Certificate != "" { - certificate = []byte(options.Certificate) + if len(options.Certificate) > 0 { + certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { content, err := os.ReadFile(options.CertificatePath) if err != nil { diff --git a/docs/configuration/index.md b/docs/configuration/index.md index ee96b5fb..a4ade707 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -31,11 +31,17 @@ sing-box uses JSON for configuration files. ### Check ```bash -$ sing-box check +sing-box check ``` ### Format ```bash -$ sing-box format -w +sing-box format -w -c config.json -D config_directory +``` + +### Merge + +```bash +sing-box merge output.json -c config.json -D config_directory ``` \ No newline at end of file diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index faccf9fa..80b2ebd3 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -29,11 +29,17 @@ sing-box 使用 JSON 作为配置文件格式。 ### 检查 ```bash -$ sing-box check +sing-box check ``` ### 格式化 ```bash -$ sing-box format -w +sing-box format -w -c config.json -D config_directory +``` + +### 合并 + +```bash +sing-box merge output.json -c config.json -D config_directory ``` \ No newline at end of file diff --git a/option/ssh.go b/option/ssh.go index 1f25caf8..d0bfbf74 100644 --- a/option/ssh.go +++ b/option/ssh.go @@ -5,7 +5,7 @@ type SSHOutboundOptions struct { ServerOptions User string `json:"user,omitempty"` Password string `json:"password,omitempty"` - PrivateKey string `json:"private_key,omitempty"` + PrivateKey Listable[string] `json:"private_key,omitempty"` PrivateKeyPath string `json:"private_key_path,omitempty"` PrivateKeyPassphrase string `json:"private_key_passphrase,omitempty"` HostKey Listable[string] `json:"host_key,omitempty"` diff --git a/option/tls.go b/option/tls.go index 63944980..a38b4ef5 100644 --- a/option/tls.go +++ b/option/tls.go @@ -26,7 +26,7 @@ type OutboundTLSOptions struct { MinVersion string `json:"min_version,omitempty"` MaxVersion string `json:"max_version,omitempty"` CipherSuites Listable[string] `json:"cipher_suites,omitempty"` - Certificate string `json:"certificate,omitempty"` + Certificate Listable[string] `json:"certificate,omitempty"` CertificatePath string `json:"certificate_path,omitempty"` ECH *OutboundECHOptions `json:"ech,omitempty"` UTLS *OutboundUTLSOptions `json:"utls,omitempty"` diff --git a/outbound/ssh.go b/outbound/ssh.go index 0c6a9894..ce62b004 100644 --- a/outbound/ssh.go +++ b/outbound/ssh.go @@ -8,6 +8,7 @@ import ( "net" "os" "strconv" + "strings" "sync" "github.com/sagernet/sing-box/adapter" @@ -76,10 +77,10 @@ func NewSSH(ctx context.Context, router adapter.Router, logger log.ContextLogger if options.Password != "" { outbound.authMethod = append(outbound.authMethod, ssh.Password(options.Password)) } - if options.PrivateKey != "" || options.PrivateKeyPath != "" { + if len(options.PrivateKey) > 0 || options.PrivateKeyPath != "" { var privateKey []byte - if options.PrivateKey != "" { - privateKey = []byte(options.PrivateKey) + if len(options.PrivateKey) > 0 { + privateKey = []byte(strings.Join(options.PrivateKey, "\n")) } else { var err error privateKey, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath)) diff --git a/transport/sip003/v2ray.go b/transport/sip003/v2ray.go index 29054c1a..c142180b 100644 --- a/transport/sip003/v2ray.go +++ b/transport/sip003/v2ray.go @@ -32,7 +32,7 @@ func newV2RayPlugin(ctx context.Context, pluginOpts Args, router adapter.Router, certHead := "-----BEGIN CERTIFICATE-----" certTail := "-----END CERTIFICATE-----" fixedCert := certHead + "\n" + certRaw + "\n" + certTail - tlsOptions.Certificate = fixedCert + tlsOptions.Certificate = []string{fixedCert} } mode := "websocket"