From c813fee28cbbb9b1bab0ca921c20032e616af940 Mon Sep 17 00:00:00 2001 From: enfein <83481737+enfein@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:28:34 +0000 Subject: [PATCH] feat: add mieru protocol --- constant/proxy.go | 3 + docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/outbound/mieru.md | 71 +++++++ docs/configuration/outbound/mieru.zh.md | 71 +++++++ go.mod | 1 + go.sum | 2 + include/registry.go | 2 + option/mieru.go | 13 ++ protocol/mieru/outbound.go | 249 ++++++++++++++++++++++++ 10 files changed, 414 insertions(+) create mode 100644 docs/configuration/outbound/mieru.md create mode 100644 docs/configuration/outbound/mieru.zh.md create mode 100644 option/mieru.go create mode 100644 protocol/mieru/outbound.go diff --git a/constant/proxy.go b/constant/proxy.go index 1044428b..4aef6816 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -20,6 +20,7 @@ const ( TypeSSH = "ssh" TypeShadowTLS = "shadowtls" TypeAnyTLS = "anytls" + TypeMieru = "mieru" TypeShadowsocksR = "shadowsocksr" TypeVLESS = "vless" TypeTUIC = "tuic" @@ -80,6 +81,8 @@ func ProxyDisplayName(proxyType string) string { return "Hysteria2" case TypeAnyTLS: return "AnyTLS" + case TypeMieru: + return "Mieru" case TypeSelector: return "Selector" case TypeURLTest: diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index b6094a15..2a93364b 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -31,6 +31,7 @@ | `tuic` | [TUIC](./tuic/) | | `hysteria2` | [Hysteria2](./hysteria2/) | | `anytls` | [AnyTLS](./anytls/) | +| `mieru` | [Mieru](./mieru/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index 1b6066e7..49b66fec 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -31,6 +31,7 @@ | `tuic` | [TUIC](./tuic/) | | `hysteria2` | [Hysteria2](./hysteria2/) | | `anytls` | [AnyTLS](./anytls/) | +| `mieru` | [Mieru](./mieru/) | | `tor` | [Tor](./tor/) | | `ssh` | [SSH](./ssh/) | | `dns` | [DNS](./dns/) | diff --git a/docs/configuration/outbound/mieru.md b/docs/configuration/outbound/mieru.md new file mode 100644 index 00000000..135403f0 --- /dev/null +++ b/docs/configuration/outbound/mieru.md @@ -0,0 +1,71 @@ +--- +icon: material/new-box +--- + +### Structure + +```json +{ + "type": "mieru", + "tag": "mieru-out", + + "server": "127.0.0.1", + "server_port": 1080, + "server_ports": [ + "9000-9010", + "9020-9030" + ], + "transport": "TCP", + "username": "asdf", + "password": "hjkl", + "multiplexing": "MULTIPLEXING_LOW", + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +The server port. + +Must set at least one field between `server_port` and `server_ports`. + +#### server_ports + +Server port range list. + +Must set at least one field between `server_port` and `server_ports`. + +#### transport + +==Required== + +Transmission protocol. The only allowed value is `TCP`. + +#### username + +==Required== + +mieru user name. + +#### password + +==Required== + +mieru password. + +#### multiplexing + +Multiplexing level. Supported values are `MULTIPLEXING_OFF`, `MULTIPLEXING_LOW`, `MULTIPLEXING_MIDDLE`, `MULTIPLEXING_HIGH`. `MULTIPLEXING_OFF` disables multiplexing. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/mieru.zh.md b/docs/configuration/outbound/mieru.zh.md new file mode 100644 index 00000000..fe9b37b8 --- /dev/null +++ b/docs/configuration/outbound/mieru.zh.md @@ -0,0 +1,71 @@ +--- +icon: material/new-box +--- + +### 结构 + +```json +{ + "type": "mieru", + "tag": "mieru-out", + + "server": "127.0.0.1", + "server_port": 1080, + "server_ports": [ + "9000-9010", + "9020-9030" + ], + "transport": "TCP", + "username": "asdf", + "password": "hjkl", + "multiplexing": "MULTIPLEXING_LOW", + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +服务器端口。 + +必须填写 `server_port` 和 `server_ports` 中至少一项。 + +#### server_ports + +服务器端口范围列表。 + +必须填写 `server_port` 和 `server_ports` 中至少一项。 + +#### transport + +==必填== + +通信协议。仅可设为 `TCP`。 + +#### username + +==必填== + +mieru 用户名。 + +#### password + +==必填== + +mieru 密码。 + +#### multiplexing + +多路复用设置。可以设为 `MULTIPLEXING_OFF`,`MULTIPLEXING_LOW`,`MULTIPLEXING_MIDDLE`,`MULTIPLEXING_HIGH`。其中 `MULTIPLEXING_OFF` 会关闭多路复用功能。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/go.mod b/go.mod index 8a2ca284..6e8d6351 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/caddyserver/certmagic v0.21.7 github.com/cloudflare/circl v1.6.0 github.com/cretz/bine v0.2.0 + github.com/enfein/mieru/v3 v3.13.0 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/render v1.0.3 github.com/gofrs/uuid/v5 v5.3.1 diff --git a/go.sum b/go.sum index 9ab20985..d8dce386 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbY github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/enfein/mieru/v3 v3.13.0 h1:eGyxLGkb+lut9ebmx+BGwLJ5UMbEc/wGIYO0AXEKy98= +github.com/enfein/mieru/v3 v3.13.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= diff --git a/include/registry.go b/include/registry.go index 9be1f2b4..a12a1937 100644 --- a/include/registry.go +++ b/include/registry.go @@ -21,6 +21,7 @@ import ( protocolDNS "github.com/sagernet/sing-box/protocol/dns" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/mieru" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" "github.com/sagernet/sing-box/protocol/redirect" @@ -83,6 +84,7 @@ func OutboundRegistry() *outbound.Registry { shadowtls.RegisterOutbound(registry) vless.RegisterOutbound(registry) anytls.RegisterOutbound(registry) + mieru.RegisterOutbound(registry) registerQUICOutbounds(registry) registerWireGuardOutbound(registry) diff --git a/option/mieru.go b/option/mieru.go new file mode 100644 index 00000000..fe5a407b --- /dev/null +++ b/option/mieru.go @@ -0,0 +1,13 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type MieruOutboundOptions struct { + DialerOptions + ServerOptions + ServerPortRanges badoption.Listable[string] `json:"server_ports,omitempty"` + Transport string `json:"transport,omitempty"` + UserName string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Multiplexing string `json:"multiplexing,omitempty"` +} diff --git a/protocol/mieru/outbound.go b/protocol/mieru/outbound.go new file mode 100644 index 00000000..8c5b715f --- /dev/null +++ b/protocol/mieru/outbound.go @@ -0,0 +1,249 @@ +package mieru + +import ( + "context" + "fmt" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + mieruclient "github.com/enfein/mieru/v3/apis/client" + mierucommon "github.com/enfein/mieru/v3/apis/common" + mierumodel "github.com/enfein/mieru/v3/apis/model" + mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" + "google.golang.org/protobuf/proto" +) + +type Outbound struct { + outbound.Adapter + dialer N.Dialer + logger log.ContextLogger + client mieruclient.Client +} + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register(registry, C.TypeMieru, NewOutbound) +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MieruOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain(), + }) + if err != nil { + return nil, err + } + + config, err := buildMieruClientConfig(options, mieruDialer{dialer: outboundDialer}) + if err != nil { + return nil, fmt.Errorf("failed to build mieru client config: %w", err) + } + c := mieruclient.NewClient() + if err := c.Store(config); err != nil { + return nil, fmt.Errorf("failed to store mieru client config: %w", err) + } + if err := c.Start(); err != nil { + return nil, fmt.Errorf("failed to start mieru client: %w", err) + } + logger.InfoContext(ctx, "mieru client is started") + + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMieru, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + dialer: outboundDialer, + logger: logger, + client: c, + }, nil +} + +func (o *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = o.Tag() + metadata.Destination = destination + switch N.NetworkName(network) { + case N.NetworkTCP: + o.logger.InfoContext(ctx, "outbound connection to ", destination) + d, err := socksAddrToNetAddrSpec(destination, "tcp") + if err != nil { + return nil, E.Cause(err, "failed to convert destination address") + } + return o.client.DialContext(ctx, d) + case N.NetworkUDP: + o.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + d, err := socksAddrToNetAddrSpec(destination, "udp") + if err != nil { + return nil, E.Cause(err, "failed to convert destination address") + } + streamConn, err := o.client.DialContext(ctx, d) + if err != nil { + return nil, err + } + return &streamer{ + PacketConn: mierucommon.NewUDPAssociateWrapper(mierucommon.NewPacketOverStreamTunnel(streamConn)), + Remote: destination, + }, nil + default: + return nil, os.ErrInvalid + } +} + +func (o *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = o.Tag() + metadata.Destination = destination + o.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) + d, err := socksAddrToNetAddrSpec(destination, "udp") + if err != nil { + return nil, E.Cause(err, "failed to convert destination address") + } + streamConn, err := o.client.DialContext(ctx, d) + if err != nil { + return nil, err + } + return mierucommon.NewUDPAssociateWrapper(mierucommon.NewPacketOverStreamTunnel(streamConn)), nil +} + +func (o *Outbound) Close() error { + return common.Close(o.client) +} + +// mieruDialer is an adapter to mieru dialer interface. +type mieruDialer struct { + dialer N.Dialer +} + +func (md mieruDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + addr := M.ParseSocksaddr(address) + return md.dialer.DialContext(ctx, network, addr) +} + +var _ mierucommon.Dialer = (*mieruDialer)(nil) + +// streamer converts a net.PacketConn to a net.Conn. +type streamer struct { + net.PacketConn + Remote net.Addr +} + +var _ net.Conn = (*streamer)(nil) + +func (s *streamer) Read(b []byte) (n int, err error) { + n, _, err = s.PacketConn.ReadFrom(b) + return +} + +func (s *streamer) Write(b []byte) (n int, err error) { + return s.WriteTo(b, s.Remote) +} + +func (s *streamer) RemoteAddr() net.Addr { + return s.Remote +} + +// socksAddrToNetAddrSpec converts a Socksaddr object to NetAddrSpec, and overrides the network. +func socksAddrToNetAddrSpec(sa M.Socksaddr, network string) (mierumodel.NetAddrSpec, error) { + var nas mierumodel.NetAddrSpec + if err := nas.From(sa); err != nil { + return nas, err + } + nas.Net = network + return nas, nil +} + +func buildMieruClientConfig(options option.MieruOutboundOptions, dialer mieruDialer) (*mieruclient.ClientConfig, error) { + if err := validateMieruOptions(options); err != nil { + return nil, fmt.Errorf("failed to validate mieru options: %w", err) + } + + transportProtocol := mierupb.TransportProtocol_TCP.Enum() + server := &mierupb.ServerEndpoint{} + if options.ServerPort != 0 { + server.PortBindings = append(server.PortBindings, &mierupb.PortBinding{ + Port: proto.Int32(int32(options.ServerPort)), + Protocol: transportProtocol, + }) + } + for _, pr := range options.ServerPortRanges { + server.PortBindings = append(server.PortBindings, &mierupb.PortBinding{ + PortRange: proto.String(pr), + Protocol: transportProtocol, + }) + } + if options.ServerIsDomain() { + server.DomainName = proto.String(options.Server) + } else { + server.IpAddress = proto.String(options.Server) + } + config := &mieruclient.ClientConfig{ + Profile: &mierupb.ClientProfile{ + ProfileName: proto.String("sing-box"), + User: &mierupb.User{ + Name: proto.String(options.UserName), + Password: proto.String(options.Password), + }, + Servers: []*mierupb.ServerEndpoint{server}, + }, + Dialer: dialer, + } + if multiplexing, ok := mierupb.MultiplexingLevel_value[options.Multiplexing]; ok { + config.Profile.Multiplexing = &mierupb.MultiplexingConfig{ + Level: mierupb.MultiplexingLevel(multiplexing).Enum(), + } + } + return config, nil +} + +func validateMieruOptions(options option.MieruOutboundOptions) error { + if options.Server == "" { + return fmt.Errorf("server is empty") + } + if options.ServerPort == 0 && len(options.ServerPortRanges) == 0 { + return fmt.Errorf("either server_port or server_ports must be set") + } + for _, pr := range options.ServerPortRanges { + begin, end, err := beginAndEndPortFromPortRange(pr) + if err != nil { + return fmt.Errorf("invalid server_ports format") + } + if begin < 1 || begin > 65535 { + return fmt.Errorf("begin port must be between 1 and 65535") + } + if end < 1 || end > 65535 { + return fmt.Errorf("end port must be between 1 and 65535") + } + if begin > end { + return fmt.Errorf("begin port must be less than or equal to end port") + } + } + if options.Transport != "TCP" { + return fmt.Errorf("transport must be TCP") + } + if options.UserName == "" { + return fmt.Errorf("username is empty") + } + if options.Password == "" { + return fmt.Errorf("password is empty") + } + if options.Multiplexing != "" { + if _, ok := mierupb.MultiplexingLevel_value[options.Multiplexing]; !ok { + return fmt.Errorf("invalid multiplexing level: %s", options.Multiplexing) + } + } + return nil +} + +func beginAndEndPortFromPortRange(portRange string) (int, int, error) { + var begin, end int + _, err := fmt.Sscanf(portRange, "%d-%d", &begin, &end) + return begin, end, err +}