mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-22 16:07:49 +08:00
Update On Tue Dec 16 19:42:47 CET 2025
This commit is contained in:
@@ -1213,3 +1213,4 @@ Update On Fri Dec 12 19:42:07 CET 2025
|
||||
Update On Sat Dec 13 19:37:32 CET 2025
|
||||
Update On Sun Dec 14 19:39:26 CET 2025
|
||||
Update On Mon Dec 15 19:43:13 CET 2025
|
||||
Update On Tue Dec 16 19:42:39 CET 2025
|
||||
|
||||
@@ -20,17 +20,19 @@ type Sudoku struct {
|
||||
|
||||
type SudokuOption struct {
|
||||
BasicOption
|
||||
Name string `proxy:"name"`
|
||||
Server string `proxy:"server"`
|
||||
Port int `proxy:"port"`
|
||||
Key string `proxy:"key"`
|
||||
AEADMethod string `proxy:"aead-method,omitempty"`
|
||||
PaddingMin *int `proxy:"padding-min,omitempty"`
|
||||
PaddingMax *int `proxy:"padding-max,omitempty"`
|
||||
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
|
||||
HTTPMask bool `proxy:"http-mask,omitempty"`
|
||||
CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
Name string `proxy:"name"`
|
||||
Server string `proxy:"server"`
|
||||
Port int `proxy:"port"`
|
||||
Key string `proxy:"key"`
|
||||
AEADMethod string `proxy:"aead-method,omitempty"`
|
||||
PaddingMin *int `proxy:"padding-min,omitempty"`
|
||||
PaddingMax *int `proxy:"padding-max,omitempty"`
|
||||
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
|
||||
HTTPMask bool `proxy:"http-mask,omitempty"`
|
||||
HTTPMaskStrategy string `proxy:"http-mask-strategy,omitempty"` // "random" (default), "post", "websocket"
|
||||
CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty
|
||||
}
|
||||
|
||||
// DialContext implements C.ProxyAdapter
|
||||
@@ -54,7 +56,9 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con
|
||||
defer done(&err)
|
||||
}
|
||||
|
||||
c, err = sudoku.ClientHandshake(c, cfg)
|
||||
c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{
|
||||
HTTPMaskStrategy: s.option.HTTPMaskStrategy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -97,7 +101,9 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
|
||||
defer done(&err)
|
||||
}
|
||||
|
||||
c, err = sudoku.ClientHandshake(c, cfg)
|
||||
c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{
|
||||
HTTPMaskStrategy: s.option.HTTPMaskStrategy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,11 +191,15 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) {
|
||||
HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds,
|
||||
DisableHTTPMask: !option.HTTPMask,
|
||||
}
|
||||
table, err := sudoku.NewTableWithCustom(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable)
|
||||
tables, err := sudoku.NewTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build table failed: %w", err)
|
||||
return nil, fmt.Errorf("build table(s) failed: %w", err)
|
||||
}
|
||||
if len(tables) == 1 {
|
||||
baseConf.Table = tables[0]
|
||||
} else {
|
||||
baseConf.Tables = tables
|
||||
}
|
||||
baseConf.Table = table
|
||||
if option.AEADMethod != "" {
|
||||
baseConf.AEADMethod = option.AEADMethod
|
||||
}
|
||||
|
||||
@@ -1049,7 +1049,9 @@ proxies: # socks5
|
||||
padding-max: 7 # 最大填充字节数
|
||||
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3
|
||||
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy`
|
||||
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table
|
||||
http-mask: true # 是否启用http掩码
|
||||
# http-mask-strategy: random # 可选:random(默认)、post、websocket;仅在 http-mask=true 时生效
|
||||
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none)
|
||||
|
||||
# anytls
|
||||
@@ -1591,6 +1593,7 @@ listeners:
|
||||
padding-max: 15 # 填充最大长度,均不建议过大
|
||||
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3
|
||||
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy`
|
||||
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table
|
||||
handshake-timeout: 5 # optional
|
||||
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none)
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ require (
|
||||
github.com/mroth/weightedrand/v2 v2.1.0
|
||||
github.com/openacid/low v0.1.21
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 // lastest version compatible with golang1.20
|
||||
github.com/saba-futai/sudoku v0.0.2-c
|
||||
github.com/saba-futai/sudoku v0.0.2-d
|
||||
github.com/sagernet/cors v1.2.1
|
||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a
|
||||
github.com/samber/lo v1.52.0
|
||||
|
||||
+2
-2
@@ -171,8 +171,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
|
||||
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
|
||||
github.com/saba-futai/sudoku v0.0.2-c h1:0CaoCKx4Br8UL97fnIxn8Y7rnQpflBza7kfaIrdg2rI=
|
||||
github.com/saba-futai/sudoku v0.0.2-c/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo=
|
||||
github.com/saba-futai/sudoku v0.0.2-d h1:HW/gIyNUFcDchpMN+ZhluM86U/HGkWkkRV+9Km6WZM8=
|
||||
github.com/saba-futai/sudoku v0.0.2-d/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo=
|
||||
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
||||
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
|
||||
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
// 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"`
|
||||
TableType string `json:"table-type,omitempty"`
|
||||
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `json:"custom-table,omitempty"`
|
||||
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"`
|
||||
TableType string `json:"table-type,omitempty"`
|
||||
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `json:"custom-table,omitempty"`
|
||||
CustomTables []string `json:"custom-tables,omitempty"`
|
||||
|
||||
// mihomo private extension (not the part of standard Sudoku protocol)
|
||||
MuxOption sing.MuxOption `json:"mux-option,omitempty"`
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
|
||||
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"`
|
||||
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
Key string `inbound:"key"`
|
||||
AEADMethod string `inbound:"aead-method,omitempty"`
|
||||
PaddingMin *int `inbound:"padding-min,omitempty"`
|
||||
PaddingMax *int `inbound:"padding-max,omitempty"`
|
||||
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
CustomTables []string `inbound:"custom-tables,omitempty"`
|
||||
|
||||
// mihomo private extension (not the part of standard Sudoku protocol)
|
||||
MuxOption MuxOption `inbound:"mux-option,omitempty"`
|
||||
@@ -57,6 +58,7 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) {
|
||||
HandshakeTimeoutSecond: options.HandshakeTimeoutSecond,
|
||||
EnablePureDownlink: options.EnablePureDownlink,
|
||||
CustomTable: options.CustomTable,
|
||||
CustomTables: options.CustomTables,
|
||||
}
|
||||
serverConf.MuxOption = options.MuxOption.Build()
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
|
||||
enablePureDownlink = *config.EnablePureDownlink
|
||||
}
|
||||
|
||||
table, err := sudoku.NewTableWithCustom(config.Key, tableType, config.CustomTable)
|
||||
tables, err := sudoku.NewTablesWithCustomPatterns(config.Key, tableType, config.CustomTable, config.CustomTables)
|
||||
if err != nil {
|
||||
_ = l.Close()
|
||||
return nil, err
|
||||
@@ -180,12 +180,16 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
|
||||
protoConf := sudoku.ProtocolConfig{
|
||||
Key: config.Key,
|
||||
AEADMethod: defaultConf.AEADMethod,
|
||||
Table: table,
|
||||
PaddingMin: paddingMin,
|
||||
PaddingMax: paddingMax,
|
||||
EnablePureDownlink: enablePureDownlink,
|
||||
HandshakeTimeoutSeconds: handshakeTimeout,
|
||||
}
|
||||
if len(tables) == 1 {
|
||||
protoConf.Table = tables[0]
|
||||
} else {
|
||||
protoConf.Tables = tables
|
||||
}
|
||||
if config.AEADMethod != "" {
|
||||
protoConf.AEADMethod = config.AEADMethod
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
type discardConn struct{}
|
||||
|
||||
func (discardConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (discardConn) Close() error { return nil }
|
||||
func (discardConn) LocalAddr() net.Addr { return nil }
|
||||
func (discardConn) RemoteAddr() net.Addr { return nil }
|
||||
func (discardConn) SetDeadline(time.Time) error { return nil }
|
||||
func (discardConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (discardConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func TestSudokuObfsWriter_ReducesWriteAllocs(t *testing.T) {
|
||||
table := sudokuobfs.NewTable("alloc-seed", "prefer_ascii")
|
||||
w := newSudokuObfsWriter(discardConn{}, table, 0, 0)
|
||||
|
||||
payload := bytes.Repeat([]byte{0x42}, 2048)
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
t.Fatalf("warmup write: %v", err)
|
||||
}
|
||||
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("expected 0 allocs/run, got %.2f", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMaskStrategy_WebSocketAndPost(t *testing.T) {
|
||||
key := "mask-test-key"
|
||||
target := "1.1.1.1:80"
|
||||
table := sudokuobfs.NewTable("mask-seed", "prefer_ascii")
|
||||
|
||||
base := DefaultConfig()
|
||||
base.Key = key
|
||||
base.AEADMethod = "chacha20-poly1305"
|
||||
base.Table = table
|
||||
base.PaddingMin = 0
|
||||
base.PaddingMax = 0
|
||||
base.EnablePureDownlink = true
|
||||
base.HandshakeTimeoutSeconds = 5
|
||||
base.DisableHTTPMask = false
|
||||
base.ServerAddress = "example.com:443"
|
||||
|
||||
cases := []string{"post", "websocket"}
|
||||
for _, strategy := range cases {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
defer clientConn.Close()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
session, err := ServerHandshake(serverConn, base)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
defer session.Conn.Close()
|
||||
if session.Type != SessionTypeTCP {
|
||||
errCh <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if session.Target != target {
|
||||
errCh <- io.ErrClosedPipe
|
||||
return
|
||||
}
|
||||
_, _ = session.Conn.Write([]byte("ok"))
|
||||
}()
|
||||
|
||||
cConn, err := ClientHandshakeWithOptions(clientConn, base, ClientHandshakeOptions{HTTPMaskStrategy: strategy})
|
||||
if err != nil {
|
||||
t.Fatalf("client handshake: %v", err)
|
||||
}
|
||||
defer cConn.Close()
|
||||
|
||||
addrBuf, err := EncodeAddress(target)
|
||||
if err != nil {
|
||||
t.Fatalf("encode addr: %v", err)
|
||||
}
|
||||
if _, err := cConn.Write(addrBuf); err != nil {
|
||||
t.Fatalf("write addr: %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 2)
|
||||
if _, err := io.ReadFull(cConn, buf); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(buf) != "ok" {
|
||||
t.Fatalf("unexpected payload: %q", buf)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTablesRotation_ProbedByServer(t *testing.T) {
|
||||
key := "rotate-test-key"
|
||||
target := "8.8.8.8:53"
|
||||
|
||||
t1, err := sudokuobfs.NewTableWithCustom("rotate-seed", "prefer_entropy", "xpxvvpvv")
|
||||
if err != nil {
|
||||
t.Fatalf("t1: %v", err)
|
||||
}
|
||||
t2, err := sudokuobfs.NewTableWithCustom("rotate-seed", "prefer_entropy", "vxpvxvvp")
|
||||
if err != nil {
|
||||
t.Fatalf("t2: %v", err)
|
||||
}
|
||||
|
||||
serverCfg := DefaultConfig()
|
||||
serverCfg.Key = key
|
||||
serverCfg.AEADMethod = "chacha20-poly1305"
|
||||
serverCfg.Tables = []*sudokuobfs.Table{t1, t2}
|
||||
serverCfg.PaddingMin = 0
|
||||
serverCfg.PaddingMax = 0
|
||||
serverCfg.EnablePureDownlink = true
|
||||
serverCfg.HandshakeTimeoutSeconds = 5
|
||||
serverCfg.DisableHTTPMask = true
|
||||
|
||||
clientCfg := DefaultConfig()
|
||||
*clientCfg = *serverCfg
|
||||
clientCfg.ServerAddress = "example.com:443"
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
defer serverConn.Close()
|
||||
session, err := ServerHandshake(serverConn, serverCfg)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
defer session.Conn.Close()
|
||||
if session.Type != SessionTypeTCP {
|
||||
errCh <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if session.Target != target {
|
||||
errCh <- io.ErrClosedPipe
|
||||
return
|
||||
}
|
||||
_, _ = session.Conn.Write([]byte{0xaa, 0xbb, 0xcc})
|
||||
}()
|
||||
|
||||
cConn, err := ClientHandshake(clientConn, clientCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("client handshake: %v", err)
|
||||
}
|
||||
|
||||
addrBuf, err := EncodeAddress(target)
|
||||
if err != nil {
|
||||
t.Fatalf("encode addr: %v", err)
|
||||
}
|
||||
if _, err := cConn.Write(addrBuf); err != nil {
|
||||
t.Fatalf("write addr: %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 3)
|
||||
if _, err := io.ReadFull(cConn, buf); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, []byte{0xaa, 0xbb, 0xcc}) {
|
||||
t.Fatalf("payload mismatch: %x", buf)
|
||||
}
|
||||
_ = cConn.Close()
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/saba-futai/sudoku/apis"
|
||||
@@ -110,25 +112,35 @@ func downlinkMode(cfg *apis.ProtocolConfig) byte {
|
||||
return downlinkModePacked
|
||||
}
|
||||
|
||||
func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig) net.Conn {
|
||||
base := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table) net.Conn {
|
||||
baseReader := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
baseWriter := newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
if cfg.EnablePureDownlink {
|
||||
return base
|
||||
return &directionalConn{
|
||||
Conn: raw,
|
||||
reader: baseReader,
|
||||
writer: baseWriter,
|
||||
}
|
||||
}
|
||||
packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
return &directionalConn{
|
||||
Conn: raw,
|
||||
reader: packed,
|
||||
writer: base,
|
||||
writer: baseWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, record bool) (*sudoku.Conn, net.Conn) {
|
||||
uplink := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, record)
|
||||
func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) {
|
||||
uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record)
|
||||
if cfg.EnablePureDownlink {
|
||||
return uplink, uplink
|
||||
downlink := &directionalConn{
|
||||
Conn: raw,
|
||||
reader: uplink,
|
||||
writer: newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax),
|
||||
}
|
||||
return uplink, downlink
|
||||
}
|
||||
packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
return uplink, &directionalConn{
|
||||
Conn: raw,
|
||||
reader: uplink,
|
||||
@@ -170,8 +182,19 @@ func ClientAEADSeed(key string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
type ClientHandshakeOptions struct {
|
||||
// HTTPMaskStrategy controls how the client generates the HTTP mask header when DisableHTTPMask=false.
|
||||
// Supported: ""/"random" (default), "post", "websocket".
|
||||
HTTPMaskStrategy string
|
||||
}
|
||||
|
||||
// ClientHandshake performs the client-side Sudoku handshake (without sending target address).
|
||||
func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) {
|
||||
return ClientHandshakeWithOptions(rawConn, cfg, ClientHandshakeOptions{})
|
||||
}
|
||||
|
||||
// ClientHandshakeWithOptions performs the client-side Sudoku handshake (without sending target address).
|
||||
func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is required")
|
||||
}
|
||||
@@ -180,18 +203,26 @@ func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, erro
|
||||
}
|
||||
|
||||
if !cfg.DisableHTTPMask {
|
||||
if err := httpmask.WriteRandomRequestHeader(rawConn, cfg.ServerAddress); err != nil {
|
||||
if err := WriteHTTPMaskHeader(rawConn, cfg.ServerAddress, opt.HTTPMaskStrategy); err != nil {
|
||||
return nil, fmt.Errorf("write http mask failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
obfsConn := buildClientObfsConn(rawConn, cfg)
|
||||
table, tableID, err := pickClientTable(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obfsConn := buildClientObfsConn(rawConn, cfg, table)
|
||||
cConn, err := crypto.NewAEADConn(obfsConn, ClientAEADSeed(cfg.Key), cfg.AEADMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup crypto failed: %w", err)
|
||||
}
|
||||
|
||||
handshake := buildHandshakePayload(cfg.Key)
|
||||
if len(tableCandidates(cfg)) > 1 {
|
||||
handshake[15] = tableID
|
||||
}
|
||||
if _, err := cConn.Write(handshake[:]); err != nil {
|
||||
cConn.Close()
|
||||
return nil, fmt.Errorf("send handshake failed: %w", err)
|
||||
@@ -218,21 +249,25 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession
|
||||
handshakeTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
||||
|
||||
bufReader := bufio.NewReader(rawConn)
|
||||
if !cfg.DisableHTTPMask {
|
||||
if peek, _ := bufReader.Peek(4); len(peek) == 4 && string(peek) == "POST" {
|
||||
if peek, err := bufReader.Peek(4); err == nil && httpmask.LooksLikeHTTPRequestStart(peek) {
|
||||
if _, err := httpmask.ConsumeHeader(bufReader); err != nil {
|
||||
return nil, fmt.Errorf("invalid http header: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
||||
bConn := &bufferedConn{
|
||||
Conn: rawConn,
|
||||
r: bufReader,
|
||||
selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, tableCandidates(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sConn, obfsConn := buildServerObfsConn(bConn, cfg, true)
|
||||
|
||||
baseConn := &preBufferedConn{Conn: rawConn, buf: preRead}
|
||||
bConn := &bufferedConn{Conn: baseConn, r: bufio.NewReader(baseConn)}
|
||||
sConn, obfsConn := buildServerObfsConn(bConn, cfg, selectedTable, true)
|
||||
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crypto setup failed: %w", err)
|
||||
@@ -313,3 +348,24 @@ func GenKeyPair() (privateKey, publicKey string, err error) {
|
||||
publicKey = crypto.EncodePoint(pair.Public) // Master Public Key for server
|
||||
return
|
||||
}
|
||||
|
||||
func normalizeHTTPMaskStrategy(strategy string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(strategy))
|
||||
switch s {
|
||||
case "", "random":
|
||||
return "random"
|
||||
case "ws":
|
||||
return "websocket"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// randomByte returns a cryptographically random byte (with a math/rand fallback).
|
||||
func randomByte() byte {
|
||||
var b [1]byte
|
||||
if _, err := rand.Read(b[:]); err == nil {
|
||||
return b[0]
|
||||
}
|
||||
return byte(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/httpmask"
|
||||
)
|
||||
|
||||
var (
|
||||
httpMaskUserAgents = []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
httpMaskAccepts = []string{
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"application/json, text/plain, */*",
|
||||
"application/octet-stream",
|
||||
"*/*",
|
||||
}
|
||||
httpMaskAcceptLanguages = []string{
|
||||
"en-US,en;q=0.9",
|
||||
"zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
httpMaskAcceptEncodings = []string{
|
||||
"gzip, deflate, br",
|
||||
"gzip, deflate",
|
||||
}
|
||||
httpMaskPaths = []string{
|
||||
"/api/v1/upload",
|
||||
"/data/sync",
|
||||
"/v1/telemetry",
|
||||
"/session",
|
||||
"/ws",
|
||||
}
|
||||
httpMaskContentTypes = []string{
|
||||
"application/octet-stream",
|
||||
"application/json",
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
httpMaskRngPool = sync.Pool{
|
||||
New: func() any { return rand.New(rand.NewSource(time.Now().UnixNano())) },
|
||||
}
|
||||
httpMaskBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
b := make([]byte, 0, 1024)
|
||||
return &b
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func trimPortForHost(host string) string {
|
||||
if host == "" {
|
||||
return host
|
||||
}
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte {
|
||||
ua := httpMaskUserAgents[r.Intn(len(httpMaskUserAgents))]
|
||||
accept := httpMaskAccepts[r.Intn(len(httpMaskAccepts))]
|
||||
lang := httpMaskAcceptLanguages[r.Intn(len(httpMaskAcceptLanguages))]
|
||||
enc := httpMaskAcceptEncodings[r.Intn(len(httpMaskAcceptEncodings))]
|
||||
|
||||
buf = append(buf, "Host: "...)
|
||||
buf = append(buf, host...)
|
||||
buf = append(buf, "\r\nUser-Agent: "...)
|
||||
buf = append(buf, ua...)
|
||||
buf = append(buf, "\r\nAccept: "...)
|
||||
buf = append(buf, accept...)
|
||||
buf = append(buf, "\r\nAccept-Language: "...)
|
||||
buf = append(buf, lang...)
|
||||
buf = append(buf, "\r\nAccept-Encoding: "...)
|
||||
buf = append(buf, enc...)
|
||||
buf = append(buf, "\r\nConnection: keep-alive\r\n"...)
|
||||
buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// WriteHTTPMaskHeader writes an HTTP/1.x request header as a mask, according to strategy.
|
||||
// Supported strategies: ""/"random", "post", "websocket".
|
||||
func WriteHTTPMaskHeader(w io.Writer, host string, strategy string) error {
|
||||
switch normalizeHTTPMaskStrategy(strategy) {
|
||||
case "random":
|
||||
return httpmask.WriteRandomRequestHeader(w, host)
|
||||
case "post":
|
||||
return writeHTTPMaskPOST(w, host)
|
||||
case "websocket":
|
||||
return writeHTTPMaskWebSocket(w, host)
|
||||
default:
|
||||
return fmt.Errorf("unsupported http-mask-strategy: %s", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func writeHTTPMaskPOST(w io.Writer, host string) error {
|
||||
r := httpMaskRngPool.Get().(*rand.Rand)
|
||||
defer httpMaskRngPool.Put(r)
|
||||
|
||||
path := httpMaskPaths[r.Intn(len(httpMaskPaths))]
|
||||
ctype := httpMaskContentTypes[r.Intn(len(httpMaskContentTypes))]
|
||||
|
||||
bufPtr := httpMaskBufPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
buf = buf[:0]
|
||||
defer func() {
|
||||
if cap(buf) <= 4096 {
|
||||
*bufPtr = buf
|
||||
httpMaskBufPool.Put(bufPtr)
|
||||
}
|
||||
}()
|
||||
|
||||
const minCL = int64(4 * 1024)
|
||||
const maxCL = int64(10 * 1024 * 1024)
|
||||
contentLength := minCL + r.Int63n(maxCL-minCL+1)
|
||||
|
||||
buf = append(buf, "POST "...)
|
||||
buf = append(buf, path...)
|
||||
buf = append(buf, " HTTP/1.1\r\n"...)
|
||||
buf = appendCommonHeaders(buf, host, r)
|
||||
buf = append(buf, "Content-Type: "...)
|
||||
buf = append(buf, ctype...)
|
||||
buf = append(buf, "\r\nContent-Length: "...)
|
||||
buf = strconv.AppendInt(buf, contentLength, 10)
|
||||
buf = append(buf, "\r\n\r\n"...)
|
||||
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeHTTPMaskWebSocket(w io.Writer, host string) error {
|
||||
r := httpMaskRngPool.Get().(*rand.Rand)
|
||||
defer httpMaskRngPool.Put(r)
|
||||
|
||||
path := httpMaskPaths[r.Intn(len(httpMaskPaths))]
|
||||
|
||||
bufPtr := httpMaskBufPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
buf = buf[:0]
|
||||
defer func() {
|
||||
if cap(buf) <= 4096 {
|
||||
*bufPtr = buf
|
||||
httpMaskBufPool.Put(bufPtr)
|
||||
}
|
||||
}()
|
||||
|
||||
hostNoPort := trimPortForHost(host)
|
||||
var keyBytes [16]byte
|
||||
for i := 0; i < len(keyBytes); i++ {
|
||||
keyBytes[i] = byte(r.Intn(256))
|
||||
}
|
||||
var wsKey [24]byte
|
||||
base64.StdEncoding.Encode(wsKey[:], keyBytes[:])
|
||||
|
||||
buf = append(buf, "GET "...)
|
||||
buf = append(buf, path...)
|
||||
buf = append(buf, " HTTP/1.1\r\n"...)
|
||||
buf = appendCommonHeaders(buf, host, r)
|
||||
buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...)
|
||||
buf = append(buf, wsKey[:]...)
|
||||
buf = append(buf, "\r\nOrigin: https://"...)
|
||||
buf = append(buf, hostNoPort...)
|
||||
buf = append(buf, "\r\n\r\n"...)
|
||||
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
crypto_rand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"math/rand"
|
||||
"net"
|
||||
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
// perm4 matches github.com/saba-futai/sudoku/pkg/obfs/sudoku perm4.
|
||||
var perm4 = [24][4]byte{
|
||||
{0, 1, 2, 3},
|
||||
{0, 1, 3, 2},
|
||||
{0, 2, 1, 3},
|
||||
{0, 2, 3, 1},
|
||||
{0, 3, 1, 2},
|
||||
{0, 3, 2, 1},
|
||||
{1, 0, 2, 3},
|
||||
{1, 0, 3, 2},
|
||||
{1, 2, 0, 3},
|
||||
{1, 2, 3, 0},
|
||||
{1, 3, 0, 2},
|
||||
{1, 3, 2, 0},
|
||||
{2, 0, 1, 3},
|
||||
{2, 0, 3, 1},
|
||||
{2, 1, 0, 3},
|
||||
{2, 1, 3, 0},
|
||||
{2, 3, 0, 1},
|
||||
{2, 3, 1, 0},
|
||||
{3, 0, 1, 2},
|
||||
{3, 0, 2, 1},
|
||||
{3, 1, 0, 2},
|
||||
{3, 1, 2, 0},
|
||||
{3, 2, 0, 1},
|
||||
{3, 2, 1, 0},
|
||||
}
|
||||
|
||||
type sudokuObfsWriter struct {
|
||||
conn net.Conn
|
||||
table *sudoku.Table
|
||||
rng *rand.Rand
|
||||
paddingRate float32
|
||||
|
||||
outBuf []byte
|
||||
pads []byte
|
||||
padLen int
|
||||
}
|
||||
|
||||
func newSudokuObfsWriter(conn net.Conn, table *sudoku.Table, pMin, pMax int) *sudokuObfsWriter {
|
||||
var seedBytes [8]byte
|
||||
if _, err := crypto_rand.Read(seedBytes[:]); err != nil {
|
||||
binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63()))
|
||||
}
|
||||
seed := int64(binary.BigEndian.Uint64(seedBytes[:]))
|
||||
localRng := rand.New(rand.NewSource(seed))
|
||||
|
||||
min := float32(pMin) / 100.0
|
||||
span := float32(pMax-pMin) / 100.0
|
||||
rate := min + localRng.Float32()*span
|
||||
|
||||
w := &sudokuObfsWriter{
|
||||
conn: conn,
|
||||
table: table,
|
||||
rng: localRng,
|
||||
paddingRate: rate,
|
||||
}
|
||||
w.pads = table.PaddingPool
|
||||
w.padLen = len(w.pads)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *sudokuObfsWriter) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Worst-case: 4 hints + up to 6 paddings per input byte.
|
||||
needed := len(p)*10 + 1
|
||||
if cap(w.outBuf) < needed {
|
||||
w.outBuf = make([]byte, 0, needed)
|
||||
}
|
||||
out := w.outBuf[:0]
|
||||
|
||||
pads := w.pads
|
||||
padLen := w.padLen
|
||||
|
||||
for _, b := range p {
|
||||
if padLen > 0 && w.rng.Float32() < w.paddingRate {
|
||||
out = append(out, pads[w.rng.Intn(padLen)])
|
||||
}
|
||||
|
||||
puzzles := w.table.EncodeTable[b]
|
||||
puzzle := puzzles[w.rng.Intn(len(puzzles))]
|
||||
|
||||
perm := perm4[w.rng.Intn(len(perm4))]
|
||||
for _, idx := range perm {
|
||||
if padLen > 0 && w.rng.Float32() < w.paddingRate {
|
||||
out = append(out, pads[w.rng.Intn(padLen)])
|
||||
}
|
||||
out = append(out, puzzle[idx])
|
||||
}
|
||||
}
|
||||
|
||||
if padLen > 0 && w.rng.Float32() < w.paddingRate {
|
||||
out = append(out, pads[w.rng.Intn(padLen)])
|
||||
}
|
||||
|
||||
w.outBuf = out
|
||||
_, err := w.conn.Write(out)
|
||||
return len(p), err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/saba-futai/sudoku/apis"
|
||||
"github.com/saba-futai/sudoku/pkg/crypto"
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
func tableCandidates(cfg *apis.ProtocolConfig) []*sudoku.Table {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Tables) > 0 {
|
||||
return cfg.Tables
|
||||
}
|
||||
if cfg.Table != nil {
|
||||
return []*sudoku.Table{cfg.Table}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickClientTable(cfg *apis.ProtocolConfig) (*sudoku.Table, byte, error) {
|
||||
candidates := tableCandidates(cfg)
|
||||
if len(candidates) == 0 {
|
||||
return nil, 0, fmt.Errorf("no table configured")
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return candidates[0], 0, nil
|
||||
}
|
||||
idx := int(randomByte()) % len(candidates)
|
||||
return candidates[idx], byte(idx), nil
|
||||
}
|
||||
|
||||
type readOnlyConn struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (c *readOnlyConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
func (c *readOnlyConn) Close() error { return nil }
|
||||
func (c *readOnlyConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *readOnlyConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *readOnlyConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *readOnlyConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *readOnlyConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func drainBuffered(r *bufio.Reader) ([]byte, error) {
|
||||
n := r.Buffered()
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]byte, n)
|
||||
_, err := io.ReadFull(r, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.Table) error {
|
||||
rc := &readOnlyConn{Reader: bytes.NewReader(probe)}
|
||||
_, obfsConn := buildServerObfsConn(rc, cfg, table, false)
|
||||
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var handshakeBuf [16]byte
|
||||
if _, err := io.ReadFull(cConn, handshakeBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
ts := int64(binary.BigEndian.Uint64(handshakeBuf[:8]))
|
||||
if absInt64(time.Now().Unix()-ts) > 60 {
|
||||
return fmt.Errorf("timestamp skew/replay detected")
|
||||
}
|
||||
|
||||
modeBuf := []byte{0}
|
||||
if _, err := io.ReadFull(cConn, modeBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
if modeBuf[0] != downlinkMode(cfg) {
|
||||
return fmt.Errorf("downlink mode mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTableByProbe(r *bufio.Reader, cfg *apis.ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) {
|
||||
const (
|
||||
maxProbeBytes = 64 * 1024
|
||||
readChunk = 4 * 1024
|
||||
)
|
||||
if len(tables) == 0 {
|
||||
return nil, nil, fmt.Errorf("no table candidates")
|
||||
}
|
||||
if len(tables) > 255 {
|
||||
return nil, nil, fmt.Errorf("too many table candidates: %d", len(tables))
|
||||
}
|
||||
|
||||
probe, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
|
||||
tmp := make([]byte, readChunk)
|
||||
for {
|
||||
if len(tables) == 1 {
|
||||
tail, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
probe = append(probe, tail...)
|
||||
return tables[0], probe, nil
|
||||
}
|
||||
|
||||
needMore := false
|
||||
for _, table := range tables {
|
||||
err := probeHandshakeBytes(probe, cfg, table)
|
||||
if err == nil {
|
||||
tail, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
probe = append(probe, tail...)
|
||||
return table, probe, nil
|
||||
}
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
needMore = true
|
||||
}
|
||||
}
|
||||
|
||||
if !needMore {
|
||||
return nil, probe, fmt.Errorf("handshake table selection failed")
|
||||
}
|
||||
if len(probe) >= maxProbeBytes {
|
||||
return nil, probe, fmt.Errorf("handshake probe exceeded %d bytes", maxProbeBytes)
|
||||
}
|
||||
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
probe = append(probe, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, probe, fmt.Errorf("handshake probe read failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
// NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns.
|
||||
// When customTables is non-empty it overrides customTable (matching upstream Sudoku behavior).
|
||||
func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
|
||||
patterns := customTables
|
||||
if len(patterns) == 0 && strings.TrimSpace(customTable) != "" {
|
||||
patterns = []string{customTable}
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{""}
|
||||
}
|
||||
|
||||
tables := make([]*sudoku.Table, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
t, err := NewTableWithCustom(key, tableType, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
+3
-3
@@ -94,7 +94,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
- name: Prepare sidecar and resources
|
||||
run: pnpm check
|
||||
run: pnpm prepare:check
|
||||
- name: Lint
|
||||
if: startsWith(matrix.targets.os, 'ubuntu-')
|
||||
run: pnpm lint # Lint
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Prepare sidecar and resources
|
||||
run: pnpm check
|
||||
run: pnpm prepare:check
|
||||
|
||||
- name: Prepare frontend
|
||||
run: pnpm -r build
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Prepare sidecar and resources
|
||||
run: pnpm check
|
||||
run: pnpm prepare:check
|
||||
|
||||
- name: Prepare frontend
|
||||
run: pnpm -r build
|
||||
|
||||
+5
-5
@@ -97,15 +97,15 @@ jobs:
|
||||
run: |
|
||||
case "${{ inputs.arch }}" in
|
||||
"x86_64")
|
||||
pnpm check ;;
|
||||
pnpm prepare:check ;;
|
||||
"i686")
|
||||
pnpm check --arch ia32 --sidecar-host i686-unknown-linux-gnu ;;
|
||||
pnpm prepare:check --arch ia32 --sidecar-host i686-unknown-linux-gnu ;;
|
||||
"aarch64")
|
||||
pnpm check --arch arm64 --sidecar-host aarch64-unknown-linux-gnu ;;
|
||||
pnpm prepare:check --arch arm64 --sidecar-host aarch64-unknown-linux-gnu ;;
|
||||
"armel")
|
||||
pnpm check --arch armel --sidecar-host armv7-unknown-linux-gnueabi ;;
|
||||
pnpm prepare:check --arch armel --sidecar-host armv7-unknown-linux-gnueabi ;;
|
||||
"armhf")
|
||||
pnpm check --arch arm --sidecar-host armv7-unknown-linux-gnueabihf ;;
|
||||
pnpm prepare:check --arch arm --sidecar-host armv7-unknown-linux-gnueabihf ;;
|
||||
esac
|
||||
|
||||
- name: Nightly Prepare
|
||||
|
||||
+2
-2
@@ -84,10 +84,10 @@ jobs:
|
||||
pnpm i
|
||||
- name: Download Sidecars aarch64
|
||||
if: ${{ inputs.aarch64 == true }}
|
||||
run: pnpm check --arch arm64 --sidecar-host aarch64-apple-darwin
|
||||
run: pnpm prepare:check --arch arm64 --sidecar-host aarch64-apple-darwin
|
||||
- name: Download Sidecars x64
|
||||
if: ${{ inputs.aarch64 == false }}
|
||||
run: pnpm check --arch x64 --sidecar-host x86_64-apple-darwin
|
||||
run: pnpm prepare:check --arch x64 --sidecar-host x86_64-apple-darwin
|
||||
- name: Nightly Prepare
|
||||
if: ${{ inputs.nightly == true }}
|
||||
run: |
|
||||
|
||||
@@ -103,13 +103,13 @@ jobs:
|
||||
$condition = '${{ inputs.arch }}'
|
||||
switch ($condition) {
|
||||
'x86_64' {
|
||||
pnpm check
|
||||
pnpm prepare:check
|
||||
}
|
||||
'i686' {
|
||||
pnpm check --arch ia32 --sidecar-host i686-pc-windows-msvc
|
||||
pnpm prepare:check --arch ia32 --sidecar-host i686-pc-windows-msvc
|
||||
}
|
||||
'aarch64' {
|
||||
pnpm check --arch arm64 --sidecar-host aarch64-pc-windows-msvc
|
||||
pnpm prepare:check --arch arm64 --sidecar-host aarch64-pc-windows-msvc
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Pnpm install and check
|
||||
run: |
|
||||
pnpm i
|
||||
pnpm check --arch arm64 --sidecar-host aarch64-apple-darwin
|
||||
pnpm prepare:check --arch arm64 --sidecar-host aarch64-apple-darwin
|
||||
|
||||
- name: Tauri build with Upload (cmd)
|
||||
env:
|
||||
|
||||
@@ -78,7 +78,7 @@ pnpm i
|
||||
### 2. Download Core & Resource Files
|
||||
|
||||
```
|
||||
pnpm check
|
||||
pnpm prepare:check
|
||||
```
|
||||
|
||||
> This command downloads binaries like `sidecar` and `resource` to ensure the project runs properly
|
||||
@@ -86,7 +86,7 @@ pnpm check
|
||||
If files are missing or you want to force update:
|
||||
|
||||
```
|
||||
pnpm check --force
|
||||
pnpm prepare:check --force
|
||||
```
|
||||
|
||||
💡 **Tip**: Configure terminal proxy if network issues occur
|
||||
|
||||
@@ -54,9 +54,9 @@ pnpm i
|
||||
|
||||
```shell
|
||||
# force update to latest version
|
||||
# pnpm check --force
|
||||
# pnpm prepare:check --force
|
||||
|
||||
pnpm check
|
||||
pnpm prepare:check
|
||||
```
|
||||
|
||||
### Run dev
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react'
|
||||
import getSystem from '@/utils/get-system'
|
||||
import { MenuOpen } from '@mui/icons-material'
|
||||
import { Backdrop, IconButton } from '@mui/material'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
import { alpha, cn } from '@nyanpasu/ui'
|
||||
import AnimatedLogo from '../layout/animated-logo'
|
||||
import DrawerContent from './drawer-content'
|
||||
@@ -50,22 +51,18 @@ export const AppDrawer = () => {
|
||||
<DrawerTitle />
|
||||
<Backdrop
|
||||
className={cn('z-20', OS !== 'linux' && 'backdrop-blur-xl')}
|
||||
sx={[
|
||||
(theme) =>
|
||||
OS === 'linux'
|
||||
? {
|
||||
backgroundColor: null,
|
||||
}
|
||||
: {
|
||||
backgroundColor: alpha(theme.vars.palette.primary.light, 0.1),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: alpha(
|
||||
theme.vars.palette.primary.dark,
|
||||
0.1,
|
||||
),
|
||||
}),
|
||||
},
|
||||
]}
|
||||
sx={
|
||||
(OS === 'linux'
|
||||
? {
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
: (theme) => ({
|
||||
backgroundColor: alpha(theme.vars.palette.primary.light, 0.1),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: alpha(theme.vars.palette.primary.dark, 0.1),
|
||||
}),
|
||||
})) as SxProps<Theme>
|
||||
}
|
||||
open={open}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SvgIconComponent } from '@mui/icons-material'
|
||||
import { Box, ListItemButton, ListItemIcon, Tooltip } from '@mui/material'
|
||||
import { useSetting } from '@nyanpasu/interface'
|
||||
import { alpha, cn } from '@nyanpasu/ui'
|
||||
import { useMatch, useNavigate } from '@tanstack/react-router'
|
||||
import { useLocation, useMatch, useNavigate } from '@tanstack/react-router'
|
||||
|
||||
export const RouteListItem = ({
|
||||
name,
|
||||
@@ -19,11 +19,10 @@ export const RouteListItem = ({
|
||||
onlyIcon?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const match = useMatch({
|
||||
strict: false,
|
||||
shouldThrow: false,
|
||||
from: path as never,
|
||||
})
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const match = location.pathname === path
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { filesize } from 'filesize'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Download, Upload } from '@mui/icons-material'
|
||||
import { Paper, Skeleton } from '@mui/material'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
import { useClashConnections } from '@nyanpasu/interface'
|
||||
import { darken, lighten } from '@nyanpasu/ui'
|
||||
|
||||
@@ -88,8 +89,8 @@ export default function ConnectionTotal() {
|
||||
>
|
||||
<Download
|
||||
className="scale-75"
|
||||
sx={[
|
||||
(theme) => ({
|
||||
sx={
|
||||
((theme) => ({
|
||||
color: darken(
|
||||
theme.vars.palette.primary.main,
|
||||
downloadHighlight ? 0.9 : 0.3,
|
||||
@@ -100,8 +101,8 @@ export default function ConnectionTotal() {
|
||||
downloadHighlight ? 0.2 : 0.9,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
})) as SxProps<Theme>
|
||||
}
|
||||
/>{' '}
|
||||
<span className="font-mono text-xs">
|
||||
{filesize(latestClashConnections.downloadTotal, { pad: true })}
|
||||
@@ -117,8 +118,8 @@ export default function ConnectionTotal() {
|
||||
>
|
||||
<Upload
|
||||
className="scale-75"
|
||||
sx={[
|
||||
(theme) => ({
|
||||
sx={
|
||||
((theme) => ({
|
||||
color: darken(
|
||||
theme.vars.palette.primary.main,
|
||||
uploadHighlight ? 0.9 : 0.3,
|
||||
@@ -129,8 +130,8 @@ export default function ConnectionTotal() {
|
||||
downloadHighlight ? 0.2 : 0.9,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
})) as SxProps<Theme>
|
||||
}
|
||||
/>{' '}
|
||||
<span className="font-mono text-xs">
|
||||
{filesize(latestClashConnections.uploadTotal, { pad: true })}
|
||||
|
||||
@@ -5,15 +5,9 @@ import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { atomIsDrawer } from '@/store'
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
SxProps,
|
||||
Theme,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
import { Box, CircularProgress, Paper, Tooltip } from '@mui/material'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
import { getCoreStatus, useSystemService } from '@nyanpasu/interface'
|
||||
import { alpha } from '@nyanpasu/ui'
|
||||
|
||||
@@ -42,24 +36,24 @@ export const ServiceShortcuts = () => {
|
||||
case 'running': {
|
||||
return {
|
||||
label: t('running'),
|
||||
sx: (theme) => ({
|
||||
sx: ((theme) => ({
|
||||
backgroundColor: alpha(theme.vars.palette.success.light, 0.3),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: alpha(theme.vars.palette.success.dark, 0.3),
|
||||
}),
|
||||
}),
|
||||
})) as SxProps<Theme>,
|
||||
}
|
||||
}
|
||||
|
||||
case 'stopped': {
|
||||
return {
|
||||
label: t('stopped'),
|
||||
sx: (theme) => ({
|
||||
sx: ((theme) => ({
|
||||
backgroundColor: alpha(theme.vars.palette.error.light, 0.3),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: alpha(theme.vars.palette.error.dark, 0.3),
|
||||
}),
|
||||
}),
|
||||
})) as SxProps<Theme>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +61,12 @@ export const ServiceShortcuts = () => {
|
||||
default: {
|
||||
return {
|
||||
label: t('not_installed'),
|
||||
sx: (theme) => ({
|
||||
sx: ((theme) => ({
|
||||
backgroundColor: theme.vars.palette.grey[100],
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: theme.vars.palette.background.paper,
|
||||
}),
|
||||
}),
|
||||
})) as SxProps<Theme>,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,24 +84,24 @@ export const ServiceShortcuts = () => {
|
||||
!!Stopped && Stopped.trim()
|
||||
? t('stopped_reason', { reason: Stopped })
|
||||
: t('stopped'),
|
||||
sx: (theme) => ({
|
||||
sx: ((theme) => ({
|
||||
backgroundColor: alpha(theme.vars.palette.success.light, 0.3),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: alpha(theme.vars.palette.success.dark, 0.3),
|
||||
}),
|
||||
}),
|
||||
})) as SxProps<Theme>,
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: t('service_shortcuts.core_started_by', {
|
||||
by: t(status[2] === 'normal' ? 'UI' : 'service'),
|
||||
}),
|
||||
sx: (theme) => ({
|
||||
sx: ((theme) => ({
|
||||
backgroundColor: alpha(theme.vars.palette.success.light, 0.3),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: alpha(theme.vars.palette.success.dark, 0.3),
|
||||
}),
|
||||
}),
|
||||
})) as SxProps<Theme>,
|
||||
}
|
||||
}, [coreStatusSWR.data, t])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { CSSProperties, memo, useMemo } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
import { ClashProxiesQueryProxyItem } from '@nyanpasu/interface'
|
||||
import { alpha, cn } from '@nyanpasu/ui'
|
||||
import { PaperSwitchButton } from '../setting/modules/system-proxy'
|
||||
@@ -41,16 +42,18 @@ export const NodeCard = memo(function NodeCard({
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
className={cn(styles.Card, delay === -1 && styles.NoDelay)}
|
||||
sxPaper={(theme) => ({
|
||||
backgroundColor: checked
|
||||
? alpha(theme.vars.palette.primary.main, 0.3)
|
||||
: theme.vars.palette.grey[100],
|
||||
...theme.applyStyles('dark', {
|
||||
sxPaper={
|
||||
((theme) => ({
|
||||
backgroundColor: checked
|
||||
? alpha(theme.vars.palette.primary.main, 0.3)
|
||||
: theme.vars.palette.grey[900],
|
||||
}),
|
||||
})}
|
||||
: theme.vars.palette.grey[100],
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: checked
|
||||
? alpha(theme.vars.palette.primary.main, 0.3)
|
||||
: theme.vars.palette.grey[900],
|
||||
}),
|
||||
})) as SxProps<Theme>
|
||||
}
|
||||
>
|
||||
<Box width="100%" display="flex" gap={0.5}>
|
||||
<FeatureChip label={node.type} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useControllableValue } from 'ahooks'
|
||||
import { memo, ReactNode } from 'react'
|
||||
import { mergeSxProps } from '@/utils/mui-theme'
|
||||
import { CircularProgress, SxProps, Theme } from '@mui/material'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
import { alpha } from '@nyanpasu/ui'
|
||||
import { PaperButton, PaperButtonProps } from './nyanpasu-path'
|
||||
|
||||
@@ -48,7 +49,7 @@ export const PaperSwitchButton = memo(function PaperSwitchButton({
|
||||
<PaperButton
|
||||
label={label}
|
||||
sxPaper={mergeSxProps(
|
||||
(theme: Theme) => ({
|
||||
((theme) => ({
|
||||
backgroundColor: checked
|
||||
? alpha(theme.vars.palette.primary.main, 0.1)
|
||||
: theme.vars.palette.grey[100],
|
||||
@@ -57,7 +58,7 @@ export const PaperSwitchButton = memo(function PaperSwitchButton({
|
||||
? alpha(theme.vars.palette.primary.main, 0.1)
|
||||
: theme.vars.palette.common.black,
|
||||
}),
|
||||
}),
|
||||
})) as SxProps<Theme>,
|
||||
sxPaper,
|
||||
)}
|
||||
sxButton={{
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ const ConnectionTotal = lazy(
|
||||
() => import('@/components/connections/connections-total'),
|
||||
)
|
||||
|
||||
export const Route = createFileRoute('/connections')({
|
||||
export const Route = createFileRoute('/(legacy)/connections')({
|
||||
component: Connections,
|
||||
})
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { useClashWSContext } from '@nyanpasu/interface'
|
||||
import { BasePage } from '@nyanpasu/ui'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
export const Route = createFileRoute('/(legacy)/dashboard')({
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import LogHeader from '@/components/logs/los-header'
|
||||
import { BasePage } from '@nyanpasu/ui'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/logs')({
|
||||
export const Route = createFileRoute('/(legacy)/logs')({
|
||||
component: LogPage,
|
||||
})
|
||||
|
||||
+6
-5
@@ -26,6 +26,7 @@ import { message } from '@/utils/notification'
|
||||
import { Public, Update } from '@mui/icons-material'
|
||||
import { Badge, Button, CircularProgress, IconButton } from '@mui/material'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
import {
|
||||
RemoteProfileOptionsBuilder,
|
||||
useProfile,
|
||||
@@ -41,7 +42,7 @@ const profileSearchParams = z.object({
|
||||
subscribeDesc: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/profiles')({
|
||||
export const Route = createFileRoute('/(legacy)/profiles')({
|
||||
validateSearch: zodSearchValidator(profileSearchParams),
|
||||
component: ProfilePage,
|
||||
})
|
||||
@@ -262,8 +263,8 @@ function ProfilePage() {
|
||||
<div className="!fixed right-8 bottom-8">
|
||||
<FloatingButton
|
||||
className="!relative -top-15 -right-13.5 flex size-11 !min-w-fit"
|
||||
sx={[
|
||||
(theme) => ({
|
||||
sx={
|
||||
((theme) => ({
|
||||
backgroundColor: theme.vars.palette.grey[200],
|
||||
boxShadow: 4,
|
||||
'&:hover': {
|
||||
@@ -275,8 +276,8 @@ function ProfilePage() {
|
||||
backgroundColor: theme.vars.palette.grey[700],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
})) as SxProps<Theme>
|
||||
}
|
||||
onClick={handleGlobalProfileUpdate}
|
||||
>
|
||||
{globalUpdatePending ? <CircularProgress size={22} /> : <Update />}
|
||||
+3
-3
@@ -12,7 +12,7 @@ import {
|
||||
import { BasePage } from '@nyanpasu/ui'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/providers')({
|
||||
export const Route = createFileRoute('/(legacy)/providers')({
|
||||
component: ProvidersPage,
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ function ProvidersPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<Chip
|
||||
className="!h-10 truncate !rounded-full !p-2 !text-lg font-bold"
|
||||
label={`${t('Proxies Providers')} (${Object.entries(proxiesProvider.data ?? {}).length})`}
|
||||
label={`${t(`Proxies Providers`)} (${Object.entries(proxiesProvider.data ?? {}).length})`}
|
||||
/>
|
||||
|
||||
<UpdateProxiesProviders />
|
||||
@@ -57,7 +57,7 @@ function ProvidersPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<Chip
|
||||
className="!h-10 truncate !rounded-full !p-2 !text-lg font-bold"
|
||||
label={`${t('Rules Providers')} (${Object.entries(rulesProvider.data ?? {}).length})`}
|
||||
label={`${t(`Rules Providers`)} (${Object.entries(rulesProvider.data ?? {}).length})`}
|
||||
/>
|
||||
|
||||
<UpdateProviders />
|
||||
+1
-1
@@ -24,7 +24,7 @@ import {
|
||||
import { alpha, cn, SidePage } from '@nyanpasu/ui'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/proxies')({
|
||||
export const Route = createFileRoute('/(legacy)/proxies')({
|
||||
component: ProxyPage,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import AppContainer from '@/components/app/app-container'
|
||||
import LocalesProvider from '@/components/app/locales-provider'
|
||||
import MutationProvider from '@/components/layout/mutation-provider'
|
||||
import NoticeProvider from '@/components/layout/notice-provider'
|
||||
import PageTransition from '@/components/layout/page-transition'
|
||||
import SchemeProvider from '@/components/layout/scheme-provider'
|
||||
import UpdaterDialog from '@/components/updater/updater-dialog-wrapper'
|
||||
import { UpdaterProvider } from '@/hooks/use-updater'
|
||||
import { FileRouteTypes } from '@/route-tree.gen'
|
||||
import { atomIsDrawer, memorizedRoutePathAtom } from '@/store'
|
||||
import { useSettings } from '@nyanpasu/interface'
|
||||
import { cn, useBreakpoint } from '@nyanpasu/ui'
|
||||
import { createFileRoute, useLocation } from '@tanstack/react-router'
|
||||
import 'dayjs/locale/ru'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/zh-tw'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
export const Route = createFileRoute('/(legacy)')({
|
||||
component: Layout,
|
||||
})
|
||||
|
||||
const QueryLoaderProvider = ({ children }: PropsWithChildren) => {
|
||||
const {
|
||||
query: { isLoading },
|
||||
} = useSettings()
|
||||
|
||||
return isLoading ? null : children
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
const breakpoint = useBreakpoint()
|
||||
|
||||
const setMemorizedPath = useSetAtom(memorizedRoutePathAtom)
|
||||
|
||||
const pathname = useLocation({
|
||||
select: (location) => location.pathname,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== '/') {
|
||||
setMemorizedPath(pathname as FileRouteTypes['fullPaths'])
|
||||
}
|
||||
}, [pathname, setMemorizedPath])
|
||||
|
||||
const [isDrawer, setIsDrawer] = useAtom(atomIsDrawer)
|
||||
|
||||
useEffect(() => {
|
||||
setIsDrawer(breakpoint === 'sm' || breakpoint === 'xs')
|
||||
}, [breakpoint, setIsDrawer])
|
||||
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
errorRetryCount: 5,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: true,
|
||||
refreshInterval: 5000,
|
||||
}}
|
||||
>
|
||||
<QueryLoaderProvider>
|
||||
<LocalesProvider />
|
||||
<MutationProvider />
|
||||
<NoticeProvider />
|
||||
<SchemeProvider />
|
||||
<UpdaterDialog />
|
||||
<UpdaterProvider />
|
||||
<AppContainer isDrawer={isDrawer}>
|
||||
<PageTransition
|
||||
className={cn('absolute inset-4 top-10', !isDrawer && 'left-0')}
|
||||
/>
|
||||
</AppContainer>
|
||||
</QueryLoaderProvider>
|
||||
</SWRConfig>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { useClashRules } from '@nyanpasu/interface'
|
||||
import { alpha, BasePage } from '@nyanpasu/ui'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/rules')({
|
||||
export const Route = createFileRoute('/(legacy)/rules')({
|
||||
component: RulesPage,
|
||||
})
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { collectEnvs, openThat } from '@nyanpasu/interface'
|
||||
import { BasePage } from '@nyanpasu/ui'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
export const Route = createFileRoute('/(legacy)/settings')({
|
||||
component: SettingPage,
|
||||
})
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { useMount } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import AppContainer from '@/components/app/app-container'
|
||||
import LocalesProvider from '@/components/app/locales-provider'
|
||||
import MutationProvider from '@/components/layout/mutation-provider'
|
||||
import NoticeProvider from '@/components/layout/notice-provider'
|
||||
import PageTransition from '@/components/layout/page-transition'
|
||||
import SchemeProvider from '@/components/layout/scheme-provider'
|
||||
import { ThemeModeProvider } from '@/components/layout/use-custom-theme'
|
||||
import UpdaterDialog from '@/components/updater/updater-dialog-wrapper'
|
||||
import { useNyanpasuStorageSubscribers } from '@/hooks/use-store'
|
||||
import { UpdaterProvider } from '@/hooks/use-updater'
|
||||
import { FileRouteTypes } from '@/route-tree.gen'
|
||||
import { atomIsDrawer, memorizedRoutePathAtom } from '@/store'
|
||||
import { CssBaseline } from '@mui/material'
|
||||
import { StyledEngineProvider, useColorScheme } from '@mui/material/styles'
|
||||
import { cn, useBreakpoint } from '@nyanpasu/ui'
|
||||
import { cn } from '@nyanpasu/ui'
|
||||
import {
|
||||
createRootRoute,
|
||||
ErrorComponentProps,
|
||||
useLocation,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
@@ -27,10 +17,8 @@ import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/zh-tw'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { lazy, PropsWithChildren, useEffect } from 'react'
|
||||
import { SWRConfig } from 'swr'
|
||||
import { NyanpasuProvider, useSettings } from '@nyanpasu/interface'
|
||||
import { lazy } from 'react'
|
||||
import { NyanpasuProvider } from '@nyanpasu/interface'
|
||||
import styles from './-__root.module.scss'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -69,36 +57,9 @@ export const Route = createRootRoute({
|
||||
pendingComponent: Pending,
|
||||
})
|
||||
|
||||
const QueryLoaderProvider = ({ children }: PropsWithChildren) => {
|
||||
const {
|
||||
query: { isLoading },
|
||||
} = useSettings()
|
||||
|
||||
return isLoading ? null : children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const breakpoint = useBreakpoint()
|
||||
|
||||
const setMemorizedPath = useSetAtom(memorizedRoutePathAtom)
|
||||
const pathname = useLocation({
|
||||
select: (location) => location.pathname,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== '/') {
|
||||
setMemorizedPath(pathname as FileRouteTypes['fullPaths'])
|
||||
}
|
||||
}, [pathname, setMemorizedPath])
|
||||
|
||||
const [isDrawer, setIsDrawer] = useAtom(atomIsDrawer)
|
||||
|
||||
useNyanpasuStorageSubscribers()
|
||||
|
||||
useEffect(() => {
|
||||
setIsDrawer(breakpoint === 'sm' || breakpoint === 'xs')
|
||||
}, [breakpoint, setIsDrawer])
|
||||
|
||||
useMount(() => {
|
||||
const appWindow = getCurrentWebviewWindow()
|
||||
Promise.all([
|
||||
@@ -110,38 +71,15 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<NyanpasuProvider>
|
||||
<SWRConfig
|
||||
value={{
|
||||
errorRetryCount: 5,
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: true,
|
||||
refreshInterval: 5000,
|
||||
}}
|
||||
>
|
||||
<QueryLoaderProvider>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeModeProvider>
|
||||
<CssBaseline />
|
||||
<LocalesProvider />
|
||||
<MutationProvider />
|
||||
<NoticeProvider />
|
||||
<SchemeProvider />
|
||||
<UpdaterDialog />
|
||||
<UpdaterProvider />
|
||||
<TanStackRouterDevtools />
|
||||
|
||||
<AppContainer isDrawer={isDrawer}>
|
||||
<PageTransition
|
||||
className={cn(
|
||||
'absolute inset-4 top-10',
|
||||
!isDrawer && 'left-0',
|
||||
)}
|
||||
/>
|
||||
<TanStackRouterDevtools />
|
||||
</AppContainer>
|
||||
</ThemeModeProvider>
|
||||
</StyledEngineProvider>
|
||||
</QueryLoaderProvider>
|
||||
</SWRConfig>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeModeProvider>
|
||||
<CssBaseline />
|
||||
|
||||
<Outlet />
|
||||
</ThemeModeProvider>
|
||||
</StyledEngineProvider>
|
||||
</NyanpasuProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
createFileRoute,
|
||||
ErrorComponentProps,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
const Catch = ({ error }: ErrorComponentProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
<h1>Oops!</h1>
|
||||
<p>Something went wrong... Caught at _layout error boundary.</p>
|
||||
<pre>{error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Pending = () => <div>Loading from _layout...</div>
|
||||
|
||||
export const Route = createFileRoute('/_layout')({
|
||||
component: Layout,
|
||||
errorComponent: Catch,
|
||||
pendingComponent: Pending,
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -1,32 +1,13 @@
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { memorizedRoutePathAtom } from '@/store'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: IndexPage,
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function IndexPage() {
|
||||
const navigate = useNavigate()
|
||||
function RouteComponent() {
|
||||
const memorizedNavigate = useAtomValue(memorizedRoutePathAtom)
|
||||
const lockRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (lockRef.current) {
|
||||
return
|
||||
}
|
||||
const to =
|
||||
memorizedNavigate && memorizedNavigate !== '/'
|
||||
? memorizedNavigate
|
||||
: '/dashboard'
|
||||
|
||||
lockRef.current = true
|
||||
console.log('navigate to', to)
|
||||
navigate({
|
||||
to: to,
|
||||
})
|
||||
}, [memorizedNavigate, navigate])
|
||||
|
||||
return null
|
||||
return <Navigate to={memorizedNavigate || '/dashboard'} />
|
||||
}
|
||||
|
||||
@@ -9,59 +9,19 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './pages/__root'
|
||||
import { Route as SettingsRouteImport } from './pages/settings'
|
||||
import { Route as RulesRouteImport } from './pages/rules'
|
||||
import { Route as ProxiesRouteImport } from './pages/proxies'
|
||||
import { Route as ProvidersRouteImport } from './pages/providers'
|
||||
import { Route as ProfilesRouteImport } from './pages/profiles'
|
||||
import { Route as LogsRouteImport } from './pages/logs'
|
||||
import { Route as DashboardRouteImport } from './pages/dashboard'
|
||||
import { Route as ConnectionsRouteImport } from './pages/connections'
|
||||
import { Route as LayoutRouteImport } from './pages/_layout'
|
||||
import { Route as legacyRouteRouteImport } from './pages/(legacy)/route'
|
||||
import { Route as IndexRouteImport } from './pages/index'
|
||||
import { Route as legacySettingsRouteImport } from './pages/(legacy)/settings'
|
||||
import { Route as legacyRulesRouteImport } from './pages/(legacy)/rules'
|
||||
import { Route as legacyProxiesRouteImport } from './pages/(legacy)/proxies'
|
||||
import { Route as legacyProvidersRouteImport } from './pages/(legacy)/providers'
|
||||
import { Route as legacyProfilesRouteImport } from './pages/(legacy)/profiles'
|
||||
import { Route as legacyLogsRouteImport } from './pages/(legacy)/logs'
|
||||
import { Route as legacyDashboardRouteImport } from './pages/(legacy)/dashboard'
|
||||
import { Route as legacyConnectionsRouteImport } from './pages/(legacy)/connections'
|
||||
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RulesRoute = RulesRouteImport.update({
|
||||
id: '/rules',
|
||||
path: '/rules',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProxiesRoute = ProxiesRouteImport.update({
|
||||
id: '/proxies',
|
||||
path: '/proxies',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProvidersRoute = ProvidersRouteImport.update({
|
||||
id: '/providers',
|
||||
path: '/providers',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProfilesRoute = ProfilesRouteImport.update({
|
||||
id: '/profiles',
|
||||
path: '/profiles',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LogsRoute = LogsRouteImport.update({
|
||||
id: '/logs',
|
||||
path: '/logs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DashboardRoute = DashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ConnectionsRoute = ConnectionsRouteImport.update({
|
||||
id: '/connections',
|
||||
path: '/connections',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LayoutRoute = LayoutRouteImport.update({
|
||||
id: '/_layout',
|
||||
const legacyRouteRoute = legacyRouteRouteImport.update({
|
||||
id: '/(legacy)',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -69,41 +29,81 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const legacySettingsRoute = legacySettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyRulesRoute = legacyRulesRouteImport.update({
|
||||
id: '/rules',
|
||||
path: '/rules',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyProxiesRoute = legacyProxiesRouteImport.update({
|
||||
id: '/proxies',
|
||||
path: '/proxies',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyProvidersRoute = legacyProvidersRouteImport.update({
|
||||
id: '/providers',
|
||||
path: '/providers',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyProfilesRoute = legacyProfilesRouteImport.update({
|
||||
id: '/profiles',
|
||||
path: '/profiles',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyLogsRoute = legacyLogsRouteImport.update({
|
||||
id: '/logs',
|
||||
path: '/logs',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyDashboardRoute = legacyDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
const legacyConnectionsRoute = legacyConnectionsRouteImport.update({
|
||||
id: '/connections',
|
||||
path: '/connections',
|
||||
getParentRoute: () => legacyRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/connections': typeof ConnectionsRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/profiles': typeof ProfilesRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/proxies': typeof ProxiesRoute
|
||||
'/rules': typeof RulesRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/connections': typeof legacyConnectionsRoute
|
||||
'/dashboard': typeof legacyDashboardRoute
|
||||
'/logs': typeof legacyLogsRoute
|
||||
'/profiles': typeof legacyProfilesRoute
|
||||
'/providers': typeof legacyProvidersRoute
|
||||
'/proxies': typeof legacyProxiesRoute
|
||||
'/rules': typeof legacyRulesRoute
|
||||
'/settings': typeof legacySettingsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/connections': typeof ConnectionsRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/profiles': typeof ProfilesRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/proxies': typeof ProxiesRoute
|
||||
'/rules': typeof RulesRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/connections': typeof legacyConnectionsRoute
|
||||
'/dashboard': typeof legacyDashboardRoute
|
||||
'/logs': typeof legacyLogsRoute
|
||||
'/profiles': typeof legacyProfilesRoute
|
||||
'/providers': typeof legacyProvidersRoute
|
||||
'/proxies': typeof legacyProxiesRoute
|
||||
'/rules': typeof legacyRulesRoute
|
||||
'/settings': typeof legacySettingsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/_layout': typeof LayoutRoute
|
||||
'/connections': typeof ConnectionsRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/profiles': typeof ProfilesRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/proxies': typeof ProxiesRoute
|
||||
'/rules': typeof RulesRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/(legacy)': typeof legacyRouteRouteWithChildren
|
||||
'/(legacy)/connections': typeof legacyConnectionsRoute
|
||||
'/(legacy)/dashboard': typeof legacyDashboardRoute
|
||||
'/(legacy)/logs': typeof legacyLogsRoute
|
||||
'/(legacy)/profiles': typeof legacyProfilesRoute
|
||||
'/(legacy)/providers': typeof legacyProvidersRoute
|
||||
'/(legacy)/proxies': typeof legacyProxiesRoute
|
||||
'/(legacy)/rules': typeof legacyRulesRoute
|
||||
'/(legacy)/settings': typeof legacySettingsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -131,93 +131,29 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_layout'
|
||||
| '/connections'
|
||||
| '/dashboard'
|
||||
| '/logs'
|
||||
| '/profiles'
|
||||
| '/providers'
|
||||
| '/proxies'
|
||||
| '/rules'
|
||||
| '/settings'
|
||||
| '/(legacy)'
|
||||
| '/(legacy)/connections'
|
||||
| '/(legacy)/dashboard'
|
||||
| '/(legacy)/logs'
|
||||
| '/(legacy)/profiles'
|
||||
| '/(legacy)/providers'
|
||||
| '/(legacy)/proxies'
|
||||
| '/(legacy)/rules'
|
||||
| '/(legacy)/settings'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LayoutRoute: typeof LayoutRoute
|
||||
ConnectionsRoute: typeof ConnectionsRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
ProfilesRoute: typeof ProfilesRoute
|
||||
ProvidersRoute: typeof ProvidersRoute
|
||||
ProxiesRoute: typeof ProxiesRoute
|
||||
RulesRoute: typeof RulesRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
legacyRouteRoute: typeof legacyRouteRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/rules': {
|
||||
id: '/rules'
|
||||
path: '/rules'
|
||||
fullPath: '/rules'
|
||||
preLoaderRoute: typeof RulesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/proxies': {
|
||||
id: '/proxies'
|
||||
path: '/proxies'
|
||||
fullPath: '/proxies'
|
||||
preLoaderRoute: typeof ProxiesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/providers': {
|
||||
id: '/providers'
|
||||
path: '/providers'
|
||||
fullPath: '/providers'
|
||||
preLoaderRoute: typeof ProvidersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/profiles': {
|
||||
id: '/profiles'
|
||||
path: '/profiles'
|
||||
fullPath: '/profiles'
|
||||
preLoaderRoute: typeof ProfilesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/logs': {
|
||||
id: '/logs'
|
||||
path: '/logs'
|
||||
fullPath: '/logs'
|
||||
preLoaderRoute: typeof LogsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dashboard': {
|
||||
id: '/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof DashboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/connections': {
|
||||
id: '/connections'
|
||||
path: '/connections'
|
||||
fullPath: '/connections'
|
||||
preLoaderRoute: typeof ConnectionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_layout': {
|
||||
id: '/_layout'
|
||||
'/(legacy)': {
|
||||
id: '/(legacy)'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof LayoutRouteImport
|
||||
preLoaderRoute: typeof legacyRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
@@ -227,20 +163,94 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(legacy)/settings': {
|
||||
id: '/(legacy)/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof legacySettingsRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/rules': {
|
||||
id: '/(legacy)/rules'
|
||||
path: '/rules'
|
||||
fullPath: '/rules'
|
||||
preLoaderRoute: typeof legacyRulesRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/proxies': {
|
||||
id: '/(legacy)/proxies'
|
||||
path: '/proxies'
|
||||
fullPath: '/proxies'
|
||||
preLoaderRoute: typeof legacyProxiesRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/providers': {
|
||||
id: '/(legacy)/providers'
|
||||
path: '/providers'
|
||||
fullPath: '/providers'
|
||||
preLoaderRoute: typeof legacyProvidersRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/profiles': {
|
||||
id: '/(legacy)/profiles'
|
||||
path: '/profiles'
|
||||
fullPath: '/profiles'
|
||||
preLoaderRoute: typeof legacyProfilesRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/logs': {
|
||||
id: '/(legacy)/logs'
|
||||
path: '/logs'
|
||||
fullPath: '/logs'
|
||||
preLoaderRoute: typeof legacyLogsRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/dashboard': {
|
||||
id: '/(legacy)/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof legacyDashboardRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
'/(legacy)/connections': {
|
||||
id: '/(legacy)/connections'
|
||||
path: '/connections'
|
||||
fullPath: '/connections'
|
||||
preLoaderRoute: typeof legacyConnectionsRouteImport
|
||||
parentRoute: typeof legacyRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface legacyRouteRouteChildren {
|
||||
legacyConnectionsRoute: typeof legacyConnectionsRoute
|
||||
legacyDashboardRoute: typeof legacyDashboardRoute
|
||||
legacyLogsRoute: typeof legacyLogsRoute
|
||||
legacyProfilesRoute: typeof legacyProfilesRoute
|
||||
legacyProvidersRoute: typeof legacyProvidersRoute
|
||||
legacyProxiesRoute: typeof legacyProxiesRoute
|
||||
legacyRulesRoute: typeof legacyRulesRoute
|
||||
legacySettingsRoute: typeof legacySettingsRoute
|
||||
}
|
||||
|
||||
const legacyRouteRouteChildren: legacyRouteRouteChildren = {
|
||||
legacyConnectionsRoute: legacyConnectionsRoute,
|
||||
legacyDashboardRoute: legacyDashboardRoute,
|
||||
legacyLogsRoute: legacyLogsRoute,
|
||||
legacyProfilesRoute: legacyProfilesRoute,
|
||||
legacyProvidersRoute: legacyProvidersRoute,
|
||||
legacyProxiesRoute: legacyProxiesRoute,
|
||||
legacyRulesRoute: legacyRulesRoute,
|
||||
legacySettingsRoute: legacySettingsRoute,
|
||||
}
|
||||
|
||||
const legacyRouteRouteWithChildren = legacyRouteRoute._addFileChildren(
|
||||
legacyRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LayoutRoute: LayoutRoute,
|
||||
ConnectionsRoute: ConnectionsRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
ProfilesRoute: ProfilesRoute,
|
||||
ProvidersRoute: ProvidersRoute,
|
||||
ProxiesRoute: ProxiesRoute,
|
||||
RulesRoute: RulesRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
legacyRouteRoute: legacyRouteRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -16,18 +16,6 @@ import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
const IS_NIGHTLY = process.env.NIGHTLY?.toLowerCase() === 'true'
|
||||
|
||||
const devtools = () => {
|
||||
return {
|
||||
name: 'react-devtools',
|
||||
transformIndexHtml(html: string) {
|
||||
return html.replace(
|
||||
/<\/head>/,
|
||||
`<script src="http://localhost:8097"></script></head>`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const builtinVars = () => {
|
||||
return {
|
||||
name: 'built-in-vars',
|
||||
@@ -123,7 +111,6 @@ export default defineConfig(({ command, mode }) => {
|
||||
compiler: 'jsx', // or 'solid'
|
||||
}),
|
||||
sassDts({ esmExport: true }),
|
||||
isDev && devtools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"license": "GPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "run-p -r web:devtools tauri:dev",
|
||||
"dev:diff": "run-p -r web:devtools tauri:diff",
|
||||
"dev": "run-p tauri:dev",
|
||||
"dev:diff": "run-p tauri:diff",
|
||||
"build": "tauri build",
|
||||
"build:debug": "tauri build -f verge-dev deadlock-detection -d -c \"{ \\\"tauri\\\" : { \\\"updater\\\": { \\\"active\\\": false } }} \"",
|
||||
"build:nightly": "tauri build -f nightly -c ./backend/tauri/tauri.nightly.conf.json",
|
||||
@@ -18,7 +18,6 @@
|
||||
"web:build": "pnpm --filter=@nyanpasu/nyanpasu build",
|
||||
"web:serve": "pnpm --filter=@nyanpasu/nyanpasu preview",
|
||||
"web:visualize": "pnpm --filter=@nyanpasu/nyanpasu bundle:visualize",
|
||||
"web:devtools": "pnpm react-devtools",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:prettier": "prettier --check .",
|
||||
"lint:eslint": "eslint --cache .",
|
||||
@@ -36,7 +35,6 @@
|
||||
"fmt": "run-p fmt:*",
|
||||
"fmt:backend": "cargo fmt --manifest-path ./backend/Cargo.toml --all",
|
||||
"fmt:prettier": "prettier --write .",
|
||||
"check": "tsx scripts/check.ts",
|
||||
"updater": "tsx scripts/updater.ts",
|
||||
"updater:nightly": "tsx scripts/updater-nightly.ts",
|
||||
"send-notify": "tsx scripts/telegram-notify.ts",
|
||||
@@ -50,7 +48,8 @@
|
||||
"prepare": "husky",
|
||||
"prepare:nightly": "tsx scripts/prepare-nightly.ts",
|
||||
"prepare:release": "tsx scripts/prepare-release.ts",
|
||||
"prepare:preview": "tsx scripts/prepare-preview.ts"
|
||||
"prepare:preview": "tsx scripts/prepare-preview.ts",
|
||||
"prepare:check": "tsx scripts/check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prettier/plugin-oxc": "0.0.4",
|
||||
@@ -97,7 +96,6 @@
|
||||
"prettier-plugin-ember-template-tag": "2.1.0",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"prettier-plugin-toml": "2.0.6",
|
||||
"react-devtools": "7.0.1",
|
||||
"stylelint": "16.25.0",
|
||||
"stylelint-config-html": "1.1.0",
|
||||
"stylelint-config-recess-order": "7.4.0",
|
||||
|
||||
Generated
+5
-1090
File diff suppressed because it is too large
Load Diff
@@ -100,8 +100,7 @@ Promise.all(jobs).then(() => {
|
||||
|
||||
const commands = [
|
||||
'pnpm dev - development with react dev tools',
|
||||
'pnpm dev:diff - deadlock development with react dev tools (recommend)',
|
||||
'pnpm tauri:diff - deadlock development',
|
||||
'pnpm dev:diff - deadlock development (recommend)',
|
||||
]
|
||||
|
||||
consola.log(' next command:\n')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
src-git packages https://github.com/coolsnowwolf/packages
|
||||
#src-git luci https://github.com/coolsnowwolf/luci.git
|
||||
src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-25.12
|
||||
src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-23.05
|
||||
#src-git luci https://github.com/coolsnowwolf/luci.git;openwrt-24.10
|
||||
src-git routing https://github.com/coolsnowwolf/routing
|
||||
src-git telephony https://github.com/coolsnowwolf/telephony.git
|
||||
|
||||
@@ -23,13 +23,13 @@ PKG_CONFIG_DEPENDS += \
|
||||
sanitize = $(call tolower,$(subst _,-,$(subst $(space),-,$(1))))
|
||||
|
||||
VERSION_NUMBER:=$(call qstrip,$(CONFIG_VERSION_NUMBER))
|
||||
VERSION_NUMBER:=$(if $(VERSION_NUMBER),$(VERSION_NUMBER),25.12-SNAPSHOT)
|
||||
VERSION_NUMBER:=$(if $(VERSION_NUMBER),$(VERSION_NUMBER),24.10.3)
|
||||
|
||||
VERSION_CODE:=$(call qstrip,$(CONFIG_VERSION_CODE))
|
||||
VERSION_CODE:=$(if $(VERSION_CODE),$(VERSION_CODE),$(REVISION))
|
||||
|
||||
VERSION_REPO:=$(call qstrip,$(CONFIG_VERSION_REPO))
|
||||
VERSION_REPO:=$(if $(VERSION_REPO),$(VERSION_REPO),https://downloads.openwrt.org/releases/25.12-SNAPSHOT)
|
||||
VERSION_REPO:=$(if $(VERSION_REPO),$(VERSION_REPO),https://downloads.openwrt.org/releases/24.10.3)
|
||||
|
||||
VERSION_DIST:=$(call qstrip,$(CONFIG_VERSION_DIST))
|
||||
VERSION_DIST:=$(if $(VERSION_DIST),$(VERSION_DIST),OpenWrt)
|
||||
|
||||
@@ -190,7 +190,7 @@ if VERSIONOPT
|
||||
config VERSION_REPO
|
||||
string
|
||||
prompt "Release repository"
|
||||
default "https://downloads.openwrt.org/releases/25.12-SNAPSHOT"
|
||||
default "https://downloads.openwrt.org/releases/24.10.3"
|
||||
help
|
||||
This is the repository address embedded in the image, it defaults
|
||||
to the trunk snapshot repo; the url may contain the following placeholders:
|
||||
|
||||
@@ -20,17 +20,19 @@ type Sudoku struct {
|
||||
|
||||
type SudokuOption struct {
|
||||
BasicOption
|
||||
Name string `proxy:"name"`
|
||||
Server string `proxy:"server"`
|
||||
Port int `proxy:"port"`
|
||||
Key string `proxy:"key"`
|
||||
AEADMethod string `proxy:"aead-method,omitempty"`
|
||||
PaddingMin *int `proxy:"padding-min,omitempty"`
|
||||
PaddingMax *int `proxy:"padding-max,omitempty"`
|
||||
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
|
||||
HTTPMask bool `proxy:"http-mask,omitempty"`
|
||||
CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
Name string `proxy:"name"`
|
||||
Server string `proxy:"server"`
|
||||
Port int `proxy:"port"`
|
||||
Key string `proxy:"key"`
|
||||
AEADMethod string `proxy:"aead-method,omitempty"`
|
||||
PaddingMin *int `proxy:"padding-min,omitempty"`
|
||||
PaddingMax *int `proxy:"padding-max,omitempty"`
|
||||
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
|
||||
HTTPMask bool `proxy:"http-mask,omitempty"`
|
||||
HTTPMaskStrategy string `proxy:"http-mask-strategy,omitempty"` // "random" (default), "post", "websocket"
|
||||
CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty
|
||||
}
|
||||
|
||||
// DialContext implements C.ProxyAdapter
|
||||
@@ -54,7 +56,9 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con
|
||||
defer done(&err)
|
||||
}
|
||||
|
||||
c, err = sudoku.ClientHandshake(c, cfg)
|
||||
c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{
|
||||
HTTPMaskStrategy: s.option.HTTPMaskStrategy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -97,7 +101,9 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
|
||||
defer done(&err)
|
||||
}
|
||||
|
||||
c, err = sudoku.ClientHandshake(c, cfg)
|
||||
c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{
|
||||
HTTPMaskStrategy: s.option.HTTPMaskStrategy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,11 +191,15 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) {
|
||||
HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds,
|
||||
DisableHTTPMask: !option.HTTPMask,
|
||||
}
|
||||
table, err := sudoku.NewTableWithCustom(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable)
|
||||
tables, err := sudoku.NewTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build table failed: %w", err)
|
||||
return nil, fmt.Errorf("build table(s) failed: %w", err)
|
||||
}
|
||||
if len(tables) == 1 {
|
||||
baseConf.Table = tables[0]
|
||||
} else {
|
||||
baseConf.Tables = tables
|
||||
}
|
||||
baseConf.Table = table
|
||||
if option.AEADMethod != "" {
|
||||
baseConf.AEADMethod = option.AEADMethod
|
||||
}
|
||||
|
||||
@@ -1049,7 +1049,9 @@ proxies: # socks5
|
||||
padding-max: 7 # 最大填充字节数
|
||||
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3
|
||||
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy`
|
||||
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table
|
||||
http-mask: true # 是否启用http掩码
|
||||
# http-mask-strategy: random # 可选:random(默认)、post、websocket;仅在 http-mask=true 时生效
|
||||
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none)
|
||||
|
||||
# anytls
|
||||
@@ -1591,6 +1593,7 @@ listeners:
|
||||
padding-max: 15 # 填充最大长度,均不建议过大
|
||||
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3
|
||||
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy`
|
||||
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table
|
||||
handshake-timeout: 5 # optional
|
||||
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none)
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ require (
|
||||
github.com/mroth/weightedrand/v2 v2.1.0
|
||||
github.com/openacid/low v0.1.21
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 // lastest version compatible with golang1.20
|
||||
github.com/saba-futai/sudoku v0.0.2-c
|
||||
github.com/saba-futai/sudoku v0.0.2-d
|
||||
github.com/sagernet/cors v1.2.1
|
||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a
|
||||
github.com/samber/lo v1.52.0
|
||||
|
||||
+2
-2
@@ -171,8 +171,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
|
||||
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
|
||||
github.com/saba-futai/sudoku v0.0.2-c h1:0CaoCKx4Br8UL97fnIxn8Y7rnQpflBza7kfaIrdg2rI=
|
||||
github.com/saba-futai/sudoku v0.0.2-c/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo=
|
||||
github.com/saba-futai/sudoku v0.0.2-d h1:HW/gIyNUFcDchpMN+ZhluM86U/HGkWkkRV+9Km6WZM8=
|
||||
github.com/saba-futai/sudoku v0.0.2-d/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo=
|
||||
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
||||
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
|
||||
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
// 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"`
|
||||
TableType string `json:"table-type,omitempty"`
|
||||
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `json:"custom-table,omitempty"`
|
||||
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"`
|
||||
TableType string `json:"table-type,omitempty"`
|
||||
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `json:"custom-table,omitempty"`
|
||||
CustomTables []string `json:"custom-tables,omitempty"`
|
||||
|
||||
// mihomo private extension (not the part of standard Sudoku protocol)
|
||||
MuxOption sing.MuxOption `json:"mux-option,omitempty"`
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
|
||||
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"`
|
||||
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
Key string `inbound:"key"`
|
||||
AEADMethod string `inbound:"aead-method,omitempty"`
|
||||
PaddingMin *int `inbound:"padding-min,omitempty"`
|
||||
PaddingMax *int `inbound:"padding-max,omitempty"`
|
||||
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
|
||||
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
|
||||
CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
||||
CustomTables []string `inbound:"custom-tables,omitempty"`
|
||||
|
||||
// mihomo private extension (not the part of standard Sudoku protocol)
|
||||
MuxOption MuxOption `inbound:"mux-option,omitempty"`
|
||||
@@ -57,6 +58,7 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) {
|
||||
HandshakeTimeoutSecond: options.HandshakeTimeoutSecond,
|
||||
EnablePureDownlink: options.EnablePureDownlink,
|
||||
CustomTable: options.CustomTable,
|
||||
CustomTables: options.CustomTables,
|
||||
}
|
||||
serverConf.MuxOption = options.MuxOption.Build()
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
|
||||
enablePureDownlink = *config.EnablePureDownlink
|
||||
}
|
||||
|
||||
table, err := sudoku.NewTableWithCustom(config.Key, tableType, config.CustomTable)
|
||||
tables, err := sudoku.NewTablesWithCustomPatterns(config.Key, tableType, config.CustomTable, config.CustomTables)
|
||||
if err != nil {
|
||||
_ = l.Close()
|
||||
return nil, err
|
||||
@@ -180,12 +180,16 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
|
||||
protoConf := sudoku.ProtocolConfig{
|
||||
Key: config.Key,
|
||||
AEADMethod: defaultConf.AEADMethod,
|
||||
Table: table,
|
||||
PaddingMin: paddingMin,
|
||||
PaddingMax: paddingMax,
|
||||
EnablePureDownlink: enablePureDownlink,
|
||||
HandshakeTimeoutSeconds: handshakeTimeout,
|
||||
}
|
||||
if len(tables) == 1 {
|
||||
protoConf.Table = tables[0]
|
||||
} else {
|
||||
protoConf.Tables = tables
|
||||
}
|
||||
if config.AEADMethod != "" {
|
||||
protoConf.AEADMethod = config.AEADMethod
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
type discardConn struct{}
|
||||
|
||||
func (discardConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (discardConn) Close() error { return nil }
|
||||
func (discardConn) LocalAddr() net.Addr { return nil }
|
||||
func (discardConn) RemoteAddr() net.Addr { return nil }
|
||||
func (discardConn) SetDeadline(time.Time) error { return nil }
|
||||
func (discardConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (discardConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func TestSudokuObfsWriter_ReducesWriteAllocs(t *testing.T) {
|
||||
table := sudokuobfs.NewTable("alloc-seed", "prefer_ascii")
|
||||
w := newSudokuObfsWriter(discardConn{}, table, 0, 0)
|
||||
|
||||
payload := bytes.Repeat([]byte{0x42}, 2048)
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
t.Fatalf("warmup write: %v", err)
|
||||
}
|
||||
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("expected 0 allocs/run, got %.2f", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMaskStrategy_WebSocketAndPost(t *testing.T) {
|
||||
key := "mask-test-key"
|
||||
target := "1.1.1.1:80"
|
||||
table := sudokuobfs.NewTable("mask-seed", "prefer_ascii")
|
||||
|
||||
base := DefaultConfig()
|
||||
base.Key = key
|
||||
base.AEADMethod = "chacha20-poly1305"
|
||||
base.Table = table
|
||||
base.PaddingMin = 0
|
||||
base.PaddingMax = 0
|
||||
base.EnablePureDownlink = true
|
||||
base.HandshakeTimeoutSeconds = 5
|
||||
base.DisableHTTPMask = false
|
||||
base.ServerAddress = "example.com:443"
|
||||
|
||||
cases := []string{"post", "websocket"}
|
||||
for _, strategy := range cases {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
defer clientConn.Close()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
session, err := ServerHandshake(serverConn, base)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
defer session.Conn.Close()
|
||||
if session.Type != SessionTypeTCP {
|
||||
errCh <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if session.Target != target {
|
||||
errCh <- io.ErrClosedPipe
|
||||
return
|
||||
}
|
||||
_, _ = session.Conn.Write([]byte("ok"))
|
||||
}()
|
||||
|
||||
cConn, err := ClientHandshakeWithOptions(clientConn, base, ClientHandshakeOptions{HTTPMaskStrategy: strategy})
|
||||
if err != nil {
|
||||
t.Fatalf("client handshake: %v", err)
|
||||
}
|
||||
defer cConn.Close()
|
||||
|
||||
addrBuf, err := EncodeAddress(target)
|
||||
if err != nil {
|
||||
t.Fatalf("encode addr: %v", err)
|
||||
}
|
||||
if _, err := cConn.Write(addrBuf); err != nil {
|
||||
t.Fatalf("write addr: %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 2)
|
||||
if _, err := io.ReadFull(cConn, buf); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(buf) != "ok" {
|
||||
t.Fatalf("unexpected payload: %q", buf)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTablesRotation_ProbedByServer(t *testing.T) {
|
||||
key := "rotate-test-key"
|
||||
target := "8.8.8.8:53"
|
||||
|
||||
t1, err := sudokuobfs.NewTableWithCustom("rotate-seed", "prefer_entropy", "xpxvvpvv")
|
||||
if err != nil {
|
||||
t.Fatalf("t1: %v", err)
|
||||
}
|
||||
t2, err := sudokuobfs.NewTableWithCustom("rotate-seed", "prefer_entropy", "vxpvxvvp")
|
||||
if err != nil {
|
||||
t.Fatalf("t2: %v", err)
|
||||
}
|
||||
|
||||
serverCfg := DefaultConfig()
|
||||
serverCfg.Key = key
|
||||
serverCfg.AEADMethod = "chacha20-poly1305"
|
||||
serverCfg.Tables = []*sudokuobfs.Table{t1, t2}
|
||||
serverCfg.PaddingMin = 0
|
||||
serverCfg.PaddingMax = 0
|
||||
serverCfg.EnablePureDownlink = true
|
||||
serverCfg.HandshakeTimeoutSeconds = 5
|
||||
serverCfg.DisableHTTPMask = true
|
||||
|
||||
clientCfg := DefaultConfig()
|
||||
*clientCfg = *serverCfg
|
||||
clientCfg.ServerAddress = "example.com:443"
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
defer serverConn.Close()
|
||||
session, err := ServerHandshake(serverConn, serverCfg)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
defer session.Conn.Close()
|
||||
if session.Type != SessionTypeTCP {
|
||||
errCh <- io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if session.Target != target {
|
||||
errCh <- io.ErrClosedPipe
|
||||
return
|
||||
}
|
||||
_, _ = session.Conn.Write([]byte{0xaa, 0xbb, 0xcc})
|
||||
}()
|
||||
|
||||
cConn, err := ClientHandshake(clientConn, clientCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("client handshake: %v", err)
|
||||
}
|
||||
|
||||
addrBuf, err := EncodeAddress(target)
|
||||
if err != nil {
|
||||
t.Fatalf("encode addr: %v", err)
|
||||
}
|
||||
if _, err := cConn.Write(addrBuf); err != nil {
|
||||
t.Fatalf("write addr: %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 3)
|
||||
if _, err := io.ReadFull(cConn, buf); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, []byte{0xaa, 0xbb, 0xcc}) {
|
||||
t.Fatalf("payload mismatch: %x", buf)
|
||||
}
|
||||
_ = cConn.Close()
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/saba-futai/sudoku/apis"
|
||||
@@ -110,25 +112,35 @@ func downlinkMode(cfg *apis.ProtocolConfig) byte {
|
||||
return downlinkModePacked
|
||||
}
|
||||
|
||||
func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig) net.Conn {
|
||||
base := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table) net.Conn {
|
||||
baseReader := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
baseWriter := newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
if cfg.EnablePureDownlink {
|
||||
return base
|
||||
return &directionalConn{
|
||||
Conn: raw,
|
||||
reader: baseReader,
|
||||
writer: baseWriter,
|
||||
}
|
||||
}
|
||||
packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
return &directionalConn{
|
||||
Conn: raw,
|
||||
reader: packed,
|
||||
writer: base,
|
||||
writer: baseWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, record bool) (*sudoku.Conn, net.Conn) {
|
||||
uplink := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, record)
|
||||
func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) {
|
||||
uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record)
|
||||
if cfg.EnablePureDownlink {
|
||||
return uplink, uplink
|
||||
downlink := &directionalConn{
|
||||
Conn: raw,
|
||||
reader: uplink,
|
||||
writer: newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax),
|
||||
}
|
||||
return uplink, downlink
|
||||
}
|
||||
packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||
return uplink, &directionalConn{
|
||||
Conn: raw,
|
||||
reader: uplink,
|
||||
@@ -170,8 +182,19 @@ func ClientAEADSeed(key string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
type ClientHandshakeOptions struct {
|
||||
// HTTPMaskStrategy controls how the client generates the HTTP mask header when DisableHTTPMask=false.
|
||||
// Supported: ""/"random" (default), "post", "websocket".
|
||||
HTTPMaskStrategy string
|
||||
}
|
||||
|
||||
// ClientHandshake performs the client-side Sudoku handshake (without sending target address).
|
||||
func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) {
|
||||
return ClientHandshakeWithOptions(rawConn, cfg, ClientHandshakeOptions{})
|
||||
}
|
||||
|
||||
// ClientHandshakeWithOptions performs the client-side Sudoku handshake (without sending target address).
|
||||
func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is required")
|
||||
}
|
||||
@@ -180,18 +203,26 @@ func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, erro
|
||||
}
|
||||
|
||||
if !cfg.DisableHTTPMask {
|
||||
if err := httpmask.WriteRandomRequestHeader(rawConn, cfg.ServerAddress); err != nil {
|
||||
if err := WriteHTTPMaskHeader(rawConn, cfg.ServerAddress, opt.HTTPMaskStrategy); err != nil {
|
||||
return nil, fmt.Errorf("write http mask failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
obfsConn := buildClientObfsConn(rawConn, cfg)
|
||||
table, tableID, err := pickClientTable(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obfsConn := buildClientObfsConn(rawConn, cfg, table)
|
||||
cConn, err := crypto.NewAEADConn(obfsConn, ClientAEADSeed(cfg.Key), cfg.AEADMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup crypto failed: %w", err)
|
||||
}
|
||||
|
||||
handshake := buildHandshakePayload(cfg.Key)
|
||||
if len(tableCandidates(cfg)) > 1 {
|
||||
handshake[15] = tableID
|
||||
}
|
||||
if _, err := cConn.Write(handshake[:]); err != nil {
|
||||
cConn.Close()
|
||||
return nil, fmt.Errorf("send handshake failed: %w", err)
|
||||
@@ -218,21 +249,25 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession
|
||||
handshakeTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
||||
|
||||
bufReader := bufio.NewReader(rawConn)
|
||||
if !cfg.DisableHTTPMask {
|
||||
if peek, _ := bufReader.Peek(4); len(peek) == 4 && string(peek) == "POST" {
|
||||
if peek, err := bufReader.Peek(4); err == nil && httpmask.LooksLikeHTTPRequestStart(peek) {
|
||||
if _, err := httpmask.ConsumeHeader(bufReader); err != nil {
|
||||
return nil, fmt.Errorf("invalid http header: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
||||
bConn := &bufferedConn{
|
||||
Conn: rawConn,
|
||||
r: bufReader,
|
||||
selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, tableCandidates(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sConn, obfsConn := buildServerObfsConn(bConn, cfg, true)
|
||||
|
||||
baseConn := &preBufferedConn{Conn: rawConn, buf: preRead}
|
||||
bConn := &bufferedConn{Conn: baseConn, r: bufio.NewReader(baseConn)}
|
||||
sConn, obfsConn := buildServerObfsConn(bConn, cfg, selectedTable, true)
|
||||
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crypto setup failed: %w", err)
|
||||
@@ -313,3 +348,24 @@ func GenKeyPair() (privateKey, publicKey string, err error) {
|
||||
publicKey = crypto.EncodePoint(pair.Public) // Master Public Key for server
|
||||
return
|
||||
}
|
||||
|
||||
func normalizeHTTPMaskStrategy(strategy string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(strategy))
|
||||
switch s {
|
||||
case "", "random":
|
||||
return "random"
|
||||
case "ws":
|
||||
return "websocket"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// randomByte returns a cryptographically random byte (with a math/rand fallback).
|
||||
func randomByte() byte {
|
||||
var b [1]byte
|
||||
if _, err := rand.Read(b[:]); err == nil {
|
||||
return b[0]
|
||||
}
|
||||
return byte(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/httpmask"
|
||||
)
|
||||
|
||||
var (
|
||||
httpMaskUserAgents = []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
httpMaskAccepts = []string{
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"application/json, text/plain, */*",
|
||||
"application/octet-stream",
|
||||
"*/*",
|
||||
}
|
||||
httpMaskAcceptLanguages = []string{
|
||||
"en-US,en;q=0.9",
|
||||
"zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
httpMaskAcceptEncodings = []string{
|
||||
"gzip, deflate, br",
|
||||
"gzip, deflate",
|
||||
}
|
||||
httpMaskPaths = []string{
|
||||
"/api/v1/upload",
|
||||
"/data/sync",
|
||||
"/v1/telemetry",
|
||||
"/session",
|
||||
"/ws",
|
||||
}
|
||||
httpMaskContentTypes = []string{
|
||||
"application/octet-stream",
|
||||
"application/json",
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
httpMaskRngPool = sync.Pool{
|
||||
New: func() any { return rand.New(rand.NewSource(time.Now().UnixNano())) },
|
||||
}
|
||||
httpMaskBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
b := make([]byte, 0, 1024)
|
||||
return &b
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func trimPortForHost(host string) string {
|
||||
if host == "" {
|
||||
return host
|
||||
}
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte {
|
||||
ua := httpMaskUserAgents[r.Intn(len(httpMaskUserAgents))]
|
||||
accept := httpMaskAccepts[r.Intn(len(httpMaskAccepts))]
|
||||
lang := httpMaskAcceptLanguages[r.Intn(len(httpMaskAcceptLanguages))]
|
||||
enc := httpMaskAcceptEncodings[r.Intn(len(httpMaskAcceptEncodings))]
|
||||
|
||||
buf = append(buf, "Host: "...)
|
||||
buf = append(buf, host...)
|
||||
buf = append(buf, "\r\nUser-Agent: "...)
|
||||
buf = append(buf, ua...)
|
||||
buf = append(buf, "\r\nAccept: "...)
|
||||
buf = append(buf, accept...)
|
||||
buf = append(buf, "\r\nAccept-Language: "...)
|
||||
buf = append(buf, lang...)
|
||||
buf = append(buf, "\r\nAccept-Encoding: "...)
|
||||
buf = append(buf, enc...)
|
||||
buf = append(buf, "\r\nConnection: keep-alive\r\n"...)
|
||||
buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// WriteHTTPMaskHeader writes an HTTP/1.x request header as a mask, according to strategy.
|
||||
// Supported strategies: ""/"random", "post", "websocket".
|
||||
func WriteHTTPMaskHeader(w io.Writer, host string, strategy string) error {
|
||||
switch normalizeHTTPMaskStrategy(strategy) {
|
||||
case "random":
|
||||
return httpmask.WriteRandomRequestHeader(w, host)
|
||||
case "post":
|
||||
return writeHTTPMaskPOST(w, host)
|
||||
case "websocket":
|
||||
return writeHTTPMaskWebSocket(w, host)
|
||||
default:
|
||||
return fmt.Errorf("unsupported http-mask-strategy: %s", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func writeHTTPMaskPOST(w io.Writer, host string) error {
|
||||
r := httpMaskRngPool.Get().(*rand.Rand)
|
||||
defer httpMaskRngPool.Put(r)
|
||||
|
||||
path := httpMaskPaths[r.Intn(len(httpMaskPaths))]
|
||||
ctype := httpMaskContentTypes[r.Intn(len(httpMaskContentTypes))]
|
||||
|
||||
bufPtr := httpMaskBufPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
buf = buf[:0]
|
||||
defer func() {
|
||||
if cap(buf) <= 4096 {
|
||||
*bufPtr = buf
|
||||
httpMaskBufPool.Put(bufPtr)
|
||||
}
|
||||
}()
|
||||
|
||||
const minCL = int64(4 * 1024)
|
||||
const maxCL = int64(10 * 1024 * 1024)
|
||||
contentLength := minCL + r.Int63n(maxCL-minCL+1)
|
||||
|
||||
buf = append(buf, "POST "...)
|
||||
buf = append(buf, path...)
|
||||
buf = append(buf, " HTTP/1.1\r\n"...)
|
||||
buf = appendCommonHeaders(buf, host, r)
|
||||
buf = append(buf, "Content-Type: "...)
|
||||
buf = append(buf, ctype...)
|
||||
buf = append(buf, "\r\nContent-Length: "...)
|
||||
buf = strconv.AppendInt(buf, contentLength, 10)
|
||||
buf = append(buf, "\r\n\r\n"...)
|
||||
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeHTTPMaskWebSocket(w io.Writer, host string) error {
|
||||
r := httpMaskRngPool.Get().(*rand.Rand)
|
||||
defer httpMaskRngPool.Put(r)
|
||||
|
||||
path := httpMaskPaths[r.Intn(len(httpMaskPaths))]
|
||||
|
||||
bufPtr := httpMaskBufPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
buf = buf[:0]
|
||||
defer func() {
|
||||
if cap(buf) <= 4096 {
|
||||
*bufPtr = buf
|
||||
httpMaskBufPool.Put(bufPtr)
|
||||
}
|
||||
}()
|
||||
|
||||
hostNoPort := trimPortForHost(host)
|
||||
var keyBytes [16]byte
|
||||
for i := 0; i < len(keyBytes); i++ {
|
||||
keyBytes[i] = byte(r.Intn(256))
|
||||
}
|
||||
var wsKey [24]byte
|
||||
base64.StdEncoding.Encode(wsKey[:], keyBytes[:])
|
||||
|
||||
buf = append(buf, "GET "...)
|
||||
buf = append(buf, path...)
|
||||
buf = append(buf, " HTTP/1.1\r\n"...)
|
||||
buf = appendCommonHeaders(buf, host, r)
|
||||
buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...)
|
||||
buf = append(buf, wsKey[:]...)
|
||||
buf = append(buf, "\r\nOrigin: https://"...)
|
||||
buf = append(buf, hostNoPort...)
|
||||
buf = append(buf, "\r\n\r\n"...)
|
||||
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
crypto_rand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"math/rand"
|
||||
"net"
|
||||
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
// perm4 matches github.com/saba-futai/sudoku/pkg/obfs/sudoku perm4.
|
||||
var perm4 = [24][4]byte{
|
||||
{0, 1, 2, 3},
|
||||
{0, 1, 3, 2},
|
||||
{0, 2, 1, 3},
|
||||
{0, 2, 3, 1},
|
||||
{0, 3, 1, 2},
|
||||
{0, 3, 2, 1},
|
||||
{1, 0, 2, 3},
|
||||
{1, 0, 3, 2},
|
||||
{1, 2, 0, 3},
|
||||
{1, 2, 3, 0},
|
||||
{1, 3, 0, 2},
|
||||
{1, 3, 2, 0},
|
||||
{2, 0, 1, 3},
|
||||
{2, 0, 3, 1},
|
||||
{2, 1, 0, 3},
|
||||
{2, 1, 3, 0},
|
||||
{2, 3, 0, 1},
|
||||
{2, 3, 1, 0},
|
||||
{3, 0, 1, 2},
|
||||
{3, 0, 2, 1},
|
||||
{3, 1, 0, 2},
|
||||
{3, 1, 2, 0},
|
||||
{3, 2, 0, 1},
|
||||
{3, 2, 1, 0},
|
||||
}
|
||||
|
||||
type sudokuObfsWriter struct {
|
||||
conn net.Conn
|
||||
table *sudoku.Table
|
||||
rng *rand.Rand
|
||||
paddingRate float32
|
||||
|
||||
outBuf []byte
|
||||
pads []byte
|
||||
padLen int
|
||||
}
|
||||
|
||||
func newSudokuObfsWriter(conn net.Conn, table *sudoku.Table, pMin, pMax int) *sudokuObfsWriter {
|
||||
var seedBytes [8]byte
|
||||
if _, err := crypto_rand.Read(seedBytes[:]); err != nil {
|
||||
binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63()))
|
||||
}
|
||||
seed := int64(binary.BigEndian.Uint64(seedBytes[:]))
|
||||
localRng := rand.New(rand.NewSource(seed))
|
||||
|
||||
min := float32(pMin) / 100.0
|
||||
span := float32(pMax-pMin) / 100.0
|
||||
rate := min + localRng.Float32()*span
|
||||
|
||||
w := &sudokuObfsWriter{
|
||||
conn: conn,
|
||||
table: table,
|
||||
rng: localRng,
|
||||
paddingRate: rate,
|
||||
}
|
||||
w.pads = table.PaddingPool
|
||||
w.padLen = len(w.pads)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *sudokuObfsWriter) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Worst-case: 4 hints + up to 6 paddings per input byte.
|
||||
needed := len(p)*10 + 1
|
||||
if cap(w.outBuf) < needed {
|
||||
w.outBuf = make([]byte, 0, needed)
|
||||
}
|
||||
out := w.outBuf[:0]
|
||||
|
||||
pads := w.pads
|
||||
padLen := w.padLen
|
||||
|
||||
for _, b := range p {
|
||||
if padLen > 0 && w.rng.Float32() < w.paddingRate {
|
||||
out = append(out, pads[w.rng.Intn(padLen)])
|
||||
}
|
||||
|
||||
puzzles := w.table.EncodeTable[b]
|
||||
puzzle := puzzles[w.rng.Intn(len(puzzles))]
|
||||
|
||||
perm := perm4[w.rng.Intn(len(perm4))]
|
||||
for _, idx := range perm {
|
||||
if padLen > 0 && w.rng.Float32() < w.paddingRate {
|
||||
out = append(out, pads[w.rng.Intn(padLen)])
|
||||
}
|
||||
out = append(out, puzzle[idx])
|
||||
}
|
||||
}
|
||||
|
||||
if padLen > 0 && w.rng.Float32() < w.paddingRate {
|
||||
out = append(out, pads[w.rng.Intn(padLen)])
|
||||
}
|
||||
|
||||
w.outBuf = out
|
||||
_, err := w.conn.Write(out)
|
||||
return len(p), err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/saba-futai/sudoku/apis"
|
||||
"github.com/saba-futai/sudoku/pkg/crypto"
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
func tableCandidates(cfg *apis.ProtocolConfig) []*sudoku.Table {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Tables) > 0 {
|
||||
return cfg.Tables
|
||||
}
|
||||
if cfg.Table != nil {
|
||||
return []*sudoku.Table{cfg.Table}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickClientTable(cfg *apis.ProtocolConfig) (*sudoku.Table, byte, error) {
|
||||
candidates := tableCandidates(cfg)
|
||||
if len(candidates) == 0 {
|
||||
return nil, 0, fmt.Errorf("no table configured")
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return candidates[0], 0, nil
|
||||
}
|
||||
idx := int(randomByte()) % len(candidates)
|
||||
return candidates[idx], byte(idx), nil
|
||||
}
|
||||
|
||||
type readOnlyConn struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (c *readOnlyConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
func (c *readOnlyConn) Close() error { return nil }
|
||||
func (c *readOnlyConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *readOnlyConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *readOnlyConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *readOnlyConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *readOnlyConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func drainBuffered(r *bufio.Reader) ([]byte, error) {
|
||||
n := r.Buffered()
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]byte, n)
|
||||
_, err := io.ReadFull(r, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.Table) error {
|
||||
rc := &readOnlyConn{Reader: bytes.NewReader(probe)}
|
||||
_, obfsConn := buildServerObfsConn(rc, cfg, table, false)
|
||||
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var handshakeBuf [16]byte
|
||||
if _, err := io.ReadFull(cConn, handshakeBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
ts := int64(binary.BigEndian.Uint64(handshakeBuf[:8]))
|
||||
if absInt64(time.Now().Unix()-ts) > 60 {
|
||||
return fmt.Errorf("timestamp skew/replay detected")
|
||||
}
|
||||
|
||||
modeBuf := []byte{0}
|
||||
if _, err := io.ReadFull(cConn, modeBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
if modeBuf[0] != downlinkMode(cfg) {
|
||||
return fmt.Errorf("downlink mode mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTableByProbe(r *bufio.Reader, cfg *apis.ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) {
|
||||
const (
|
||||
maxProbeBytes = 64 * 1024
|
||||
readChunk = 4 * 1024
|
||||
)
|
||||
if len(tables) == 0 {
|
||||
return nil, nil, fmt.Errorf("no table candidates")
|
||||
}
|
||||
if len(tables) > 255 {
|
||||
return nil, nil, fmt.Errorf("too many table candidates: %d", len(tables))
|
||||
}
|
||||
|
||||
probe, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
|
||||
tmp := make([]byte, readChunk)
|
||||
for {
|
||||
if len(tables) == 1 {
|
||||
tail, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
probe = append(probe, tail...)
|
||||
return tables[0], probe, nil
|
||||
}
|
||||
|
||||
needMore := false
|
||||
for _, table := range tables {
|
||||
err := probeHandshakeBytes(probe, cfg, table)
|
||||
if err == nil {
|
||||
tail, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
probe = append(probe, tail...)
|
||||
return table, probe, nil
|
||||
}
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
needMore = true
|
||||
}
|
||||
}
|
||||
|
||||
if !needMore {
|
||||
return nil, probe, fmt.Errorf("handshake table selection failed")
|
||||
}
|
||||
if len(probe) >= maxProbeBytes {
|
||||
return nil, probe, fmt.Errorf("handshake probe exceeded %d bytes", maxProbeBytes)
|
||||
}
|
||||
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
probe = append(probe, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, probe, fmt.Errorf("handshake probe read failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/saba-futai/sudoku/pkg/obfs/sudoku"
|
||||
)
|
||||
|
||||
// NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns.
|
||||
// When customTables is non-empty it overrides customTable (matching upstream Sudoku behavior).
|
||||
func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
|
||||
patterns := customTables
|
||||
if len(patterns) == 0 && strings.TrimSpace(customTable) != "" {
|
||||
patterns = []string{customTable}
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{""}
|
||||
}
|
||||
|
||||
tables := make([]*sudoku.Table, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
t, err := NewTableWithCustom(key, tableType, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
@@ -308,6 +308,17 @@ o = s:option(DummyValue, "switch_mode", " ")
|
||||
o.template = appname .. "/global/proxy"
|
||||
o:depends({ _tcp_node_bool = "1" })
|
||||
|
||||
-- Node → DNS Depends Settings
|
||||
o = s:option(DummyValue, "_node_sel_shunt", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ tcp_node = "__always__" })
|
||||
|
||||
o = s:option(DummyValue, "_node_sel_other", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
|
||||
|
||||
---- DNS
|
||||
o = s:option(ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
|
||||
o.default = "chinadns-ng"
|
||||
@@ -333,6 +344,7 @@ end
|
||||
if has_xray then
|
||||
o:value("xray", "Xray")
|
||||
end
|
||||
o:depends({ _tcp_node_bool = "1", _node_sel_other = "1" })
|
||||
o.remove = function(self, section)
|
||||
local f = s.fields["tcp_node"]
|
||||
local id_val = f and f:formvalue(section) or ""
|
||||
@@ -362,7 +374,9 @@ o:value("tcp", "TCP")
|
||||
o:value("tcp+doh", "TCP + DoH (" .. translate("A/AAAA type") .. ")")
|
||||
o:depends("dns_mode", "xray")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, ["tcp+doh"] = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "xray" then
|
||||
@@ -377,7 +391,9 @@ o:value("tcp", "TCP")
|
||||
o:value("doh", "DoH")
|
||||
o:depends("dns_mode", "sing-box")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, doh = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "sing-box" then
|
||||
@@ -444,6 +460,7 @@ o = s:option(Value, "remote_dns_client_ip", translate("EDNS Client Subnet"))
|
||||
o.datatype = "ipaddr"
|
||||
o:depends({dns_mode = "sing-box"})
|
||||
o:depends({dns_mode = "xray"})
|
||||
o:depends({_node_sel_shunt = "1"})
|
||||
|
||||
o = s:option(ListValue, "chinadns_ng_default_tag", translate("Default DNS"))
|
||||
o.default = "none"
|
||||
@@ -485,6 +502,7 @@ for k, v in pairs(nodes_table) do
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["xray_dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
|
||||
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
|
||||
end
|
||||
if v.type == "sing-box" and has_singbox then
|
||||
tcp:value(v.id, v["remark"])
|
||||
@@ -493,17 +511,13 @@ for k, v in pairs(nodes_table) do
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["singbox_dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
|
||||
end
|
||||
if has_xray or has_singbox then
|
||||
s.fields["remote_dns_client_ip"]:depends({ tcp_node = v.id })
|
||||
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
|
||||
end
|
||||
else
|
||||
tcp:value(v.id, v["remark"])
|
||||
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
udp:value(v.id, v["remark"])
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -309,6 +309,18 @@ o = s:taboption("Main", Flag, "tcp_node_socks_bind_local", translate("TCP Node")
|
||||
o.default = "1"
|
||||
o:depends({ tcp_node = "", ["!reverse"] = true })
|
||||
|
||||
-- Node → DNS Depends Settings
|
||||
o = s:taboption("Main", DummyValue, "_node_sel_shunt", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ tcp_node = "__always__" })
|
||||
|
||||
o = s:taboption("Main", DummyValue, "_node_sel_other", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
|
||||
|
||||
-- [[ DNS Settings ]]--
|
||||
s:tab("DNS", translate("DNS"))
|
||||
|
||||
o = s:taboption("DNS", ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
|
||||
@@ -388,8 +400,8 @@ end
|
||||
if has_xray then
|
||||
o:value("xray", "Xray")
|
||||
end
|
||||
o:depends({ dns_shunt = "chinadns-ng", tcp_node = "" })
|
||||
o:depends({ dns_shunt = "dnsmasq", tcp_node = "" })
|
||||
o:depends({ dns_shunt = "chinadns-ng", _node_sel_other = "1" })
|
||||
o:depends({ dns_shunt = "dnsmasq", _node_sel_other = "1" })
|
||||
o.remove = function(self, section)
|
||||
local f = s.fields["smartdns_dns_mode"]
|
||||
if f and f:formvalue(section) then
|
||||
@@ -408,7 +420,7 @@ if api.is_finded("smartdns") then
|
||||
if has_xray then
|
||||
o:value("xray", "Xray")
|
||||
end
|
||||
o:depends({ dns_shunt = "smartdns", tcp_node = "" })
|
||||
o:depends({ dns_shunt = "smartdns", _node_sel_other = "1" })
|
||||
o.remove = function(self, section)
|
||||
local f = s.fields["dns_mode"]
|
||||
if f and f:formvalue(section) then
|
||||
@@ -468,7 +480,9 @@ o:value("tcp+doh", "TCP + DoH (" .. translate("A/AAAA type") .. ")")
|
||||
o:depends("dns_mode", "xray")
|
||||
o:depends("smartdns_dns_mode", "xray")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, ["tcp+doh"] = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "xray" or s.fields["smartdns_dns_mode"]:formvalue(section) == "xray" then
|
||||
@@ -484,7 +498,9 @@ o:value("doh", "DoH")
|
||||
o:depends("dns_mode", "sing-box")
|
||||
o:depends("smartdns_dns_mode", "sing-box")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, doh = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "sing-box" or s.fields["smartdns_dns_mode"]:formvalue(section) == "sing-box" then
|
||||
@@ -548,6 +564,7 @@ o.datatype = "ipaddr"
|
||||
o:depends({dns_mode = "sing-box"})
|
||||
o:depends({dns_mode = "xray"})
|
||||
o:depends("dns_shunt", "smartdns")
|
||||
o:depends("_node_sel_shunt", "1")
|
||||
|
||||
o = s:taboption("DNS", Flag, "remote_fakedns", "FakeDNS", translate("Use FakeDNS work in the shunt domain that proxy."))
|
||||
o.default = "0"
|
||||
@@ -557,6 +574,7 @@ o:depends({smartdns_dns_mode = "sing-box", dns_shunt = "smartdns"})
|
||||
o:depends({dns_mode = "xray", dns_shunt = "dnsmasq"})
|
||||
o:depends({dns_mode = "xray", dns_shunt = "chinadns-ng"})
|
||||
o:depends({smartdns_dns_mode = "xray", dns_shunt = "smartdns"})
|
||||
o:depends("_node_sel_shunt", "1")
|
||||
o.validate = function(self, value, t)
|
||||
if value and value == "1" then
|
||||
local _dns_mode = s.fields["dns_mode"]:formvalue(t)
|
||||
@@ -810,22 +828,15 @@ for k, v in pairs(nodes_table) do
|
||||
udp:value(v.id, v["remark"])
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["xray_dns_mode"]:depends({ [v.id .. "-type"] = "Xray", tcp_node = v.id })
|
||||
s.fields["singbox_dns_mode"]:depends({ [v.id .. "-type"] = "sing-box", tcp_node = v.id })
|
||||
s.fields["remote_dns_client_ip"]:depends({ tcp_node = v.id })
|
||||
s.fields["remote_fakedns"]:depends({ tcp_node = v.id })
|
||||
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
|
||||
s.fields["xray_dns_mode"]:depends({ [v.id .. "-type"] = "Xray", _node_sel_shunt = "1" })
|
||||
s.fields["singbox_dns_mode"]:depends({ [v.id .. "-type"] = "sing-box", _node_sel_shunt = "1" })
|
||||
end
|
||||
else
|
||||
tcp:value(v.id, v["remark"])
|
||||
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
udp:value(v.id, v["remark"])
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["dns_mode"]:depends({ dns_shunt = "chinadns-ng", tcp_node = v.id })
|
||||
s.fields["dns_mode"]:depends({ dns_shunt = "dnsmasq", tcp_node = v.id })
|
||||
if api.is_finded("smartdns") then
|
||||
s.fields["smartdns_dns_mode"]:depends({ dns_shunt = "smartdns", tcp_node = v.id })
|
||||
end
|
||||
end
|
||||
if v.type == "Socks" then
|
||||
if has_singbox or has_xray then
|
||||
|
||||
@@ -201,8 +201,8 @@ function gen_outbound(flag, node, tag, proxy_table)
|
||||
} or nil,
|
||||
wsSettings = (node.transport == "ws") and {
|
||||
path = node.ws_path or "/",
|
||||
headers = (node.ws_host or node.ws_user_agent) and {
|
||||
Host = node.ws_host,
|
||||
host = node.ws_host,
|
||||
headers = node.ws_user_agent and {
|
||||
["User-Agent"] = node.ws_user_agent
|
||||
} or nil,
|
||||
maxEarlyData = tonumber(node.ws_maxEarlyData) or nil,
|
||||
|
||||
@@ -139,7 +139,7 @@ window.lv_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
|
||||
lv_openPanel(cbid,display,panel,listContainer,hiddenSelect,searchInput);
|
||||
}
|
||||
});
|
||||
lv_adaptiveStyle(cbid); // copy select styles
|
||||
lv_registerAdaptive(cbid);
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
+33
-28
@@ -244,9 +244,11 @@ local appname = api.appname
|
||||
return lv_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// copy select styles
|
||||
function lv_adaptiveStyle(cbid) {
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const hiddenSelect = document.getElementById(cbid);
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
if (hiddenSelect && display) {
|
||||
const elOption = hiddenSelect.getElementsByTagName("option")[0]
|
||||
const styleSelect = window.getComputedStyle(hiddenSelect)
|
||||
@@ -466,7 +468,13 @@ local appname = api.appname
|
||||
}
|
||||
panel.style.left = rect.left + "px";
|
||||
panel.style.top = top + "px";
|
||||
panel.style.minWidth = rect.width + "px";
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const displayWidth = rect.width;
|
||||
const remainingWidth = window.innerWidth - panelRect.left - 12;
|
||||
const maxWidth = Math.max(displayWidth, Math.floor(remainingWidth));
|
||||
panel.style.maxWidth = maxWidth + "px";
|
||||
panel.style.minWidth = displayWidth + "px";
|
||||
panel.style.width = "auto";
|
||||
panel.style.visibility = "";
|
||||
}
|
||||
|
||||
@@ -601,22 +609,24 @@ local appname = api.appname
|
||||
if(!li || li === listContainer) return;
|
||||
const key = li.getAttribute('data-key') || "";
|
||||
const text = li.querySelector(".lv-item-label")?.textContent || li.textContent || key;
|
||||
if (key !== hiddenSelect.value) {
|
||||
//动态改值
|
||||
|
||||
const changed = key !== hiddenSelect.value;
|
||||
if (changed) {
|
||||
//改值
|
||||
hiddenSelect.options[0].value = key;
|
||||
hiddenSelect.options[0].text = key;
|
||||
hiddenSelect.value = key;
|
||||
labelSpan.textContent = text;
|
||||
labelSpan.title = text;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const evt = new Event('change', { bubbles: true });
|
||||
hiddenSelect.dispatchEvent(evt);
|
||||
} catch(e){}
|
||||
}, 0);
|
||||
lv_highlightSelectedItem(listContainer, hiddenSelect);
|
||||
lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput);
|
||||
}
|
||||
lv_closePanel(cbid,panel,listContainer,hiddenSelect,searchInput);
|
||||
if (changed) {
|
||||
setTimeout(() => {
|
||||
hiddenSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
@@ -635,27 +645,22 @@ local appname = api.appname
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 设置宽度
|
||||
panel.style.maxWidth = lv_getPanelMaxWidth(display);
|
||||
panel.style.minWidth = display.getBoundingClientRect().width + "px";
|
||||
panel.style.width = "auto";
|
||||
}
|
||||
|
||||
function lv_getPanelMaxWidth(display) {
|
||||
if (!display) return 0;
|
||||
const rectW = el => el && el.getBoundingClientRect().width;
|
||||
const fallback = rectW(display) || 0;
|
||||
const cbiValue = display.closest(".cbi-value");
|
||||
if (cbiValue) {
|
||||
const valueW = rectW(cbiValue);
|
||||
const titleW = rectW(cbiValue.querySelector(".cbi-value-title"));
|
||||
if (valueW) {
|
||||
return Math.floor(titleW ? valueW - titleW : valueW);
|
||||
}
|
||||
}
|
||||
const fieldW = rectW(display.closest(".cbi-value-field"));
|
||||
return Math.floor(fieldW || fallback);
|
||||
const lv_adaptiveControls = new Set();
|
||||
function lv_registerAdaptive(cbid) {
|
||||
lv_adaptiveControls.add(cbid);
|
||||
lv_adaptiveStyle(cbid);
|
||||
}
|
||||
let lv_adaptiveTicking = false;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!lv_adaptiveTicking) {
|
||||
lv_adaptiveTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
lv_adaptiveControls.forEach(cbid => lv_adaptiveStyle(cbid));
|
||||
lv_adaptiveTicking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
Generated
+12
-12
@@ -821,7 +821,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -928,7 +928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1470,7 +1470,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -2127,7 +2127,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2452,7 +2452,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2489,9 +2489,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2604,9 +2604,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.25"
|
||||
version = "0.12.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a"
|
||||
checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -2758,7 +2758,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3432,7 +3432,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4060,7 +4060,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
fe7ab107d3a222ca878b9a727d76075938ee7cde
|
||||
3745171528eaf62dd8819df4c089b1259a32a1f2
|
||||
|
||||
Vendored
+15
-5
@@ -69,11 +69,11 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- { os: linux, arch: amd64, variant: purego, openwrt: "x86_64" }
|
||||
- { os: linux, arch: amd64, variant: purego, naive: true, openwrt: "x86_64" }
|
||||
- { os: linux, arch: amd64, variant: glibc, naive: true }
|
||||
- { os: linux, arch: amd64, variant: musl, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64, openwrt: "x86_64" }
|
||||
|
||||
- { os: linux, arch: arm64, variant: purego, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" }
|
||||
- { os: linux, arch: arm64, variant: purego, naive: true, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" }
|
||||
- { os: linux, arch: arm64, variant: glibc, naive: true }
|
||||
- { os: linux, arch: arm64, variant: musl, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" }
|
||||
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
- name: Set build tags
|
||||
run: |
|
||||
set -xeuo pipefail
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
|
||||
if [[ "${{ matrix.naive }}" == "true" ]]; then
|
||||
TAGS="${TAGS},with_naive_outbound"
|
||||
fi
|
||||
@@ -427,7 +427,7 @@ jobs:
|
||||
- name: Set build tags
|
||||
run: |
|
||||
set -xeuo pipefail
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
|
||||
if [[ "${{ matrix.legacy_go124 }}" != "true" ]]; then
|
||||
TAGS="${TAGS},with_naive_outbound"
|
||||
fi
|
||||
@@ -495,7 +495,7 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
mkdir -p dist
|
||||
go build -v -trimpath -o dist/sing-box.exe -tags "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0" `
|
||||
go build -v -trimpath -o dist/sing-box.exe -tags "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0" `
|
||||
-ldflags "-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -checklinkname=0" `
|
||||
./cmd/sing-box
|
||||
env:
|
||||
@@ -885,6 +885,16 @@ jobs:
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Generate SFA version metadata
|
||||
run: |-
|
||||
VERSION_CODE=$(grep VERSION_CODE clients/android/version.properties | cut -d= -f2)
|
||||
cat > dist/SFA-version-metadata.json << EOF
|
||||
{
|
||||
"version_code": ${VERSION_CODE},
|
||||
"version_name": "${VERSION}"
|
||||
}
|
||||
EOF
|
||||
cat dist/SFA-version-metadata.json
|
||||
- name: Upload builds
|
||||
if: ${{ env.PUBLISHED == 'false' }}
|
||||
run: |-
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ jobs:
|
||||
- name: Set build tags
|
||||
run: |
|
||||
set -xeuo pipefail
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
|
||||
if [[ "${{ matrix.naive }}" == "true" ]]; then
|
||||
TAGS="${TAGS},with_naive_outbound,with_musl"
|
||||
fi
|
||||
|
||||
Vendored
+1
-1
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: Set build tags
|
||||
run: |
|
||||
set -xeuo pipefail
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
|
||||
if [[ "${{ matrix.naive }}" == "true" ]]; then
|
||||
TAGS="${TAGS},with_naive_outbound,with_musl"
|
||||
fi
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ RUN set -ex \
|
||||
&& export COMMIT=$(git rev-parse --short HEAD) \
|
||||
&& export VERSION=$(go run ./cmd/internal/read_tag) \
|
||||
&& go build -v -trimpath -tags \
|
||||
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0" \
|
||||
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0" \
|
||||
-o /go/bin/sing-box \
|
||||
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \
|
||||
./cmd/sing-box
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
NAME = sing-box
|
||||
COMMIT = $(shell git rev-parse --short HEAD)
|
||||
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0
|
||||
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0
|
||||
|
||||
GOHOSTOS = $(shell go env GOHOSTOS)
|
||||
GOHOSTARCH = $(shell go env GOHOSTARCH)
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
TypeResolved = "resolved"
|
||||
TypeSSMAPI = "ssm-api"
|
||||
TypeCCM = "ccm"
|
||||
TypeOCM = "ocm"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
icon: material/alert-decagram
|
||||
---
|
||||
|
||||
#### 1.13.0-alpha.30
|
||||
|
||||
* Add OpenAI Codex Multiplexer service **1**
|
||||
* Fixes and improvements
|
||||
|
||||
**1**:
|
||||
|
||||
See [OCM](/configuration/service/ocm).
|
||||
|
||||
#### 1.13.0-alpha.29
|
||||
|
||||
* Add UDP over TCP support for naiveproxy outbound **1**
|
||||
|
||||
@@ -25,6 +25,7 @@ icon: material/new-box
|
||||
|------------|------------------------|
|
||||
| `ccm` | [CCM](./ccm) |
|
||||
| `derp` | [DERP](./derp) |
|
||||
| `ocm` | [OCM](./ocm) |
|
||||
| `resolved` | [Resolved](./resolved) |
|
||||
| `ssm-api` | [SSM API](./ssm-api) |
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ icon: material/new-box
|
||||
|-----------|------------------------|
|
||||
| `ccm` | [CCM](./ccm) |
|
||||
| `derp` | [DERP](./derp) |
|
||||
| `ocm` | [OCM](./ocm) |
|
||||
| `resolved`| [Resolved](./resolved) |
|
||||
| `ssm-api` | [SSM API](./ssm-api) |
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
icon: material/new-box
|
||||
---
|
||||
|
||||
!!! question "Since sing-box 1.13.0"
|
||||
|
||||
# OCM
|
||||
|
||||
OCM (OpenAI Codex Multiplexer) service is a multiplexing service that allows you to access your local OpenAI Codex subscription remotely through custom tokens.
|
||||
|
||||
It handles OAuth authentication with OpenAI's API on your local machine while allowing remote clients to authenticate using custom tokens.
|
||||
|
||||
### Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ocm",
|
||||
|
||||
... // Listen Fields
|
||||
|
||||
"credential_path": "",
|
||||
"usages_path": "",
|
||||
"users": [],
|
||||
"headers": {},
|
||||
"detour": "",
|
||||
"tls": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Listen Fields
|
||||
|
||||
See [Listen Fields](/configuration/shared/listen/) for details.
|
||||
|
||||
### Fields
|
||||
|
||||
#### credential_path
|
||||
|
||||
Path to the OpenAI OAuth credentials file.
|
||||
|
||||
If not specified, defaults to `~/.codex/auth.json`.
|
||||
|
||||
Refreshed tokens are automatically written back to the same location.
|
||||
|
||||
#### usages_path
|
||||
|
||||
Path to the file for storing aggregated API usage statistics.
|
||||
|
||||
Usage tracking is disabled if not specified.
|
||||
|
||||
When enabled, the service tracks and saves comprehensive statistics including:
|
||||
- Request counts
|
||||
- Token usage (input, output, cached)
|
||||
- Calculated costs in USD based on OpenAI API pricing
|
||||
|
||||
Statistics are organized by model and optionally by user when authentication is enabled.
|
||||
|
||||
The statistics file is automatically saved every minute and upon service shutdown.
|
||||
|
||||
#### users
|
||||
|
||||
List of authorized users for token authentication.
|
||||
|
||||
If empty, no authentication is required.
|
||||
|
||||
Object format:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "",
|
||||
"token": ""
|
||||
}
|
||||
```
|
||||
|
||||
Object fields:
|
||||
|
||||
- `name`: Username identifier for tracking purposes.
|
||||
- `token`: Bearer token for authentication. Clients authenticate by setting the `Authorization: Bearer <token>` header.
|
||||
|
||||
#### headers
|
||||
|
||||
Custom HTTP headers to send to the OpenAI API.
|
||||
|
||||
These headers will override any existing headers with the same name.
|
||||
|
||||
#### detour
|
||||
|
||||
Outbound tag for connecting to the OpenAI API.
|
||||
|
||||
#### tls
|
||||
|
||||
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
|
||||
|
||||
### Example
|
||||
|
||||
#### Server
|
||||
|
||||
```json
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"type": "ocm",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": 8080
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Client
|
||||
|
||||
Add to `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[model_providers.ocm]
|
||||
name = "OCM Proxy"
|
||||
base_url = "http://127.0.0.1:8080/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
codex --model-provider ocm
|
||||
```
|
||||
|
||||
### Example with Authentication
|
||||
|
||||
#### Server
|
||||
|
||||
```json
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"type": "ocm",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 8080,
|
||||
"usages_path": "./codex-usages.json",
|
||||
"users": [
|
||||
{
|
||||
"name": "alice",
|
||||
"token": "sk-alice-secret-token"
|
||||
},
|
||||
{
|
||||
"name": "bob",
|
||||
"token": "sk-bob-secret-token"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Client
|
||||
|
||||
Add to `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[model_providers.ocm]
|
||||
name = "OCM Proxy"
|
||||
base_url = "http://127.0.0.1:8080/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-alice-secret-token"
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
codex --model-provider ocm
|
||||
```
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
icon: material/new-box
|
||||
---
|
||||
|
||||
!!! question "自 sing-box 1.13.0 起"
|
||||
|
||||
# OCM
|
||||
|
||||
OCM(OpenAI Codex 多路复用器)服务是一个多路复用服务,允许您通过自定义令牌远程访问本地的 OpenAI Codex 订阅。
|
||||
|
||||
它在本地机器上处理与 OpenAI API 的 OAuth 身份验证,同时允许远程客户端使用自定义令牌进行身份验证。
|
||||
|
||||
### 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ocm",
|
||||
|
||||
... // 监听字段
|
||||
|
||||
"credential_path": "",
|
||||
"usages_path": "",
|
||||
"users": [],
|
||||
"headers": {},
|
||||
"detour": "",
|
||||
"tls": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 监听字段
|
||||
|
||||
参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。
|
||||
|
||||
### 字段
|
||||
|
||||
#### credential_path
|
||||
|
||||
OpenAI OAuth 凭据文件的路径。
|
||||
|
||||
如果未指定,默认值为 `~/.codex/auth.json`。
|
||||
|
||||
刷新的令牌会自动写回相同位置。
|
||||
|
||||
#### usages_path
|
||||
|
||||
用于存储聚合 API 使用统计信息的文件路径。
|
||||
|
||||
如果未指定,使用跟踪将被禁用。
|
||||
|
||||
启用后,服务会跟踪并保存全面的统计信息,包括:
|
||||
- 请求计数
|
||||
- 令牌使用量(输入、输出、缓存)
|
||||
- 基于 OpenAI API 定价计算的美元成本
|
||||
|
||||
统计信息按模型以及可选的用户(启用身份验证时)进行组织。
|
||||
|
||||
统计文件每分钟自动保存一次,并在服务关闭时保存。
|
||||
|
||||
#### users
|
||||
|
||||
用于令牌身份验证的授权用户列表。
|
||||
|
||||
如果为空,则不需要身份验证。
|
||||
|
||||
对象格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "",
|
||||
"token": ""
|
||||
}
|
||||
```
|
||||
|
||||
对象字段:
|
||||
|
||||
- `name`:用于跟踪的用户名标识符。
|
||||
- `token`:用于身份验证的 Bearer 令牌。客户端通过设置 `Authorization: Bearer <token>` 头进行身份验证。
|
||||
|
||||
#### headers
|
||||
|
||||
发送到 OpenAI API 的自定义 HTTP 头。
|
||||
|
||||
这些头会覆盖同名的现有头。
|
||||
|
||||
#### detour
|
||||
|
||||
用于连接 OpenAI API 的出站标签。
|
||||
|
||||
#### tls
|
||||
|
||||
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
|
||||
|
||||
### 示例
|
||||
|
||||
#### 服务端
|
||||
|
||||
```json
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"type": "ocm",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": 8080
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 客户端
|
||||
|
||||
在 `~/.codex/config.toml` 中添加:
|
||||
|
||||
```toml
|
||||
[model_providers.ocm]
|
||||
name = "OCM Proxy"
|
||||
base_url = "http://127.0.0.1:8080/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
```
|
||||
|
||||
然后运行:
|
||||
|
||||
```bash
|
||||
codex --model-provider ocm
|
||||
```
|
||||
|
||||
### 带身份验证的示例
|
||||
|
||||
#### 服务端
|
||||
|
||||
```json
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"type": "ocm",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 8080,
|
||||
"usages_path": "./codex-usages.json",
|
||||
"users": [
|
||||
{
|
||||
"name": "alice",
|
||||
"token": "sk-alice-secret-token"
|
||||
},
|
||||
{
|
||||
"name": "bob",
|
||||
"token": "sk-bob-secret-token"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 客户端
|
||||
|
||||
在 `~/.codex/config.toml` 中添加:
|
||||
|
||||
```toml
|
||||
[model_providers.ocm]
|
||||
name = "OCM Proxy"
|
||||
base_url = "http://127.0.0.1:8080/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-alice-secret-token"
|
||||
```
|
||||
|
||||
然后运行:
|
||||
|
||||
```bash
|
||||
codex --model-provider ocm
|
||||
```
|
||||
+26
-25
@@ -21,12 +21,13 @@ require (
|
||||
github.com/metacubex/utls v1.8.3
|
||||
github.com/mholt/acmez/v3 v3.1.2
|
||||
github.com/miekg/dns v1.1.67
|
||||
github.com/openai/openai-go/v3 v3.13.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
|
||||
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
|
||||
github.com/sagernet/cors v1.2.1
|
||||
github.com/sagernet/cronet-go v0.0.0-20251215064722-77bfb8fdd9f7
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20251215064722-77bfb8fdd9f7
|
||||
github.com/sagernet/cronet-go v0.0.0-20251216133850-319203a5f9c0
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20251216133850-319203a5f9c0
|
||||
github.com/sagernet/fswatch v0.1.1
|
||||
github.com/sagernet/gomobile v0.1.10
|
||||
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
|
||||
@@ -107,29 +108,29 @@ require (
|
||||
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/safchain/ethtool v0.3.0 // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251215064325-26e9598ca37b // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251216133425-9f6a31f51e7f // indirect
|
||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
||||
github.com/sagernet/nftables v0.3.0-beta.4 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
|
||||
+52
-50
@@ -132,6 +132,8 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/openai/openai-go/v3 v3.13.0 h1:arSFmVHcBHNVYG5iqspPJrLoin0Qqn2JcCLWWcTcM1Q=
|
||||
github.com/openai/openai-go/v3 v3.13.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
@@ -152,56 +154,56 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk
|
||||
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
|
||||
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
||||
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
||||
github.com/sagernet/cronet-go v0.0.0-20251215064722-77bfb8fdd9f7 h1:jb/nr5YECJ56gcAphQ7tBWierrBbaLT7v1MI9n3e/Gw=
|
||||
github.com/sagernet/cronet-go v0.0.0-20251215064722-77bfb8fdd9f7/go.mod h1:DzcRxPQdpy5y2bbabpFXotAzPfY2P4HKZ8rQj3dSClo=
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20251215064722-77bfb8fdd9f7 h1:6PjoWjKnYrz/HmEezV1Z5K39EC8l+sek1V14aXlslyc=
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20251215064722-77bfb8fdd9f7/go.mod h1:SrXj1iQMVqZcy8XINBJOhlBncfCe7DimX6mTRY+rdDw=
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251215064325-26e9598ca37b h1:+Dk1yBvaKl49l8j3YFoEvraAdt7VMy7n2Qzrs40/ekI=
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251215064325-26e9598ca37b h1:tjkKLyRhD1ePdl48SjW38o7yjW1fCJ2x2nyvq5e/8oE=
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251215064325-26e9598ca37b h1:P++HSm1JhmkKbDskFNfQuR8aCTg5uEWe2/5qFfj+6YU=
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251215064325-26e9598ca37b/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251215064325-26e9598ca37b h1:Rbo1r5Mk8yWlZTC8gcyuQFv2BXUI1/wWMC9Vc+cJNQ8=
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251215064325-26e9598ca37b h1:VA1M5Yw09HiBD+Zemq6mOBVwBd4pr47LMN9WKOVf62Q=
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251215064325-26e9598ca37b h1:AdWIsXfKxH3/hGjiYqcUSc0fb+R4vONjfRaO0emwdNA=
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20251215064325-26e9598ca37b h1:sg8SupaVsj0Krc4DKSC1n2quig08bRtmsF0/iwwXeAI=
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20251215064325-26e9598ca37b/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251215064325-26e9598ca37b h1:0dmsm/vEAYxQjtH4sS/A8X6bf6YqS0I0Vc6oDZdnlRc=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20251215064325-26e9598ca37b h1:jnT/bYjzvdfGVgPEgZX0Mi0qkm8qcU/DluV+TqShVPg=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20251215064325-26e9598ca37b/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251215064325-26e9598ca37b h1:/NqFcrdXS3e3Ad+ILfrwXFw3urwwFsQ1XxrDW9PkU4E=
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251215064325-26e9598ca37b h1:vqeLRyeHq++RCcuUriJflTQne7hldEVJ19Or0xwCIrs=
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251215064325-26e9598ca37b/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251215064325-26e9598ca37b h1:Xr7dFoKy0o2YdPl2JcU7GtM4NxQyS8vGovd6Aw4pX8I=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251215064325-26e9598ca37b h1:GEt+x1qXt8xicDSD4GXOHs0WrVec5HAo+HmBAXzkidg=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251215064325-26e9598ca37b/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251215064325-26e9598ca37b h1:MbjH6TmLoXlAkBWoUzuNF2w0FPfOMY6Rj9T226fe858=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251215064325-26e9598ca37b/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251215064325-26e9598ca37b h1:AP85VNYiACL8QQeXqCUB8hz5hFOUtgwReLELRhve/4c=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251215064325-26e9598ca37b h1:4uNGGiOrJsa2S+PteucoO/Qyzz7FWHNJw2ezOkS7QiM=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251215064325-26e9598ca37b/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251215064325-26e9598ca37b h1:N5yoxOlynwvTgaJnEOsL3iuI6FFmDJy1toyNSU+vlLA=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251215064325-26e9598ca37b/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20251215064325-26e9598ca37b h1:JKyBNyt/DWAutvuDFjFTi0dMe0bh5zG7UUpZHH8Uqzo=
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20251215064325-26e9598ca37b/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20251215064325-26e9598ca37b h1:m0sCMM6ry0+eXBuTPLGY9JYOVcIvtHcDEcstMo+oSTU=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20251215064325-26e9598ca37b h1:UURnlFD48/9wn7cdi1NqYQuTvJZEFuQShxX8pvk2Fsg=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20251215064325-26e9598ca37b/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
|
||||
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251215064325-26e9598ca37b h1:jhwpI5IXK5RPvbk9+xUV9GAw2QeRZvcZprp4bJOP9e0=
|
||||
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:rnS7D+ULJX2PrP0Cy+05GS0mRZ2PP6+gVSroZKt8fjk=
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251215064325-26e9598ca37b h1:qoleSwhzgH6jDSwqktbJCPDex4yMWtijcouGR8+BL+s=
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251215064325-26e9598ca37b h1:v7eakED1u8ZTKjmqxa+Eu0S5ewK+r+mfEf9KI6ymu+I=
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251215064325-26e9598ca37b/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
|
||||
github.com/sagernet/cronet-go v0.0.0-20251216133850-319203a5f9c0 h1:LESmw76c/bzprJSDdAPpXTDLbZnir2w3TEX08uPD+Ls=
|
||||
github.com/sagernet/cronet-go v0.0.0-20251216133850-319203a5f9c0/go.mod h1:DzcRxPQdpy5y2bbabpFXotAzPfY2P4HKZ8rQj3dSClo=
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20251216133850-319203a5f9c0 h1:q2Y9oZBQyCyFV7hMdZXtpPabJWq1WiWL/eyKRbj2z28=
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20251216133850-319203a5f9c0/go.mod h1:ozG0O0AvB4rKe9A7twrqp4UvnExSzmbEhsdcmm14PwM=
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251216133425-9f6a31f51e7f h1:eXI9DXjk4WtcukZumm2EEihwvNswjcoe3oT9LrriOxw=
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251216133425-9f6a31f51e7f h1:AmjLqs5BiC8YPi900EB5LdEZK5kWmHg0MMmQbpT9WsM=
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251216133425-9f6a31f51e7f h1:ts10PNXwK+2HP15ushAYJVMtoUV7V9UAOr+8AMr00uw=
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251216133425-9f6a31f51e7f h1:dUd/75r4DC/P0374iXDyx7vPlCt7j13vuuCsoPhvJvg=
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251216133425-9f6a31f51e7f h1:Wfjf525uzg8TRV752sn6N7Ycw+Ee0Eorytd2cT7aVh0=
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251216133425-9f6a31f51e7f h1:y8KaKG9TgX5KPcUxzMu/hiPChoUNL8U17f92YWrV4zo=
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20251216133425-9f6a31f51e7f h1:mxy23kdWCNe0zLPC8+jw0kmvPNeC8WPRGA5O9BWvC0U=
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251216133425-9f6a31f51e7f h1:TNPhjBjjMBJTRIoK+0gqIjDks/5bJm47sGZ3bw8gCFs=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20251216133425-9f6a31f51e7f h1:WBSrJO0CiqWKS7sMstL0zJJVvt2L7TrmBmbj8vPz+gY=
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251216133425-9f6a31f51e7f h1:UOJuw1PUXmLGlVTdvr7U93/olD6CeDgFpkeKg7OdzbI=
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251216133425-9f6a31f51e7f h1:wfENhS38qymYlW0Ef9Y45tvPRAEyj5WFcQDVebg0Uqc=
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251216133425-9f6a31f51e7f h1:JMdNN9P8Pt65PKwUOrK6/ejXrosOPyAP7QdlA2X71E0=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251216133425-9f6a31f51e7f h1:6lyBrJKu+nQSiNdP1jWLKbhEnJ5pJ3Amcr96qAk/Wxk=
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251216133425-9f6a31f51e7f h1:9/7uFHDpI5ZGzLAVSro0dBGOVBxboIFsxtI2QIrf3CI=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251216133425-9f6a31f51e7f h1:3vgjnWxtlP2qqCP9A3kA4IRRr3AjDzF0C8NiB0qRpnY=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251216133425-9f6a31f51e7f h1:HXNSrz0zn5LgbdfLoIJb45YQY789vs8EfSjZwX6P46Y=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251216133425-9f6a31f51e7f h1:mEys4XlVs9Ewnj08PPkuMD+7Cd9+kWIe2UAvhBDc4t4=
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20251216133425-9f6a31f51e7f h1:Jsqlc8k52ZsOF6nd/Pq7jh9MhZUEh6wxbabL8Y5CilU=
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20251216133425-9f6a31f51e7f h1:wP4nDfGDca5JvqTj1mpqu3CMfMahsmIY1vPyALTtqXI=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20251216133425-9f6a31f51e7f h1:psbgr8u0fuUwH95k8+Z4HfMcyOevAXARGRLdfexsldo=
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
|
||||
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251216133425-9f6a31f51e7f h1:xzfMiwWj9cyJWDr0WWC6xkZ14RWo/XqEQrJlJm6MZag=
|
||||
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:rnS7D+ULJX2PrP0Cy+05GS0mRZ2PP6+gVSroZKt8fjk=
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251216133425-9f6a31f51e7f h1:AxA2aJ5IcEUkAgsEDeu6ICHd01F+Kn61bXKfZB4uKTk=
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251216133425-9f6a31f51e7f h1:M21bGiwCgCY2rl0huKXo36eV0D4ep3ZgRPh6BKgvGh0=
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251216133425-9f6a31f51e7f/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
|
||||
github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
|
||||
github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
|
||||
github.com/sagernet/gomobile v0.1.10 h1:ElqZ0OVDvyQlU91MU0C9cfU0FrILBbc65+NOKzZ1t0c=
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build with_ocm
|
||||
|
||||
package include
|
||||
|
||||
import (
|
||||
"github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/service/ocm"
|
||||
)
|
||||
|
||||
func registerOCMService(registry *service.Registry) {
|
||||
ocm.RegisterService(registry)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !with_ocm
|
||||
|
||||
package include
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/service"
|
||||
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"
|
||||
)
|
||||
|
||||
func registerOCMService(registry *service.Registry) {
|
||||
service.Register[option.OCMServiceOptions](registry, C.TypeOCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.OCMServiceOptions) (adapter.Service, error) {
|
||||
return nil, E.New(`OCM is not included in this build, rebuild with -tags with_ocm`)
|
||||
})
|
||||
}
|
||||
@@ -136,6 +136,7 @@ func ServiceRegistry() *service.Registry {
|
||||
|
||||
registerDERPService(registry)
|
||||
registerCCMService(registry)
|
||||
registerOCMService(registry)
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
@@ -176,6 +176,8 @@ nav:
|
||||
- DERP: configuration/service/derp.md
|
||||
- Resolved: configuration/service/resolved.md
|
||||
- SSM API: configuration/service/ssm-api.md
|
||||
- CCM: configuration/service/ccm.md
|
||||
- OCM: configuration/service/ocm.md
|
||||
markdown_extensions:
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package option
|
||||
|
||||
import (
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
)
|
||||
|
||||
type OCMServiceOptions struct {
|
||||
ListenOptions
|
||||
InboundTLSOptionsContainer
|
||||
CredentialPath string `json:"credential_path,omitempty"`
|
||||
Users []OCMUser `json:"users,omitempty"`
|
||||
Headers badoption.HTTPHeader `json:"headers,omitempty"`
|
||||
Detour string `json:"detour,omitempty"`
|
||||
UsagesPath string `json:"usages_path,omitempty"`
|
||||
}
|
||||
|
||||
type OCMUser struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
@@ -11,7 +11,7 @@ INSTALL_CONFIG_PATH="/usr/local/etc/sing-box"
|
||||
INSTALL_DATA_PATH="/var/lib/sing-box"
|
||||
SYSTEMD_SERVICE_PATH="/etc/systemd/system"
|
||||
|
||||
DEFAULT_BUILD_TAGS="with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0"
|
||||
DEFAULT_BUILD_TAGS="with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0"
|
||||
|
||||
setup_environment() {
|
||||
if [ -d /usr/local/go ]; then
|
||||
|
||||
@@ -124,12 +124,69 @@ var (
|
||||
CacheWritePrice: 3.75,
|
||||
}
|
||||
|
||||
opus45Pricing = ModelPricing{
|
||||
InputPrice: 5.0,
|
||||
OutputPrice: 25.0,
|
||||
CacheReadPrice: 0.5,
|
||||
CacheWritePrice: 6.25,
|
||||
}
|
||||
|
||||
sonnet45StandardPricing = ModelPricing{
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
CacheReadPrice: 0.3,
|
||||
CacheWritePrice: 3.75,
|
||||
}
|
||||
|
||||
sonnet45PremiumPricing = ModelPricing{
|
||||
InputPrice: 6.0,
|
||||
OutputPrice: 22.5,
|
||||
CacheReadPrice: 0.6,
|
||||
CacheWritePrice: 7.5,
|
||||
}
|
||||
|
||||
haiku45Pricing = ModelPricing{
|
||||
InputPrice: 1.0,
|
||||
OutputPrice: 5.0,
|
||||
CacheReadPrice: 0.1,
|
||||
CacheWritePrice: 1.25,
|
||||
}
|
||||
|
||||
haiku3Pricing = ModelPricing{
|
||||
InputPrice: 0.25,
|
||||
OutputPrice: 1.25,
|
||||
CacheReadPrice: 0.03,
|
||||
CacheWritePrice: 0.3,
|
||||
}
|
||||
|
||||
opus3Pricing = ModelPricing{
|
||||
InputPrice: 15.0,
|
||||
OutputPrice: 75.0,
|
||||
CacheReadPrice: 1.5,
|
||||
CacheWritePrice: 18.75,
|
||||
}
|
||||
|
||||
modelFamilies = []modelFamily{
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-opus-4-5-`),
|
||||
standardPricing: opus45Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:opus-4-|4-opus-|opus-4-1-)`),
|
||||
standardPricing: opus4Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:opus-3-|3-opus-)`),
|
||||
standardPricing: opus3Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:sonnet-4-5-|4-5-sonnet-)`),
|
||||
standardPricing: sonnet45StandardPricing,
|
||||
premiumPricing: &sonnet45PremiumPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-7-sonnet-`),
|
||||
standardPricing: sonnet4StandardPricing,
|
||||
@@ -140,6 +197,16 @@ var (
|
||||
standardPricing: sonnet4StandardPricing,
|
||||
premiumPricing: &sonnet4PremiumPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-5-sonnet-`),
|
||||
standardPricing: sonnet35Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:haiku-4-5-|4-5-haiku-)`),
|
||||
standardPricing: haiku45Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-haiku-4-`),
|
||||
standardPricing: haiku4Pricing,
|
||||
@@ -151,8 +218,8 @@ var (
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-5-sonnet-`),
|
||||
standardPricing: sonnet35Pricing,
|
||||
pattern: regexp.MustCompile(`^claude-3-haiku-`),
|
||||
standardPricing: haiku3Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package ocm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
const (
|
||||
oauth2ClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
oauth2TokenURL = "https://auth.openai.com/oauth/token"
|
||||
openaiAPIBaseURL = "https://api.openai.com"
|
||||
chatGPTBackendURL = "https://chatgpt.com/backend-api/codex"
|
||||
tokenRefreshIntervalDays = 8
|
||||
)
|
||||
|
||||
func getRealUser() (*user.User, error) {
|
||||
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
||||
sudoUserInfo, err := user.Lookup(sudoUser)
|
||||
if err == nil {
|
||||
return sudoUserInfo, nil
|
||||
}
|
||||
}
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
func getDefaultCredentialsPath() (string, error) {
|
||||
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
|
||||
return filepath.Join(codexHome, "auth.json"), nil
|
||||
}
|
||||
userInfo, err := getRealUser()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(userInfo.HomeDir, ".codex", "auth.json"), nil
|
||||
}
|
||||
|
||||
func readCredentialsFromFile(path string) (*oauthCredentials, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var credentials oauthCredentials
|
||||
err = json.Unmarshal(data, &credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &credentials, nil
|
||||
}
|
||||
|
||||
func writeCredentialsToFile(credentials *oauthCredentials, path string) error {
|
||||
data, err := json.MarshalIndent(credentials, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
type oauthCredentials struct {
|
||||
APIKey string `json:"OPENAI_API_KEY,omitempty"`
|
||||
Tokens *tokenData `json:"tokens,omitempty"`
|
||||
LastRefresh *time.Time `json:"last_refresh,omitempty"`
|
||||
}
|
||||
|
||||
type tokenData struct {
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c *oauthCredentials) isAPIKeyMode() bool {
|
||||
return c.APIKey != ""
|
||||
}
|
||||
|
||||
func (c *oauthCredentials) getAccessToken() string {
|
||||
if c.APIKey != "" {
|
||||
return c.APIKey
|
||||
}
|
||||
if c.Tokens != nil {
|
||||
return c.Tokens.AccessToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *oauthCredentials) getAccountID() string {
|
||||
if c.Tokens != nil {
|
||||
return c.Tokens.AccountID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *oauthCredentials) needsRefresh() bool {
|
||||
if c.APIKey != "" {
|
||||
return false
|
||||
}
|
||||
if c.Tokens == nil || c.Tokens.RefreshToken == "" {
|
||||
return false
|
||||
}
|
||||
if c.LastRefresh == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(*c.LastRefresh) >= time.Duration(tokenRefreshIntervalDays)*24*time.Hour
|
||||
}
|
||||
|
||||
func refreshToken(httpClient *http.Client, credentials *oauthCredentials) (*oauthCredentials, error) {
|
||||
if credentials.Tokens == nil || credentials.Tokens.RefreshToken == "" {
|
||||
return nil, E.New("refresh token is empty")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": credentials.Tokens.RefreshToken,
|
||||
"client_id": oauth2ClientID,
|
||||
"scope": "openid profile email",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "marshal request")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("POST", oauth2TokenURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, E.New("refresh failed: ", response.Status, " ", string(body))
|
||||
}
|
||||
|
||||
var tokenResponse struct {
|
||||
IDToken string `json:"id_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
err = json.NewDecoder(response.Body).Decode(&tokenResponse)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "decode response")
|
||||
}
|
||||
|
||||
newCredentials := *credentials
|
||||
if newCredentials.Tokens == nil {
|
||||
newCredentials.Tokens = &tokenData{}
|
||||
}
|
||||
if tokenResponse.IDToken != "" {
|
||||
newCredentials.Tokens.IDToken = tokenResponse.IDToken
|
||||
}
|
||||
if tokenResponse.AccessToken != "" {
|
||||
newCredentials.Tokens.AccessToken = tokenResponse.AccessToken
|
||||
}
|
||||
if tokenResponse.RefreshToken != "" {
|
||||
newCredentials.Tokens.RefreshToken = tokenResponse.RefreshToken
|
||||
}
|
||||
now := time.Now()
|
||||
newCredentials.LastRefresh = &now
|
||||
|
||||
return &newCredentials, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build darwin
|
||||
|
||||
package ocm
|
||||
|
||||
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return readCredentialsFromFile(customPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(credentials *oauthCredentials, customPath string) error {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeCredentialsToFile(credentials, customPath)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !darwin
|
||||
|
||||
package ocm
|
||||
|
||||
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return readCredentialsFromFile(customPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(credentials *oauthCredentials, customPath string) error {
|
||||
if customPath == "" {
|
||||
var err error
|
||||
customPath, err = getDefaultCredentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeCredentialsToFile(credentials, customPath)
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package ocm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing-box/common/listener"
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
aTLS "github.com/sagernet/sing/common/tls"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/responses"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func RegisterService(registry *boxService.Registry) {
|
||||
boxService.Register[option.OCMServiceOptions](registry, C.TypeOCM, NewService)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorDetails `json:"error"`
|
||||
}
|
||||
|
||||
type errorDetails struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, r *http.Request, statusCode int, errorType string, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
json.NewEncoder(w).Encode(errorResponse{
|
||||
Error: errorDetails{
|
||||
Type: errorType,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func isHopByHopHeader(header string) bool {
|
||||
switch strings.ToLower(header) {
|
||||
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
boxService.Adapter
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
credentialPath string
|
||||
credentials *oauthCredentials
|
||||
users []option.OCMUser
|
||||
httpClient *http.Client
|
||||
httpHeaders http.Header
|
||||
listener *listener.Listener
|
||||
tlsConfig tls.ServerConfig
|
||||
httpServer *http.Server
|
||||
userManager *UserManager
|
||||
accessMutex sync.RWMutex
|
||||
usageTracker *AggregatedUsage
|
||||
trackingGroup sync.WaitGroup
|
||||
shuttingDown bool
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OCMServiceOptions) (adapter.Service, error) {
|
||||
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: option.DialerOptions{
|
||||
Detour: options.Detour,
|
||||
},
|
||||
RemoteIsDomain: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create dialer")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
userManager := &UserManager{
|
||||
tokenMap: make(map[string]string),
|
||||
}
|
||||
|
||||
var usageTracker *AggregatedUsage
|
||||
if options.UsagesPath != "" {
|
||||
usageTracker = &AggregatedUsage{
|
||||
LastUpdated: time.Now(),
|
||||
Combinations: make([]CostCombination, 0),
|
||||
filePath: options.UsagesPath,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
Adapter: boxService.NewAdapter(C.TypeOCM, tag),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
credentialPath: options.CredentialPath,
|
||||
users: options.Users,
|
||||
httpClient: httpClient,
|
||||
httpHeaders: options.Headers.Build(),
|
||||
listener: listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
Network: []string{N.NetworkTCP},
|
||||
Listen: options.ListenOptions,
|
||||
}),
|
||||
userManager: userManager,
|
||||
usageTracker: usageTracker,
|
||||
}
|
||||
|
||||
if options.TLS != nil {
|
||||
tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service.tlsConfig = tlsConfig
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.userManager.UpdateUsers(s.users)
|
||||
|
||||
credentials, err := platformReadCredentials(s.credentialPath)
|
||||
if err != nil {
|
||||
return E.Cause(err, "read credentials")
|
||||
}
|
||||
s.credentials = credentials
|
||||
|
||||
if s.usageTracker != nil {
|
||||
err = s.usageTracker.Load()
|
||||
if err != nil {
|
||||
s.logger.Warn("load usage statistics: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/", s)
|
||||
|
||||
s.httpServer = &http.Server{Handler: router}
|
||||
|
||||
if s.tlsConfig != nil {
|
||||
err = s.tlsConfig.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create TLS config")
|
||||
}
|
||||
}
|
||||
|
||||
tcpListener, err := s.listener.ListenTCP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.tlsConfig != nil {
|
||||
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
|
||||
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
|
||||
}
|
||||
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
|
||||
}
|
||||
|
||||
go func() {
|
||||
serveErr := s.httpServer.Serve(tcpListener)
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
s.logger.Error("serve error: ", serveErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getAccessToken() (string, error) {
|
||||
s.accessMutex.RLock()
|
||||
if !s.credentials.needsRefresh() {
|
||||
token := s.credentials.getAccessToken()
|
||||
s.accessMutex.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
s.accessMutex.RUnlock()
|
||||
|
||||
s.accessMutex.Lock()
|
||||
defer s.accessMutex.Unlock()
|
||||
|
||||
if !s.credentials.needsRefresh() {
|
||||
return s.credentials.getAccessToken(), nil
|
||||
}
|
||||
|
||||
newCredentials, err := refreshToken(s.httpClient, s.credentials)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.credentials = newCredentials
|
||||
|
||||
err = platformWriteCredentials(newCredentials, s.credentialPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("persist refreshed token: ", err)
|
||||
}
|
||||
|
||||
return newCredentials.getAccessToken(), nil
|
||||
}
|
||||
|
||||
func (s *Service) getAccountID() string {
|
||||
s.accessMutex.RLock()
|
||||
defer s.accessMutex.RUnlock()
|
||||
return s.credentials.getAccountID()
|
||||
}
|
||||
|
||||
func (s *Service) isAPIKeyMode() bool {
|
||||
s.accessMutex.RLock()
|
||||
defer s.accessMutex.RUnlock()
|
||||
return s.credentials.isAPIKeyMode()
|
||||
}
|
||||
|
||||
func (s *Service) getBaseURL() string {
|
||||
if s.isAPIKeyMode() {
|
||||
return openaiAPIBaseURL
|
||||
}
|
||||
return chatGPTBackendURL
|
||||
}
|
||||
|
||||
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
writeJSONError(w, r, http.StatusNotFound, "invalid_request_error", "path must start with /v1/")
|
||||
return
|
||||
}
|
||||
|
||||
var proxyPath string
|
||||
if s.isAPIKeyMode() {
|
||||
proxyPath = path
|
||||
} else {
|
||||
if path == "/v1/chat/completions" {
|
||||
writeJSONError(w, r, http.StatusBadRequest, "invalid_request_error",
|
||||
"chat completions endpoint is only available in API key mode")
|
||||
return
|
||||
}
|
||||
proxyPath = strings.TrimPrefix(path, "/v1")
|
||||
}
|
||||
|
||||
var username string
|
||||
if len(s.users) > 0 {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": missing Authorization header")
|
||||
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "missing api key")
|
||||
return
|
||||
}
|
||||
clientToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if clientToken == authHeader {
|
||||
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": invalid Authorization format")
|
||||
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key format")
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
username, ok = s.userManager.Authenticate(clientToken)
|
||||
if !ok {
|
||||
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": unknown key: ", clientToken)
|
||||
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var requestModel string
|
||||
|
||||
if s.usageTracker != nil && r.Body != nil {
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err == nil {
|
||||
var request struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
err := json.Unmarshal(bodyBytes, &request)
|
||||
if err == nil {
|
||||
requestModel = request.Model
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
accessToken, err := s.getAccessToken()
|
||||
if err != nil {
|
||||
s.logger.Error("get access token: ", err)
|
||||
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "Authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
proxyURL := s.getBaseURL() + proxyPath
|
||||
if r.URL.RawQuery != "" {
|
||||
proxyURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
proxyRequest, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body)
|
||||
if err != nil {
|
||||
s.logger.Error("create proxy request: ", err)
|
||||
writeJSONError(w, r, http.StatusInternalServerError, "api_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range r.Header {
|
||||
if !isHopByHopHeader(key) && key != "Authorization" {
|
||||
proxyRequest.Header[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
for key, values := range s.httpHeaders {
|
||||
proxyRequest.Header.Del(key)
|
||||
proxyRequest.Header[key] = values
|
||||
}
|
||||
|
||||
proxyRequest.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
if accountID := s.getAccountID(); accountID != "" {
|
||||
proxyRequest.Header.Set("ChatGPT-Account-Id", accountID)
|
||||
}
|
||||
|
||||
response, err := s.httpClient.Do(proxyRequest)
|
||||
if err != nil {
|
||||
writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error())
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
for key, values := range response.Header {
|
||||
if !isHopByHopHeader(key) {
|
||||
w.Header()[key] = values
|
||||
}
|
||||
}
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
trackUsage := s.usageTracker != nil && response.StatusCode == http.StatusOK &&
|
||||
(path == "/v1/chat/completions" || strings.HasPrefix(path, "/v1/responses"))
|
||||
if trackUsage {
|
||||
s.handleResponseWithTracking(w, response, path, requestModel, username)
|
||||
} else {
|
||||
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||
if err == nil && mediaType != "text/event-stream" {
|
||||
_, _ = io.Copy(w, response.Body)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.logger.Error("streaming not supported")
|
||||
return
|
||||
}
|
||||
buffer := make([]byte, buf.BufferSize)
|
||||
for {
|
||||
n, err := response.Body.Read(buffer)
|
||||
if n > 0 {
|
||||
_, writeError := w.Write(buffer[:n])
|
||||
if writeError != nil {
|
||||
s.logger.Error("write streaming response: ", writeError)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, response *http.Response, path string, requestModel string, username string) {
|
||||
isChatCompletions := path == "/v1/chat/completions"
|
||||
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||
isStreaming := err == nil && mediaType == "text/event-stream"
|
||||
|
||||
if !isStreaming {
|
||||
bodyBytes, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
s.logger.Error("read response body: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
var responseModel string
|
||||
var inputTokens, outputTokens, cachedTokens int64
|
||||
|
||||
if isChatCompletions {
|
||||
var chatCompletion openai.ChatCompletion
|
||||
if json.Unmarshal(bodyBytes, &chatCompletion) == nil {
|
||||
responseModel = chatCompletion.Model
|
||||
inputTokens = chatCompletion.Usage.PromptTokens
|
||||
outputTokens = chatCompletion.Usage.CompletionTokens
|
||||
cachedTokens = chatCompletion.Usage.PromptTokensDetails.CachedTokens
|
||||
}
|
||||
} else {
|
||||
var responsesResponse responses.Response
|
||||
if json.Unmarshal(bodyBytes, &responsesResponse) == nil {
|
||||
responseModel = string(responsesResponse.Model)
|
||||
inputTokens = responsesResponse.Usage.InputTokens
|
||||
outputTokens = responsesResponse.Usage.OutputTokens
|
||||
cachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
|
||||
}
|
||||
}
|
||||
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
if responseModel == "" {
|
||||
responseModel = requestModel
|
||||
}
|
||||
if responseModel != "" {
|
||||
s.usageTracker.AddUsage(responseModel, inputTokens, outputTokens, cachedTokens, username)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = writer.Write(bodyBytes)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := writer.(http.Flusher)
|
||||
if !ok {
|
||||
s.logger.Error("streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
var inputTokens, outputTokens, cachedTokens int64
|
||||
var responseModel string
|
||||
buffer := make([]byte, buf.BufferSize)
|
||||
var leftover []byte
|
||||
|
||||
for {
|
||||
n, err := response.Body.Read(buffer)
|
||||
if n > 0 {
|
||||
data := append(leftover, buffer[:n]...)
|
||||
lines := bytes.Split(data, []byte("\n"))
|
||||
|
||||
if err == nil {
|
||||
leftover = lines[len(lines)-1]
|
||||
lines = lines[:len(lines)-1]
|
||||
} else {
|
||||
leftover = nil
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(line, []byte("data: ")) {
|
||||
eventData := bytes.TrimPrefix(line, []byte("data: "))
|
||||
if bytes.Equal(eventData, []byte("[DONE]")) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isChatCompletions {
|
||||
var chatChunk openai.ChatCompletionChunk
|
||||
if json.Unmarshal(eventData, &chatChunk) == nil {
|
||||
if chatChunk.Model != "" {
|
||||
responseModel = chatChunk.Model
|
||||
}
|
||||
if chatChunk.Usage.PromptTokens > 0 {
|
||||
inputTokens = chatChunk.Usage.PromptTokens
|
||||
cachedTokens = chatChunk.Usage.PromptTokensDetails.CachedTokens
|
||||
}
|
||||
if chatChunk.Usage.CompletionTokens > 0 {
|
||||
outputTokens = chatChunk.Usage.CompletionTokens
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var streamEvent responses.ResponseStreamEventUnion
|
||||
if json.Unmarshal(eventData, &streamEvent) == nil {
|
||||
if streamEvent.Type == "response.completed" {
|
||||
completedEvent := streamEvent.AsResponseCompleted()
|
||||
if string(completedEvent.Response.Model) != "" {
|
||||
responseModel = string(completedEvent.Response.Model)
|
||||
}
|
||||
if completedEvent.Response.Usage.InputTokens > 0 {
|
||||
inputTokens = completedEvent.Response.Usage.InputTokens
|
||||
cachedTokens = completedEvent.Response.Usage.InputTokensDetails.CachedTokens
|
||||
}
|
||||
if completedEvent.Response.Usage.OutputTokens > 0 {
|
||||
outputTokens = completedEvent.Response.Usage.OutputTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, writeError := writer.Write(buffer[:n])
|
||||
if writeError != nil {
|
||||
s.logger.Error("write streaming response: ", writeError)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if responseModel == "" {
|
||||
responseModel = requestModel
|
||||
}
|
||||
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
if responseModel != "" {
|
||||
s.usageTracker.AddUsage(responseModel, inputTokens, outputTokens, cachedTokens, username)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
err := common.Close(
|
||||
common.PtrOrNil(s.httpServer),
|
||||
common.PtrOrNil(s.listener),
|
||||
s.tlsConfig,
|
||||
)
|
||||
|
||||
if s.usageTracker != nil {
|
||||
s.usageTracker.cancelPendingSave()
|
||||
saveErr := s.usageTracker.Save()
|
||||
if saveErr != nil {
|
||||
s.logger.Error("save usage statistics: ", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package ocm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
type UsageStats struct {
|
||||
RequestCount int `json:"request_count"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
func (u *UsageStats) UnmarshalJSON(data []byte) error {
|
||||
type Alias UsageStats
|
||||
aux := &struct {
|
||||
*Alias
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
}{
|
||||
Alias: (*Alias)(u),
|
||||
}
|
||||
err := json.Unmarshal(data, aux)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.InputTokens == 0 && aux.PromptTokens > 0 {
|
||||
u.InputTokens = aux.PromptTokens
|
||||
}
|
||||
if u.OutputTokens == 0 && aux.CompletionTokens > 0 {
|
||||
u.OutputTokens = aux.CompletionTokens
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CostCombination struct {
|
||||
Model string `json:"model"`
|
||||
Total UsageStats `json:"total"`
|
||||
ByUser map[string]UsageStats `json:"by_user"`
|
||||
}
|
||||
|
||||
type AggregatedUsage struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Combinations []CostCombination `json:"combinations"`
|
||||
mutex sync.Mutex
|
||||
filePath string
|
||||
logger log.ContextLogger
|
||||
lastSaveTime time.Time
|
||||
pendingSave bool
|
||||
saveTimer *time.Timer
|
||||
saveMutex sync.Mutex
|
||||
}
|
||||
|
||||
type UsageStatsJSON struct {
|
||||
RequestCount int `json:"request_count"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
}
|
||||
|
||||
type CostCombinationJSON struct {
|
||||
Model string `json:"model"`
|
||||
Total UsageStatsJSON `json:"total"`
|
||||
ByUser map[string]UsageStatsJSON `json:"by_user"`
|
||||
}
|
||||
|
||||
type CostsSummaryJSON struct {
|
||||
TotalUSD float64 `json:"total_usd"`
|
||||
ByUser map[string]float64 `json:"by_user"`
|
||||
}
|
||||
|
||||
type AggregatedUsageJSON struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Costs CostsSummaryJSON `json:"costs"`
|
||||
Combinations []CostCombinationJSON `json:"combinations"`
|
||||
}
|
||||
|
||||
type ModelPricing struct {
|
||||
InputPrice float64
|
||||
OutputPrice float64
|
||||
CachedInputPrice float64
|
||||
}
|
||||
|
||||
type modelFamily struct {
|
||||
pattern *regexp.Regexp
|
||||
pricing ModelPricing
|
||||
}
|
||||
|
||||
var (
|
||||
gpt4oPricing = ModelPricing{
|
||||
InputPrice: 2.5,
|
||||
OutputPrice: 10.0,
|
||||
CachedInputPrice: 1.25,
|
||||
}
|
||||
|
||||
gpt4oMiniPricing = ModelPricing{
|
||||
InputPrice: 0.15,
|
||||
OutputPrice: 0.6,
|
||||
CachedInputPrice: 0.075,
|
||||
}
|
||||
|
||||
gpt4oAudioPricing = ModelPricing{
|
||||
InputPrice: 2.5,
|
||||
OutputPrice: 10.0,
|
||||
CachedInputPrice: 1.25,
|
||||
}
|
||||
|
||||
o1Pricing = ModelPricing{
|
||||
InputPrice: 15.0,
|
||||
OutputPrice: 60.0,
|
||||
CachedInputPrice: 7.5,
|
||||
}
|
||||
|
||||
o1MiniPricing = ModelPricing{
|
||||
InputPrice: 1.1,
|
||||
OutputPrice: 4.4,
|
||||
CachedInputPrice: 0.55,
|
||||
}
|
||||
|
||||
o3MiniPricing = ModelPricing{
|
||||
InputPrice: 1.1,
|
||||
OutputPrice: 4.4,
|
||||
CachedInputPrice: 0.55,
|
||||
}
|
||||
|
||||
o3Pricing = ModelPricing{
|
||||
InputPrice: 2.0,
|
||||
OutputPrice: 8.0,
|
||||
CachedInputPrice: 1.0,
|
||||
}
|
||||
|
||||
o4MiniPricing = ModelPricing{
|
||||
InputPrice: 1.1,
|
||||
OutputPrice: 4.4,
|
||||
CachedInputPrice: 0.55,
|
||||
}
|
||||
|
||||
gpt41Pricing = ModelPricing{
|
||||
InputPrice: 2.0,
|
||||
OutputPrice: 8.0,
|
||||
CachedInputPrice: 0.5,
|
||||
}
|
||||
|
||||
gpt41MiniPricing = ModelPricing{
|
||||
InputPrice: 0.4,
|
||||
OutputPrice: 1.6,
|
||||
CachedInputPrice: 0.1,
|
||||
}
|
||||
|
||||
gpt41NanoPricing = ModelPricing{
|
||||
InputPrice: 0.1,
|
||||
OutputPrice: 0.4,
|
||||
CachedInputPrice: 0.025,
|
||||
}
|
||||
|
||||
modelFamilies = []modelFamily{
|
||||
{
|
||||
pattern: regexp.MustCompile(`^gpt-4\.1-nano`),
|
||||
pricing: gpt41NanoPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^gpt-4\.1-mini`),
|
||||
pricing: gpt41MiniPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^gpt-4\.1`),
|
||||
pricing: gpt41Pricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^o4-mini`),
|
||||
pricing: o4MiniPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^o3-mini`),
|
||||
pricing: o3MiniPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^o3`),
|
||||
pricing: o3Pricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^o1-mini`),
|
||||
pricing: o1MiniPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^o1`),
|
||||
pricing: o1Pricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^gpt-4o-audio`),
|
||||
pricing: gpt4oAudioPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^gpt-4o-mini`),
|
||||
pricing: gpt4oMiniPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^gpt-4o`),
|
||||
pricing: gpt4oPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^chatgpt-4o`),
|
||||
pricing: gpt4oPricing,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func getPricing(model string) ModelPricing {
|
||||
for _, family := range modelFamilies {
|
||||
if family.pattern.MatchString(model) {
|
||||
return family.pricing
|
||||
}
|
||||
}
|
||||
return gpt4oPricing
|
||||
}
|
||||
|
||||
func calculateCost(stats UsageStats, model string) float64 {
|
||||
pricing := getPricing(model)
|
||||
|
||||
regularInputTokens := stats.InputTokens - stats.CachedTokens
|
||||
if regularInputTokens < 0 {
|
||||
regularInputTokens = 0
|
||||
}
|
||||
|
||||
cost := (float64(regularInputTokens)*pricing.InputPrice +
|
||||
float64(stats.OutputTokens)*pricing.OutputPrice +
|
||||
float64(stats.CachedTokens)*pricing.CachedInputPrice) / 1_000_000
|
||||
|
||||
return math.Round(cost*100) / 100
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON {
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
result := &AggregatedUsageJSON{
|
||||
LastUpdated: u.LastUpdated,
|
||||
Combinations: make([]CostCombinationJSON, len(u.Combinations)),
|
||||
Costs: CostsSummaryJSON{
|
||||
TotalUSD: 0,
|
||||
ByUser: make(map[string]float64),
|
||||
},
|
||||
}
|
||||
|
||||
for i, combo := range u.Combinations {
|
||||
totalCost := calculateCost(combo.Total, combo.Model)
|
||||
|
||||
result.Costs.TotalUSD += totalCost
|
||||
|
||||
comboJSON := CostCombinationJSON{
|
||||
Model: combo.Model,
|
||||
Total: UsageStatsJSON{
|
||||
RequestCount: combo.Total.RequestCount,
|
||||
InputTokens: combo.Total.InputTokens,
|
||||
OutputTokens: combo.Total.OutputTokens,
|
||||
CachedTokens: combo.Total.CachedTokens,
|
||||
CostUSD: totalCost,
|
||||
},
|
||||
ByUser: make(map[string]UsageStatsJSON),
|
||||
}
|
||||
|
||||
for user, userStats := range combo.ByUser {
|
||||
userCost := calculateCost(userStats, combo.Model)
|
||||
result.Costs.ByUser[user] += userCost
|
||||
|
||||
comboJSON.ByUser[user] = UsageStatsJSON{
|
||||
RequestCount: userStats.RequestCount,
|
||||
InputTokens: userStats.InputTokens,
|
||||
OutputTokens: userStats.OutputTokens,
|
||||
CachedTokens: userStats.CachedTokens,
|
||||
CostUSD: userCost,
|
||||
}
|
||||
}
|
||||
|
||||
result.Combinations[i] = comboJSON
|
||||
}
|
||||
|
||||
result.Costs.TotalUSD = math.Round(result.Costs.TotalUSD*100) / 100
|
||||
for user, cost := range result.Costs.ByUser {
|
||||
result.Costs.ByUser[user] = math.Round(cost*100) / 100
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) Load() error {
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
data, err := os.ReadFile(u.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var temp struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Combinations []CostCombination `json:"combinations"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &temp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.LastUpdated = temp.LastUpdated
|
||||
u.Combinations = temp.Combinations
|
||||
|
||||
for i := range u.Combinations {
|
||||
if u.Combinations[i].ByUser == nil {
|
||||
u.Combinations[i].ByUser = make(map[string]UsageStats)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) Save() error {
|
||||
jsonData := u.ToJSON()
|
||||
|
||||
data, err := json.MarshalIndent(jsonData, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpFile := u.filePath + ".tmp"
|
||||
err = os.WriteFile(tmpFile, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
err = os.Rename(tmpFile, u.filePath)
|
||||
if err == nil {
|
||||
u.saveMutex.Lock()
|
||||
u.lastSaveTime = time.Now()
|
||||
u.saveMutex.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) AddUsage(model string, inputTokens, outputTokens, cachedTokens int64, user string) error {
|
||||
if model == "" {
|
||||
return E.New("model cannot be empty")
|
||||
}
|
||||
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
u.LastUpdated = time.Now()
|
||||
|
||||
var combo *CostCombination
|
||||
for i := range u.Combinations {
|
||||
if u.Combinations[i].Model == model {
|
||||
combo = &u.Combinations[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if combo == nil {
|
||||
newCombo := CostCombination{
|
||||
Model: model,
|
||||
Total: UsageStats{},
|
||||
ByUser: make(map[string]UsageStats),
|
||||
}
|
||||
u.Combinations = append(u.Combinations, newCombo)
|
||||
combo = &u.Combinations[len(u.Combinations)-1]
|
||||
}
|
||||
|
||||
combo.Total.RequestCount++
|
||||
combo.Total.InputTokens += inputTokens
|
||||
combo.Total.OutputTokens += outputTokens
|
||||
combo.Total.CachedTokens += cachedTokens
|
||||
|
||||
if user != "" {
|
||||
userStats := combo.ByUser[user]
|
||||
userStats.RequestCount++
|
||||
userStats.InputTokens += inputTokens
|
||||
userStats.OutputTokens += outputTokens
|
||||
userStats.CachedTokens += cachedTokens
|
||||
combo.ByUser[user] = userStats
|
||||
}
|
||||
|
||||
go u.scheduleSave()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) scheduleSave() {
|
||||
const saveInterval = time.Minute
|
||||
|
||||
u.saveMutex.Lock()
|
||||
defer u.saveMutex.Unlock()
|
||||
|
||||
timeSinceLastSave := time.Since(u.lastSaveTime)
|
||||
|
||||
if timeSinceLastSave >= saveInterval {
|
||||
go u.saveAsync()
|
||||
return
|
||||
}
|
||||
|
||||
if u.pendingSave {
|
||||
return
|
||||
}
|
||||
|
||||
u.pendingSave = true
|
||||
remainingTime := saveInterval - timeSinceLastSave
|
||||
|
||||
u.saveTimer = time.AfterFunc(remainingTime, func() {
|
||||
u.saveMutex.Lock()
|
||||
u.pendingSave = false
|
||||
u.saveMutex.Unlock()
|
||||
u.saveAsync()
|
||||
})
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) saveAsync() {
|
||||
err := u.Save()
|
||||
if err != nil {
|
||||
if u.logger != nil {
|
||||
u.logger.Error("save usage statistics: ", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) cancelPendingSave() {
|
||||
u.saveMutex.Lock()
|
||||
defer u.saveMutex.Unlock()
|
||||
|
||||
if u.saveTimer != nil {
|
||||
u.saveTimer.Stop()
|
||||
u.saveTimer = nil
|
||||
}
|
||||
u.pendingSave = false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ocm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
)
|
||||
|
||||
type UserManager struct {
|
||||
accessMutex sync.RWMutex
|
||||
tokenMap map[string]string
|
||||
}
|
||||
|
||||
func (m *UserManager) UpdateUsers(users []option.OCMUser) {
|
||||
m.accessMutex.Lock()
|
||||
defer m.accessMutex.Unlock()
|
||||
tokenMap := make(map[string]string, len(users))
|
||||
for _, user := range users {
|
||||
tokenMap[user.Token] = user.Name
|
||||
}
|
||||
m.tokenMap = tokenMap
|
||||
}
|
||||
|
||||
func (m *UserManager) Authenticate(token string) (string, bool) {
|
||||
m.accessMutex.RLock()
|
||||
username, found := m.tokenMap[token]
|
||||
m.accessMutex.RUnlock()
|
||||
return username, found
|
||||
}
|
||||
@@ -841,7 +841,22 @@ return view.extend({
|
||||
_('Routing mode of the traffic enters mihomo via firewall rules.'));
|
||||
so.value('', _('All allowed'));
|
||||
so.value('bypass_cn', _('Bypass CN'));
|
||||
so.value('routing_gfw', _('Routing GFW'));
|
||||
if (features.has_dnsmasq_full)
|
||||
so.value('routing_gfw', _('Routing GFW'));
|
||||
so.validate = function(section_id, value) {
|
||||
const mode = this.section.getOption('routing_mode').formvalue(section_id);
|
||||
let pd = this.section.getUIElement(section_id, 'routing_domain').node.querySelector('input');
|
||||
|
||||
// Force enabled
|
||||
if (mode === 'routing_gfw') {
|
||||
pd.checked = true;
|
||||
pd.disabled = true;
|
||||
} else {
|
||||
pd.removeAttribute('disabled');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
so = ss.taboption('routing_control', form.Flag, 'routing_domain', _('Handle domain'),
|
||||
_('Routing mode will be handle domain.') + '</br>' +
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-passwall
|
||||
PKG_VERSION:=25.12.13
|
||||
PKG_VERSION:=25.12.16
|
||||
PKG_RELEASE:=1
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -308,6 +308,17 @@ o = s:option(DummyValue, "switch_mode", " ")
|
||||
o.template = appname .. "/global/proxy"
|
||||
o:depends({ _tcp_node_bool = "1" })
|
||||
|
||||
-- Node → DNS Depends Settings
|
||||
o = s:option(DummyValue, "_node_sel_shunt", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ tcp_node = "__always__" })
|
||||
|
||||
o = s:option(DummyValue, "_node_sel_other", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
|
||||
|
||||
---- DNS
|
||||
o = s:option(ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
|
||||
o.default = "chinadns-ng"
|
||||
@@ -333,6 +344,7 @@ end
|
||||
if has_xray then
|
||||
o:value("xray", "Xray")
|
||||
end
|
||||
o:depends({ _tcp_node_bool = "1", _node_sel_other = "1" })
|
||||
o.remove = function(self, section)
|
||||
local f = s.fields["tcp_node"]
|
||||
local id_val = f and f:formvalue(section) or ""
|
||||
@@ -362,7 +374,9 @@ o:value("tcp", "TCP")
|
||||
o:value("tcp+doh", "TCP + DoH (" .. translate("A/AAAA type") .. ")")
|
||||
o:depends("dns_mode", "xray")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, ["tcp+doh"] = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "xray" then
|
||||
@@ -377,7 +391,9 @@ o:value("tcp", "TCP")
|
||||
o:value("doh", "DoH")
|
||||
o:depends("dns_mode", "sing-box")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, doh = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "sing-box" then
|
||||
@@ -444,6 +460,7 @@ o = s:option(Value, "remote_dns_client_ip", translate("EDNS Client Subnet"))
|
||||
o.datatype = "ipaddr"
|
||||
o:depends({dns_mode = "sing-box"})
|
||||
o:depends({dns_mode = "xray"})
|
||||
o:depends({_node_sel_shunt = "1"})
|
||||
|
||||
o = s:option(ListValue, "chinadns_ng_default_tag", translate("Default DNS"))
|
||||
o.default = "none"
|
||||
@@ -485,6 +502,7 @@ for k, v in pairs(nodes_table) do
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["xray_dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
|
||||
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
|
||||
end
|
||||
if v.type == "sing-box" and has_singbox then
|
||||
tcp:value(v.id, v["remark"])
|
||||
@@ -493,17 +511,13 @@ for k, v in pairs(nodes_table) do
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["singbox_dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
|
||||
end
|
||||
if has_xray or has_singbox then
|
||||
s.fields["remote_dns_client_ip"]:depends({ tcp_node = v.id })
|
||||
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
|
||||
end
|
||||
else
|
||||
tcp:value(v.id, v["remark"])
|
||||
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
udp:value(v.id, v["remark"])
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -309,6 +309,18 @@ o = s:taboption("Main", Flag, "tcp_node_socks_bind_local", translate("TCP Node")
|
||||
o.default = "1"
|
||||
o:depends({ tcp_node = "", ["!reverse"] = true })
|
||||
|
||||
-- Node → DNS Depends Settings
|
||||
o = s:taboption("Main", DummyValue, "_node_sel_shunt", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ tcp_node = "__always__" })
|
||||
|
||||
o = s:taboption("Main", DummyValue, "_node_sel_other", "")
|
||||
o.template = appname .. "/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
|
||||
|
||||
-- [[ DNS Settings ]]--
|
||||
s:tab("DNS", translate("DNS"))
|
||||
|
||||
o = s:taboption("DNS", ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
|
||||
@@ -388,8 +400,8 @@ end
|
||||
if has_xray then
|
||||
o:value("xray", "Xray")
|
||||
end
|
||||
o:depends({ dns_shunt = "chinadns-ng", tcp_node = "" })
|
||||
o:depends({ dns_shunt = "dnsmasq", tcp_node = "" })
|
||||
o:depends({ dns_shunt = "chinadns-ng", _node_sel_other = "1" })
|
||||
o:depends({ dns_shunt = "dnsmasq", _node_sel_other = "1" })
|
||||
o.remove = function(self, section)
|
||||
local f = s.fields["smartdns_dns_mode"]
|
||||
if f and f:formvalue(section) then
|
||||
@@ -408,7 +420,7 @@ if api.is_finded("smartdns") then
|
||||
if has_xray then
|
||||
o:value("xray", "Xray")
|
||||
end
|
||||
o:depends({ dns_shunt = "smartdns", tcp_node = "" })
|
||||
o:depends({ dns_shunt = "smartdns", _node_sel_other = "1" })
|
||||
o.remove = function(self, section)
|
||||
local f = s.fields["dns_mode"]
|
||||
if f and f:formvalue(section) then
|
||||
@@ -468,7 +480,9 @@ o:value("tcp+doh", "TCP + DoH (" .. translate("A/AAAA type") .. ")")
|
||||
o:depends("dns_mode", "xray")
|
||||
o:depends("smartdns_dns_mode", "xray")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, ["tcp+doh"] = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "xray" or s.fields["smartdns_dns_mode"]:formvalue(section) == "xray" then
|
||||
@@ -484,7 +498,9 @@ o:value("doh", "DoH")
|
||||
o:depends("dns_mode", "sing-box")
|
||||
o:depends("smartdns_dns_mode", "sing-box")
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(section, "v2ray_dns_mode")
|
||||
local v = m:get(section, "v2ray_dns_mode")
|
||||
local key = { udp = true, tcp = true, doh = true }
|
||||
return (v and key[v]) and v or self.default
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
if s.fields["dns_mode"]:formvalue(section) == "sing-box" or s.fields["smartdns_dns_mode"]:formvalue(section) == "sing-box" then
|
||||
@@ -548,6 +564,7 @@ o.datatype = "ipaddr"
|
||||
o:depends({dns_mode = "sing-box"})
|
||||
o:depends({dns_mode = "xray"})
|
||||
o:depends("dns_shunt", "smartdns")
|
||||
o:depends("_node_sel_shunt", "1")
|
||||
|
||||
o = s:taboption("DNS", Flag, "remote_fakedns", "FakeDNS", translate("Use FakeDNS work in the shunt domain that proxy."))
|
||||
o.default = "0"
|
||||
@@ -557,6 +574,7 @@ o:depends({smartdns_dns_mode = "sing-box", dns_shunt = "smartdns"})
|
||||
o:depends({dns_mode = "xray", dns_shunt = "dnsmasq"})
|
||||
o:depends({dns_mode = "xray", dns_shunt = "chinadns-ng"})
|
||||
o:depends({smartdns_dns_mode = "xray", dns_shunt = "smartdns"})
|
||||
o:depends("_node_sel_shunt", "1")
|
||||
o.validate = function(self, value, t)
|
||||
if value and value == "1" then
|
||||
local _dns_mode = s.fields["dns_mode"]:formvalue(t)
|
||||
@@ -810,22 +828,15 @@ for k, v in pairs(nodes_table) do
|
||||
udp:value(v.id, v["remark"])
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["xray_dns_mode"]:depends({ [v.id .. "-type"] = "Xray", tcp_node = v.id })
|
||||
s.fields["singbox_dns_mode"]:depends({ [v.id .. "-type"] = "sing-box", tcp_node = v.id })
|
||||
s.fields["remote_dns_client_ip"]:depends({ tcp_node = v.id })
|
||||
s.fields["remote_fakedns"]:depends({ tcp_node = v.id })
|
||||
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
|
||||
s.fields["xray_dns_mode"]:depends({ [v.id .. "-type"] = "Xray", _node_sel_shunt = "1" })
|
||||
s.fields["singbox_dns_mode"]:depends({ [v.id .. "-type"] = "sing-box", _node_sel_shunt = "1" })
|
||||
end
|
||||
else
|
||||
tcp:value(v.id, v["remark"])
|
||||
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
udp:value(v.id, v["remark"])
|
||||
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
s.fields["dns_mode"]:depends({ dns_shunt = "chinadns-ng", tcp_node = v.id })
|
||||
s.fields["dns_mode"]:depends({ dns_shunt = "dnsmasq", tcp_node = v.id })
|
||||
if api.is_finded("smartdns") then
|
||||
s.fields["smartdns_dns_mode"]:depends({ dns_shunt = "smartdns", tcp_node = v.id })
|
||||
end
|
||||
end
|
||||
if v.type == "Socks" then
|
||||
if has_singbox or has_xray then
|
||||
|
||||
@@ -147,7 +147,7 @@ o = s:option(DummyValue, "_stop", translate("Delete All Subscribe Node"))
|
||||
o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
return string.format(
|
||||
[[<button type="button" class="cbi-button cbi-button-remove" onclick="return confirmDeleteAll()">%s</button>]],
|
||||
[[<input type="button" class="btn cbi-button cbi-button-remove" onclick="return confirmDeleteAll()" value="%s" />]],
|
||||
translate("Delete All Subscribe Node"))
|
||||
end
|
||||
|
||||
@@ -155,7 +155,7 @@ o = s:option(DummyValue, "_update", translate("Manual subscription All"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(self, section)
|
||||
return string.format([[
|
||||
<button type="button" class="cbi-button cbi-button-apply" onclick="ManualSubscribeAll()">%s</button>]],
|
||||
<input type="button" class="btn cbi-button cbi-button-apply" onclick="ManualSubscribeAll()" value="%s" />]],
|
||||
translate("Manual subscription All"))
|
||||
end
|
||||
|
||||
@@ -231,7 +231,7 @@ o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
local remark = m:get(section, "remark") or ""
|
||||
return string.format(
|
||||
[[<button type="button" class="cbi-button cbi-button-remove" onclick="return confirmDeleteNode('%s')">%s</button>]],
|
||||
[[<input type="button" class="btn cbi-button cbi-button-remove" onclick="return confirmDeleteNode('%s')" value="%s" />]],
|
||||
remark, translate("Delete the subscribed node"))
|
||||
end
|
||||
|
||||
@@ -239,7 +239,7 @@ o = s:option(DummyValue, "_update", translate("Manual subscription"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(self, section)
|
||||
return string.format([[
|
||||
<button type="button" class="cbi-button cbi-button-apply" onclick="ManualSubscribe('%s')">%s</button>]],
|
||||
<input type="button" class="btn cbi-button cbi-button-apply" onclick="ManualSubscribe('%s')" value="%s" />]],
|
||||
section, translate("Manual subscription"))
|
||||
end
|
||||
|
||||
|
||||
@@ -201,8 +201,8 @@ function gen_outbound(flag, node, tag, proxy_table)
|
||||
} or nil,
|
||||
wsSettings = (node.transport == "ws") and {
|
||||
path = node.ws_path or "/",
|
||||
headers = (node.ws_host or node.ws_user_agent) and {
|
||||
Host = node.ws_host,
|
||||
host = node.ws_host,
|
||||
headers = node.ws_user_agent and {
|
||||
["User-Agent"] = node.ws_user_agent
|
||||
} or nil,
|
||||
maxEarlyData = tonumber(node.ws_maxEarlyData) or nil,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user