feat: add Sudoku protocol inbound & outbound support (#2397)

This commit is contained in:
futai
2025-11-28 23:40:00 +08:00
committed by GitHub
parent 8b6ba22b90
commit 6cf1743961
12 changed files with 700 additions and 3 deletions

22
listener/config/sudoku.go Normal file
View File

@@ -0,0 +1,22 @@
package config
import "encoding/json"
// SudokuServer describes a Sudoku inbound server configuration.
// It is internal to the listener layer and mainly used for logging and wiring.
type SudokuServer struct {
Enable bool `json:"enable"`
Listen string `json:"listen"`
Key string `json:"key"`
AEADMethod string `json:"aead-method,omitempty"`
PaddingMin *int `json:"padding-min,omitempty"`
PaddingMax *int `json:"padding-max,omitempty"`
Seed string `json:"seed,omitempty"`
TableType string `json:"table-type,omitempty"`
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
}
func (s SudokuServer) String() string {
b, _ := json.Marshal(s)
return string(b)
}

128
listener/inbound/sudoku.go Normal file
View File

@@ -0,0 +1,128 @@
package inbound
import (
"errors"
"fmt"
"strings"
"github.com/saba-futai/sudoku/apis"
C "github.com/metacubex/mihomo/constant"
LC "github.com/metacubex/mihomo/listener/config"
sudokuListener "github.com/metacubex/mihomo/listener/sudoku"
"github.com/metacubex/mihomo/log"
)
type SudokuOption struct {
BaseOption
Key string `inbound:"key"`
AEADMethod string `inbound:"aead-method,omitempty"`
PaddingMin *int `inbound:"padding-min,omitempty"`
PaddingMax *int `inbound:"padding-max,omitempty"`
Seed string `inbound:"seed,omitempty"`
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
}
func (o SudokuOption) Equal(config C.InboundConfig) bool {
return optionToString(o) == optionToString(config)
}
type Sudoku struct {
*Base
config *SudokuOption
listeners []*sudokuListener.Listener
serverConf LC.SudokuServer
}
func NewSudoku(options *SudokuOption) (*Sudoku, error) {
if options.Key == "" {
return nil, fmt.Errorf("sudoku inbound requires key")
}
base, err := NewBase(&options.BaseOption)
if err != nil {
return nil, err
}
defaultConf := apis.DefaultConfig()
serverConf := LC.SudokuServer{
Enable: true,
Listen: base.RawAddress(),
Key: options.Key,
AEADMethod: options.AEADMethod,
PaddingMin: options.PaddingMin,
PaddingMax: options.PaddingMax,
Seed: options.Seed,
TableType: options.TableType,
}
if options.HandshakeTimeoutSecond != nil {
serverConf.HandshakeTimeoutSecond = options.HandshakeTimeoutSecond
} else {
// Use Sudoku default if not specified.
v := defaultConf.HandshakeTimeoutSeconds
serverConf.HandshakeTimeoutSecond = &v
}
return &Sudoku{
Base: base,
config: options,
serverConf: serverConf,
}, nil
}
// Config implements constant.InboundListener
func (s *Sudoku) Config() C.InboundConfig {
return s.config
}
// Address implements constant.InboundListener
func (s *Sudoku) Address() string {
var addrList []string
for _, l := range s.listeners {
addrList = append(addrList, l.Address())
}
return strings.Join(addrList, ",")
}
// Listen implements constant.InboundListener
func (s *Sudoku) Listen(tunnel C.Tunnel) error {
if s.serverConf.Key == "" {
return fmt.Errorf("sudoku inbound requires key")
}
var errs []error
for _, addr := range strings.Split(s.RawAddress(), ",") {
conf := s.serverConf
conf.Listen = addr
l, err := sudokuListener.New(conf, tunnel, s.Additions()...)
if err != nil {
errs = append(errs, err)
continue
}
s.listeners = append(s.listeners, l)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
log.Infoln("Sudoku[%s] inbound listening at: %s", s.Name(), s.Address())
return nil
}
// Close implements constant.InboundListener
func (s *Sudoku) Close() error {
var errs []error
for _, l := range s.listeners {
if err := l.Close(); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
var _ C.InboundListener = (*Sudoku)(nil)

View File

@@ -0,0 +1,91 @@
package inbound_test
import (
"net/netip"
"testing"
"github.com/metacubex/mihomo/adapter/outbound"
"github.com/metacubex/mihomo/listener/inbound"
"github.com/stretchr/testify/assert"
)
func testInboundSudoku(t *testing.T, inboundOptions inbound.SudokuOption, outboundOptions outbound.SudokuOption) {
t.Parallel()
inboundOptions.BaseOption = inbound.BaseOption{
NameStr: "sudoku_inbound",
Listen: "127.0.0.1",
Port: "0",
}
in, err := inbound.NewSudoku(&inboundOptions)
if !assert.NoError(t, err) {
return
}
tunnel := NewHttpTestTunnel()
defer tunnel.Close()
err = in.Listen(tunnel)
if !assert.NoError(t, err) {
return
}
defer in.Close()
addrPort, err := netip.ParseAddrPort(in.Address())
if !assert.NoError(t, err) {
return
}
outboundOptions.Name = "sudoku_outbound"
outboundOptions.Server = addrPort.Addr().String()
outboundOptions.Port = int(addrPort.Port())
out, err := outbound.NewSudoku(outboundOptions)
if !assert.NoError(t, err) {
return
}
defer out.Close()
tunnel.DoTest(t, out)
}
func TestInboundSudoku_Basic(t *testing.T) {
key := "test_key"
inboundOptions := inbound.SudokuOption{
Key: key,
}
outboundOptions := outbound.SudokuOption{
Key: key,
}
testInboundSudoku(t, inboundOptions, outboundOptions)
}
func TestInboundSudoku_Entropy(t *testing.T) {
key := "test_key_entropy"
inboundOptions := inbound.SudokuOption{
Key: key,
TableType: "prefer_entropy",
}
outboundOptions := outbound.SudokuOption{
Key: key,
TableType: "prefer_entropy",
}
testInboundSudoku(t, inboundOptions, outboundOptions)
}
func TestInboundSudoku_Padding(t *testing.T) {
key := "test_key_padding"
min := 10
max := 100
inboundOptions := inbound.SudokuOption{
Key: key,
PaddingMin: &min,
PaddingMax: &max,
}
outboundOptions := outbound.SudokuOption{
Key: key,
PaddingMin: &min,
PaddingMax: &max,
}
testInboundSudoku(t, inboundOptions, outboundOptions)
}

View File

@@ -134,6 +134,13 @@ func ParseListener(mapping map[string]any) (C.InboundListener, error) {
return nil, err
}
listener, err = IN.NewMieru(mieruOption)
case "sudoku":
sudokuOption := &IN.SudokuOption{}
err = decoder.Decode(mapping, sudokuOption)
if err != nil {
return nil, err
}
listener, err = IN.NewSudoku(sudokuOption)
default:
return nil, fmt.Errorf("unsupport proxy type: %s", proxyType)
}

139
listener/sudoku/server.go Normal file
View File

@@ -0,0 +1,139 @@
package sudoku
import (
"net"
"strings"
"github.com/saba-futai/sudoku/apis"
sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku"
"github.com/metacubex/mihomo/adapter/inbound"
C "github.com/metacubex/mihomo/constant"
LC "github.com/metacubex/mihomo/listener/config"
"github.com/metacubex/mihomo/transport/socks5"
)
type Listener struct {
listener net.Listener
addr string
closed bool
protoConf apis.ProtocolConfig
}
// RawAddress implements C.Listener
func (l *Listener) RawAddress() string {
return l.addr
}
// Address implements C.Listener
func (l *Listener) Address() string {
if l.listener == nil {
return ""
}
return l.listener.Addr().String()
}
// Close implements C.Listener
func (l *Listener) Close() error {
l.closed = true
if l.listener != nil {
return l.listener.Close()
}
return nil
}
func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbound.Addition) {
tunnelConn, target, err := apis.ServerHandshake(conn, &l.protoConf)
if err != nil {
_ = conn.Close()
return
}
targetAddr := socks5.ParseAddr(target)
if targetAddr == nil {
_ = tunnelConn.Close()
return
}
tunnel.HandleTCPConn(inbound.NewSocket(targetAddr, tunnelConn, C.SUDOKU, additions...))
}
func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) (*Listener, error) {
if len(additions) == 0 {
additions = []inbound.Addition{
inbound.WithInName("DEFAULT-SUDOKU"),
inbound.WithSpecialRules(""),
}
}
l, err := inbound.Listen("tcp", config.Listen)
if err != nil {
return nil, err
}
seed := config.Seed
if seed == "" {
seed = config.Key
}
tableType := strings.ToLower(config.TableType)
if tableType == "" {
tableType = "prefer_ascii"
}
table := sudokuobfs.NewTable(seed, tableType)
defaultConf := apis.DefaultConfig()
paddingMin := defaultConf.PaddingMin
paddingMax := defaultConf.PaddingMax
if config.PaddingMin != nil {
paddingMin = *config.PaddingMin
}
if config.PaddingMax != nil {
paddingMax = *config.PaddingMax
}
if config.PaddingMin == nil && config.PaddingMax != nil && paddingMax < paddingMin {
paddingMin = paddingMax
}
if config.PaddingMax == nil && config.PaddingMin != nil && paddingMax < paddingMin {
paddingMax = paddingMin
}
handshakeTimeout := defaultConf.HandshakeTimeoutSeconds
if config.HandshakeTimeoutSecond != nil {
handshakeTimeout = *config.HandshakeTimeoutSecond
}
protoConf := apis.ProtocolConfig{
Key: config.Key,
AEADMethod: defaultConf.AEADMethod,
Table: table,
PaddingMin: paddingMin,
PaddingMax: paddingMax,
HandshakeTimeoutSeconds: handshakeTimeout,
}
if config.AEADMethod != "" {
protoConf.AEADMethod = config.AEADMethod
}
sl := &Listener{
listener: l,
addr: config.Listen,
protoConf: protoConf,
}
go func() {
for {
c, err := l.Accept()
if err != nil {
if sl.closed {
break
}
continue
}
go sl.handleConn(c, tunnel, additions...)
}
}()
return sl, nil
}