Update On Tue Dec 16 19:42:47 CET 2025

This commit is contained in:
github-action[bot]
2025-12-16 19:42:48 +01:00
parent 4456ecc632
commit 68db8a2905
112 changed files with 4014 additions and 1843 deletions
+1
View File
@@ -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
+26 -16
View File
@@ -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
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+11 -10
View File
@@ -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"`
+10 -8
View File
@@ -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()
+6 -2
View File
@@ -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)
}
}
}
+73 -17
View File
@@ -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
}
+113
View File
@@ -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
}
+152
View File
@@ -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)
}
}
}
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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={{
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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 />}
@@ -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 />
@@ -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>
)
}
@@ -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,
})
@@ -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: {
+4 -6
View File
@@ -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",
+5 -1090
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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:
+26 -16
View File
@@ -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
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+11 -10
View File
@@ -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"`
+10 -8
View File
@@ -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()
+6 -2
View File
@@ -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
}
+192
View File
@@ -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)
}
}
}
+73 -17
View File
@@ -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
}
+113
View File
@@ -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
}
+152
View File
@@ -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)
}
}
}
+30
View File
@@ -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>
@@ -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>
+12 -12
View File
@@ -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]]
+1 -1
View File
@@ -1 +1 @@
fe7ab107d3a222ca878b9a727d76075938ee7cde
3745171528eaf62dd8819df4c089b1259a32a1f2
+15 -5
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -29,6 +29,7 @@ const (
TypeResolved = "resolved"
TypeSSMAPI = "ssm-api"
TypeCCM = "ccm"
TypeOCM = "ocm"
)
const (
+9
View File
@@ -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) |
+171
View File
@@ -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
OCMOpenAI 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
View File
@@ -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
View File
@@ -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=
+12
View File
@@ -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)
}
+20
View File
@@ -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`)
})
}
+1
View File
@@ -136,6 +136,7 @@ func ServiceRegistry() *service.Registry {
registerDERPService(registry)
registerCCMService(registry)
registerOCMService(registry)
return registry
}
+2
View File
@@ -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
+20
View File
@@ -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"`
}
+1 -1
View File
@@ -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
+69 -2
View File
@@ -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,
},
}
+173
View File
@@ -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
}
+25
View File
@@ -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)
}
+25
View File
@@ -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)
}
+555
View File
@@ -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
}
+445
View File
@@ -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
}
+29
View File
@@ -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>' +
+1 -1
View File
@@ -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)
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