Update On Tue Mar 31 21:15:58 CEST 2026

This commit is contained in:
github-action[bot]
2026-03-31 21:15:59 +02:00
parent 67e071018a
commit 2133616eef
175 changed files with 20222 additions and 936 deletions
+1
View File
@@ -1315,3 +1315,4 @@ Update On Fri Mar 27 20:08:05 CET 2026
Update On Sat Mar 28 19:56:41 CET 2026
Update On Sun Mar 29 20:57:11 CEST 2026
Update On Mon Mar 30 21:11:19 CEST 2026
Update On Tue Mar 31 21:15:50 CEST 2026
+13 -1
View File
@@ -306,7 +306,7 @@ import type { MediaLibraryFolder } from '../types/media'
import { Modal } from '@arco-design/web-vue'
import { MediaScanner } from '../utils/mediaScanner'
import message from '../utils/message'
import { createWebDavConnection, getWebDavConnections, saveWebDavConnection, testWebDavConnection } from '../utils/webdavClient'
import { createWebDavConnection, getWebDavConnectionId, getWebDavConnections, removeWebDavConnection, saveWebDavConnection, testWebDavConnection } from '../utils/webdavClient'
const mediaStore = useMediaLibraryStore()
const mediaScanner = MediaScanner.getInstance()
@@ -467,6 +467,18 @@ const handleDeleteFolder = () => {
okButtonProps: { status: 'danger' },
cancelText: '取消',
onOk: () => {
if (isWebDavFolderSource(folder)) {
const connectionId = folder.userId || getWebDavConnectionId(folder.driveId)
if (connectionId) {
removeWebDavConnection(connectionId)
const relatedFolders = mediaStore.folders.filter(item =>
isWebDavFolderSource(item)
&& ((item.userId && item.userId === connectionId) || item.driveId === `webdav:${connectionId}`)
)
relatedFolders.forEach(item => mediaStore.removeFolder(item.id))
}
}
// 从媒体库删除文件夹和相关媒体
mediaStore.removeFolder(folder.id)
console.log(`已删除文件夹: ${folder.name}`)
+6 -3
View File
@@ -170,16 +170,19 @@ export async function getRawUrl(
}
}
// 违规文件无法获取地址
if ((!weifa && !data.url) || uiVideoPlayer == 'web') {
const needOriginQuality = !encType && preview_type === 'video' && !data.qualities.some((q: any) => q.quality === 'Origin')
if ((!weifa && !data.url) || uiVideoPlayer == 'web' || needOriginQuality) {
let downUrl = await AliFile.ApiFileDownloadUrl(user_id, drive_id, file_id, 14400)
if (typeof downUrl != 'string') {
if (getUrlFileName(downUrl.url).includes('wma')) {
return '不支持预览的加密音频格式'
}
if (!encType && preview_type) {
if (!encType && preview_type && !data.qualities.some((q: any) => q.quality === 'Origin')) {
data.qualities.unshift({ quality: 'Origin', html: '原画', label: '原画', value: '', url: downUrl.url })
}
data.url = downUrl.url
if (!data.url || quality === 'Origin' || uiVideoQuality === 'Origin') {
data.url = downUrl.url
}
data.size = downUrl.size
} else {
return data
+5
View File
@@ -141,6 +141,11 @@ export const saveWebDavConnection = (config: WebDavConnectionConfig) => {
saveWebDavConnections(list)
}
export const removeWebDavConnection = (id: string) => {
const list = getWebDavConnections().filter(item => item.id !== id)
saveWebDavConnections(list)
}
export const getWebDavConnection = (id: string) => {
return getWebDavConnections().find(item => item.id === id)
}
+1 -1
View File
@@ -36,7 +36,7 @@ type SudokuOption struct {
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"
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii", "prefer_entropy", or directional "up_ascii_down_entropy"/"up_entropy_down_ascii"
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
HTTPMask *bool `proxy:"http-mask,omitempty"`
HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto", "ws"
+181 -47
View File
@@ -24,6 +24,7 @@ import (
"github.com/metacubex/sing-vmess/packetaddr"
M "github.com/metacubex/sing/common/metadata"
"github.com/metacubex/tls"
"github.com/samber/lo"
)
type Vless struct {
@@ -35,6 +36,8 @@ type Vless struct {
// for gun mux
gunTransport *gun.Transport
// for xhttp
dialXHTTPConn func() (net.Conn, error)
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -72,12 +75,35 @@ type VlessOption struct {
}
type XHTTPOptions struct {
Path string `proxy:"path,omitempty"`
Host string `proxy:"host,omitempty"`
Mode string `proxy:"mode,omitempty"`
Headers map[string]string `proxy:"headers,omitempty"`
NoGRPCHeader bool `proxy:"no-grpc-header,omitempty"`
XPaddingBytes string `proxy:"x-padding-bytes,omitempty"`
Path string `proxy:"path,omitempty"`
Host string `proxy:"host,omitempty"`
Mode string `proxy:"mode,omitempty"`
Headers map[string]string `proxy:"headers,omitempty"`
NoGRPCHeader bool `proxy:"no-grpc-header,omitempty"`
XPaddingBytes string `proxy:"x-padding-bytes,omitempty"`
DownloadSettings *XHTTPDownloadSettings `proxy:"download-settings,omitempty"`
}
type XHTTPDownloadSettings struct {
// xhttp part
Path *string `proxy:"path,omitempty"`
Host *string `proxy:"host,omitempty"`
Headers *map[string]string `proxy:"headers,omitempty"`
NoGRPCHeader *bool `proxy:"no-grpc-header,omitempty"`
XPaddingBytes *string `proxy:"x-padding-bytes,omitempty"`
// proxy part
Server *string `proxy:"server,omitempty"`
Port *int `proxy:"port,omitempty"`
TLS *bool `proxy:"tls,omitempty"`
ALPN *[]string `proxy:"alpn,omitempty"`
ECHOpts *ECHOptions `proxy:"ech-opts,omitempty"`
RealityOpts *RealityOptions `proxy:"reality-opts,omitempty"`
SkipCertVerify *bool `proxy:"skip-cert-verify,omitempty"`
Fingerprint *string `proxy:"fingerprint,omitempty"`
Certificate *string `proxy:"certificate,omitempty"`
PrivateKey *string `proxy:"private-key,omitempty"`
ServerName *string `proxy:"servername,omitempty"`
ClientFingerprint *string `proxy:"client-fingerprint,omitempty"`
}
func (v *Vless) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.Metadata) (_ net.Conn, err error) {
@@ -241,51 +267,12 @@ func (v *Vless) streamTLSConn(ctx context.Context, conn net.Conn, isH2 bool) (ne
return conn, nil
}
func (v *Vless) dialXHTTPConn() (net.Conn, error) {
requestHost := v.option.XHTTPOpts.Host
if requestHost == "" {
if v.option.ServerName != "" {
requestHost = v.option.ServerName
} else {
requestHost = v.option.Server
}
}
cfg := &xhttp.Config{
Host: requestHost,
Path: v.option.XHTTPOpts.Path,
Mode: v.option.XHTTPOpts.Mode,
Headers: v.option.XHTTPOpts.Headers,
NoGRPCHeader: v.option.XHTTPOpts.NoGRPCHeader,
XPaddingBytes: v.option.XHTTPOpts.XPaddingBytes,
}
mode := cfg.EffectiveMode(v.realityConfig != nil)
transport := xhttp.NewTransport(
func(ctx context.Context) (net.Conn, error) {
return v.dialer.DialContext(ctx, "tcp", v.addr)
},
func(ctx context.Context, raw net.Conn, isH2 bool) (net.Conn, error) {
return v.streamTLSConn(ctx, raw, isH2)
},
)
switch mode {
case "stream-one":
return xhttp.DialStreamOne(cfg, transport)
case "packet-up":
return xhttp.DialPacketUp(cfg, transport)
default:
return nil, fmt.Errorf("xhttp mode %s is not implemented yet", mode)
}
}
func (v *Vless) dialContext(ctx context.Context) (c net.Conn, err error) {
switch v.option.Network {
case "xhttp":
return v.dialXHTTPConn()
case "grpc": // gun transport
return v.gunTransport.Dial()
case "xhttp":
return v.dialXHTTPConn()
default:
}
return v.dialer.DialContext(ctx, "tcp", v.addr)
@@ -501,6 +488,153 @@ func NewVless(option VlessOption) (*Vless, error) {
}
v.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
case "xhttp":
requestHost := v.option.XHTTPOpts.Host
if requestHost == "" {
if v.option.ServerName != "" {
requestHost = v.option.ServerName
} else {
requestHost = v.option.Server
}
}
cfg := &xhttp.Config{
Host: requestHost,
Path: v.option.XHTTPOpts.Path,
Mode: v.option.XHTTPOpts.Mode,
Headers: v.option.XHTTPOpts.Headers,
NoGRPCHeader: v.option.XHTTPOpts.NoGRPCHeader,
XPaddingBytes: v.option.XHTTPOpts.XPaddingBytes,
}
makeTransport := func() http.RoundTripper {
return xhttp.NewTransport(
func(ctx context.Context) (net.Conn, error) {
return v.dialer.DialContext(ctx, "tcp", v.addr)
},
func(ctx context.Context, raw net.Conn, isH2 bool) (net.Conn, error) {
return v.streamTLSConn(ctx, raw, isH2)
},
)
}
var makeDownloadTransport func() http.RoundTripper
if ds := v.option.XHTTPOpts.DownloadSettings; ds != nil {
if cfg.Mode == "stream-one" {
return nil, fmt.Errorf(`xhttp mode "stream-one" cannot be used with download-settings`)
}
downloadServer := lo.FromPtrOr(ds.Server, v.option.Server)
downloadPort := lo.FromPtrOr(ds.Port, v.option.Port)
downloadTLS := lo.FromPtrOr(ds.TLS, v.option.TLS)
downloadALPN := lo.FromPtrOr(ds.ALPN, v.option.ALPN)
downloadEchConfig := v.echConfig
if ds.ECHOpts != nil {
downloadEchConfig, err = ds.ECHOpts.Parse()
if err != nil {
return nil, err
}
}
downloadRealityCfg := v.realityConfig
if ds.RealityOpts != nil {
downloadRealityCfg, err = ds.RealityOpts.Parse()
if err != nil {
return nil, err
}
}
downloadSkipCertVerify := lo.FromPtrOr(ds.SkipCertVerify, v.option.SkipCertVerify)
downloadFingerprint := lo.FromPtrOr(ds.Fingerprint, v.option.Fingerprint)
downloadCertificate := lo.FromPtrOr(ds.Certificate, v.option.Certificate)
downloadPrivateKey := lo.FromPtrOr(ds.PrivateKey, v.option.PrivateKey)
downloadServerName := lo.FromPtrOr(ds.ServerName, v.option.ServerName)
downloadClientFingerprint := lo.FromPtrOr(ds.ClientFingerprint, v.option.ClientFingerprint)
downloadAddr := net.JoinHostPort(downloadServer, strconv.Itoa(downloadPort))
downloadHost := lo.FromPtrOr(ds.Host, v.option.XHTTPOpts.Host)
if downloadHost == "" {
if downloadServerName != "" {
downloadHost = downloadServerName
} else {
downloadHost = downloadServer
}
}
cfg.DownloadConfig = &xhttp.Config{
Host: downloadHost,
Path: lo.FromPtrOr(ds.Path, v.option.XHTTPOpts.Path),
Mode: v.option.XHTTPOpts.Mode,
Headers: lo.FromPtrOr(ds.Headers, v.option.XHTTPOpts.Headers),
NoGRPCHeader: lo.FromPtrOr(ds.NoGRPCHeader, v.option.XHTTPOpts.NoGRPCHeader),
XPaddingBytes: lo.FromPtrOr(ds.XPaddingBytes, v.option.XHTTPOpts.XPaddingBytes),
}
makeDownloadTransport = func() http.RoundTripper {
return xhttp.NewTransport(
func(ctx context.Context) (net.Conn, error) {
return v.dialer.DialContext(ctx, "tcp", downloadAddr)
},
func(ctx context.Context, conn net.Conn, isH2 bool) (net.Conn, error) {
if downloadTLS {
host, _, _ := net.SplitHostPort(downloadAddr)
tlsOpts := vmess.TLSConfig{
Host: host,
SkipCertVerify: downloadSkipCertVerify,
FingerPrint: downloadFingerprint,
Certificate: downloadCertificate,
PrivateKey: downloadPrivateKey,
ClientFingerprint: downloadClientFingerprint,
ECH: downloadEchConfig,
Reality: downloadRealityCfg,
NextProtos: downloadALPN,
}
if isH2 {
tlsOpts.NextProtos = []string{"h2"}
}
if downloadServerName != "" {
tlsOpts.Host = downloadServerName
}
return vmess.StreamTLSConn(ctx, conn, &tlsOpts)
}
return conn, nil
},
)
}
}
mode := cfg.EffectiveMode(v.realityConfig != nil)
switch mode {
case "stream-one":
v.dialXHTTPConn = func() (net.Conn, error) {
transport := makeTransport()
return xhttp.DialStreamOne(cfg, transport)
}
case "stream-up":
v.dialXHTTPConn = func() (net.Conn, error) {
transport := makeTransport()
downloadTransport := transport
if makeDownloadTransport != nil {
downloadTransport = makeDownloadTransport()
}
return xhttp.DialStreamUp(cfg, transport, downloadTransport)
}
case "packet-up":
v.dialXHTTPConn = func() (net.Conn, error) {
transport := makeTransport()
downloadTransport := transport
if makeDownloadTransport != nil {
downloadTransport = makeDownloadTransport()
}
return xhttp.DialPacketUp(cfg, transport, downloadTransport)
}
default:
return nil, fmt.Errorf("xhttp mode %s is not implemented yet", mode)
}
}
return v, nil
+69
View File
@@ -618,6 +618,75 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) {
anytls["udp"] = true
proxies = append(proxies, anytls)
case "mierus":
urlMieru, err := url.Parse(line)
if err != nil {
continue
}
query := urlMieru.Query()
server := urlMieru.Hostname()
if server == "" {
continue
}
username := urlMieru.User.Username()
password, _ := urlMieru.User.Password()
baseName := urlMieru.Fragment
if baseName == "" {
baseName = query.Get("profile")
}
if baseName == "" {
baseName = server
}
multiplexing := query.Get("multiplexing")
handshakeMode := query.Get("handshake-mode")
trafficPattern := query.Get("traffic-pattern")
portList := query["port"]
protocolList := query["protocol"]
if len(portList) == 0 || len(portList) != len(protocolList) {
continue
}
for i, port := range portList {
protocol := protocolList[i]
name := uniqueName(names, fmt.Sprintf("%s:%s/%s", baseName, port, protocol))
mieru := make(map[string]any, 15)
mieru["name"] = name
mieru["type"] = "mieru"
mieru["server"] = server
mieru["transport"] = protocol
mieru["udp"] = true
mieru["username"] = username
mieru["password"] = password
if strings.Contains(port, "-") {
mieru["port-range"] = port
} else {
portNum, err := strconv.Atoi(port)
if err != nil {
continue
}
mieru["port"] = portNum
}
if multiplexing != "" {
mieru["multiplexing"] = multiplexing
}
if handshakeMode != "" {
mieru["handshake-mode"] = handshakeMode
}
if trafficPattern != "" {
mieru["traffic-pattern"] = trafficPattern
}
proxies = append(proxies, mieru)
}
}
}
@@ -33,3 +33,99 @@ func TestConvertsV2Ray_normal(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, expected, proxies)
}
func TestConvertsV2RayMieru(t *testing.T) {
mierusTest := "mierus://user:pass@1.2.3.4?handshake-mode=HANDSHAKE_NO_WAIT&mtu=1400&multiplexing=MULTIPLEXING_HIGH&port=6666&port=9998-9999&port=6489&port=4896&profile=default&protocol=TCP&protocol=TCP&protocol=UDP&protocol=UDP&traffic-pattern=CCoQAQ"
expected := []map[string]any{
{
"name": "default:6666/TCP",
"type": "mieru",
"server": "1.2.3.4",
"port": 6666,
"transport": "TCP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
{
"name": "default:9998-9999/TCP",
"type": "mieru",
"server": "1.2.3.4",
"port-range": "9998-9999",
"transport": "TCP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
{
"name": "default:6489/UDP",
"type": "mieru",
"server": "1.2.3.4",
"port": 6489,
"transport": "UDP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
{
"name": "default:4896/UDP",
"type": "mieru",
"server": "1.2.3.4",
"port": 4896,
"transport": "UDP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
}
proxies, err := ConvertsV2Ray([]byte(mierusTest))
assert.Nil(t, err)
assert.Equal(t, expected, proxies)
}
func TestConvertsV2RayMieruMinimal(t *testing.T) {
mierusTest := "mierus://user:pass@example.com?port=443&protocol=TCP&profile=simple"
expected := []map[string]any{
{
"name": "simple:443/TCP",
"type": "mieru",
"server": "example.com",
"port": 443,
"transport": "TCP",
"udp": true,
"username": "user",
"password": "pass",
},
}
proxies, err := ConvertsV2Ray([]byte(mierusTest))
assert.Nil(t, err)
assert.Equal(t, expected, proxies)
}
func TestConvertsV2RayMieruFragment(t *testing.T) {
mierusTest := "mierus://user:pass@example.com?port=443&protocol=TCP&profile=default#myproxy"
proxies, err := ConvertsV2Ray([]byte(mierusTest))
assert.Nil(t, err)
assert.Len(t, proxies, 1)
assert.Equal(t, "myproxy:443/TCP", proxies[0]["name"])
}
+39 -25
View File
@@ -799,17 +799,45 @@ proxies: # socks5
network: xhttp
alpn:
- h2
client-fingerprint: chrome
# ech-opts: ...
# reality-opts: ...
# skip-cert-verify: false
# fingerprint: ...
# certificate: ...
# private-key: ...
servername: xxx.com
client-fingerprint: chrome
encryption: ""
xhttp-opts:
path: "/"
host: xxx.com
# mode: "stream-one" # Available: "stream-one" or "packet-up"
# mode: "stream-one" # Available: "stream-one", "stream-up" or "packet-up"
# headers:
# X-Forwarded-For: ""
# no-grpc-header: false
# x-padding-bytes: "100-1000"
# download-settings:
# ## xhttp part
# path: "/"
# host: xxx.com
# headers:
# X-Forwarded-For: ""
# no-grpc-header: false
# x-padding-bytes: "100-1000"
# ## proxy part
# server: server
# port: 443
# tls: true
# alpn:
# - h2
# ech-opts: ...
# reality-opts: ...
# skip-cert-verify: false
# fingerprint: ...
# certificate: ...
# private-key: ...
# servername: xxx.com
# client-fingerprint: chrome
# Trojan
@@ -1115,25 +1143,17 @@ proxies: # socks5
aead-method: chacha20-poly1305 # 可选:chacha20-poly1305、aes-128-gcm、none(不建议;且 enable-pure-downlink=false 时不可用)
padding-min: 2 # 最小填充率(0-100
padding-max: 7 # 最大填充率(0-100,必须 >= padding-min
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
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy、up_ascii_down_entropy、up_entropy_down_ascii
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合;只对 entropy 方向生效
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),非空时覆盖 custom-table
# 推荐:使用 httpmask 对象统一管理 HTTPMask 相关字段:
httpmask:
disable: false # true 禁用所有 HTTP 伪装/隧道
mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# tls: true # 可选:仅在 mode 为 stream/poll/auto/ws 时生效;true 强制 https/wssfalse 强制 http/ws(不会根据端口自动推断)
# tls: true # 可选:按需开启 HTTPS/WSS
# host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 mode 为 stream/poll/auto/ws 时生效
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload、/aabbcc/ws
# multiplex: off # 可选:off(默认)、auto(复用底层 HTTP 连接,减少建链 RTT)、on(Sudoku mux 单隧道多目标;仅在 mode=stream/poll/auto 生效;ws 强制 off
#
# 向后兼容旧写法:
# http-mask: true # 是否启用 http 掩码
# http-mask-mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto/ws 时生效;true 强制 https/wssfalse 强制 http/ws
# http-mask-host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 http-mask-mode 为 stream/poll/auto/ws 时生效
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致)
# http-mask-multiplex: off # 可选:off(默认)、auto(复用底层 HTTP 连接)、onSudoku mux 单隧道多目标;ws 强制 off)
# multiplex: "off" # 可选字符串:off(默认)、auto(复用底层 HTTP 连接,减少建链 RTT)、on(Sudoku mux 单隧道多目标;仅在 mode=stream/poll/auto 生效;ws 强制 off
enable-pure-downlink: false # 可选:false=带宽优化下行(更快,要求 aead-method != none);true=纯 Sudoku 下行
# anytls
@@ -1694,9 +1714,9 @@ listeners:
aead-method: chacha20-poly1305 # 可选:chacha20-poly1305、aes-128-gcm、none(不建议;且 enable-pure-downlink=false 时不可用)
padding-min: 1 # 最小填充率(0-100
padding-max: 15 # 最大填充率(0-100,必须 >= padding-min
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
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy、up_ascii_down_entropy、up_entropy_down_ascii
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合;只对 entropy 方向生效
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于多表轮换;非空时覆盖 custom-table
handshake-timeout: 5 # 可选(秒)
enable-pure-downlink: false # 可选:false=带宽优化下行(更快,要求 aead-method != none);true=纯 Sudoku 下行
# 推荐:使用 httpmask 对象统一管理 HTTPMask 相关字段:
@@ -1705,13 +1725,7 @@ listeners:
mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload、/aabbcc/ws
#
# 可选:当启用 HTTPMask 且识别到“像 HTTP 但不符合 tunnel/auth”的请求时,将原始字节透传给 fallback(常用于与其他服务共端口):
# fallback: "127.0.0.1:80"
#
# 向后兼容旧写法:
# disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false
# http-mask-mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致)
# fallback: "127.0.0.1:80" # 可选:用于可连接请求的回落转发,可与其他服务共端口
+1 -1
View File
@@ -6,7 +6,7 @@ require (
github.com/bahlo/generic-list-go v0.2.0
github.com/coreos/go-iptables v0.8.0
github.com/dlclark/regexp2 v1.11.5
github.com/enfein/mieru/v3 v3.29.0
github.com/enfein/mieru/v3 v3.30.0
github.com/gobwas/ws v1.4.0
github.com/gofrs/uuid/v5 v5.4.0
github.com/golang/snappy v1.0.0
+2 -2
View File
@@ -20,8 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/enfein/mieru/v3 v3.29.0 h1:i5Hwl5spEWg4ydvYW86zWSYVJ2uGTf5sLYQmFXHdulQ=
github.com/enfein/mieru/v3 v3.29.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/enfein/mieru/v3 v3.30.0 h1:g7v0TuK7y0ZMn6TOdjOs8WEUQk8bvs6WYPBJ16SKdBU=
github.com/enfein/mieru/v3 v3.30.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
+1 -1
View File
@@ -17,7 +17,7 @@ type SudokuOption struct {
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"
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii", "prefer_entropy", or directional "up_ascii_down_entropy"/"up_entropy_down_ascii"
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
+53
View File
@@ -391,3 +391,56 @@ func TestInboundVless_Reality_XHTTP(t *testing.T) {
}
testInboundVless(t, inboundOptions, outboundOptions)
}
func TestInboundVless_XHTTP_DownloadSettings(t *testing.T) {
for _, mode := range []string{"stream-up", "packet-up"} {
t.Run(mode, func(t *testing.T) {
inboundOptions := inbound.VlessOption{
Certificate: tlsCertificate,
PrivateKey: tlsPrivateKey,
XHTTPConfig: inbound.XHTTPConfig{
Path: "/vless-xhttp",
Host: "example.com",
Mode: mode,
},
}
outboundOptions := outbound.VlessOption{
TLS: true,
Fingerprint: tlsFingerprint,
ServerName: "example.org",
ClientFingerprint: "chrome",
Network: "xhttp",
XHTTPOpts: outbound.XHTTPOptions{
Path: "/vless-xhttp",
Host: "example.com",
Mode: mode,
DownloadSettings: &outbound.XHTTPDownloadSettings{},
},
}
testInboundVlessTLS(t, inboundOptions, outboundOptions, false)
})
}
}
func TestInboundVless_XHTTP_StreamUp(t *testing.T) {
inboundOptions := inbound.VlessOption{
Certificate: tlsCertificate,
PrivateKey: tlsPrivateKey,
XHTTPConfig: inbound.XHTTPConfig{
Path: "/vless-xhttp",
Host: "example.com",
Mode: "stream-up",
},
}
outboundOptions := outbound.VlessOption{
TLS: true,
Fingerprint: tlsFingerprint,
Network: "xhttp",
XHTTPOpts: outbound.XHTTPOptions{
Path: "/vless-xhttp",
Host: "example.com",
Mode: "stream-up",
},
}
testInboundVlessTLS(t, inboundOptions, outboundOptions, false)
}
+1 -1
View File
@@ -155,7 +155,7 @@ func New(options LC.Tun, tunnel C.Tunnel, additions ...inbound.Addition) (l *Lis
}
forwarderBindInterface := false
if options.FileDescriptor > 0 {
if tunnelName, err := getTunnelName(int32(options.FileDescriptor)); err != nil {
if tunnelName, err := getTunnelName(int32(options.FileDescriptor)); err == nil {
tunName = tunnelName // sing-tun must have the truth tun interface name even it from a fd
forwarderBindInterface = true
}
+1 -1
View File
@@ -148,7 +148,7 @@ func New(config LC.VlessServer, tunnel C.Tunnel, additions ...inbound.Addition)
}
if config.XHTTPConfig.Mode != "" {
switch config.XHTTPConfig.Mode {
case "auto":
case "auto", "stream-up", "stream-one", "packet-up":
default:
return nil, errors.New("unsupported xhttp mode")
}
+4 -7
View File
@@ -195,14 +195,11 @@ func ResolvePadding(min, max *int, defMin, defMax int) (int, int) {
}
func NormalizeTableType(tableType string) (string, error) {
switch t := strings.ToLower(strings.TrimSpace(tableType)); t {
case "", "prefer_ascii":
return "prefer_ascii", nil
case "prefer_entropy":
return "prefer_entropy", nil
default:
return "", fmt.Errorf("table-type must be prefer_ascii or prefer_entropy")
normalized, err := sudoku.NormalizeASCIIMode(tableType)
if err != nil {
return "", fmt.Errorf("table-type must be prefer_ascii, prefer_entropy, up_ascii_down_entropy, or up_entropy_down_ascii")
}
return normalized, nil
}
func (c *ProtocolConfig) tableCandidates() []*sudoku.Table {
@@ -0,0 +1,119 @@
package sudoku
import (
"bytes"
"io"
"net"
"testing"
"github.com/metacubex/mihomo/transport/sudoku/crypto"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
func TestDirectionalCustomTableRotationHintRoundTrip(t *testing.T) {
key := "directional-rotate-key"
target := "8.8.8.8:53"
serverTables, err := NewServerTablesWithCustomPatterns(ClientAEADSeed(key), "up_ascii_down_entropy", "", []string{"xpxvvpvv", "vxpvxvvp"})
if err != nil {
t.Fatalf("server tables: %v", err)
}
if len(serverTables) != 2 {
t.Fatalf("expected 2 server tables, got %d", len(serverTables))
}
clientTable, err := sudokuobfs.NewTableWithCustom(ClientAEADSeed(key), "up_ascii_down_entropy", "vxpvxvvp")
if err != nil {
t.Fatalf("client table: %v", err)
}
serverCfg := DefaultConfig()
serverCfg.Key = key
serverCfg.AEADMethod = "chacha20-poly1305"
serverCfg.Tables = serverTables
serverCfg.PaddingMin = 0
serverCfg.PaddingMax = 0
serverCfg.EnablePureDownlink = true
serverCfg.HandshakeTimeoutSeconds = 5
serverCfg.DisableHTTPMask = true
clientCfg := DefaultConfig()
*clientCfg = *serverCfg
clientCfg.ServerAddress = "example.com:443"
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
serverErr := make(chan error, 1)
go func() {
defer close(serverErr)
defer serverConn.Close()
c, meta, err := ServerHandshake(serverConn, serverCfg)
if err != nil {
serverErr <- err
return
}
defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
serverErr <- err
return
}
if session.Type != SessionTypeTCP || session.Target != target {
serverErr <- io.ErrUnexpectedEOF
return
}
payload := make([]byte, len("client-payload"))
if _, err := io.ReadFull(session.Conn, payload); err != nil {
serverErr <- err
return
}
if !bytes.Equal(payload, []byte("client-payload")) {
serverErr <- io.ErrUnexpectedEOF
return
}
if _, err := session.Conn.Write([]byte("server-reply")); err != nil {
serverErr <- err
}
}()
seed := ClientAEADSeed(clientCfg.Key)
obfsConn := buildClientObfsConn(clientConn, clientCfg, clientTable)
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
cConn, err := crypto.NewRecordConn(obfsConn, clientCfg.AEADMethod, pskC2S, pskS2C)
if err != nil {
t.Fatalf("setup crypto: %v", err)
}
defer cConn.Close()
if _, err := kipHandshakeClient(cConn, seed, kipUserHashFromKey(clientCfg.Key), KIPFeatAll, clientTable.Hint(), true); err != nil {
t.Fatalf("client handshake: %v", err)
}
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode target: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write target: %v", err)
}
if _, err := cConn.Write([]byte("client-payload")); err != nil {
t.Fatalf("write payload: %v", err)
}
reply := make([]byte, len("server-reply"))
if _, err := io.ReadFull(cConn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
if !bytes.Equal(reply, []byte("server-reply")) {
t.Fatalf("unexpected reply: %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatalf("server: %v", err)
}
}
+18 -20
View File
@@ -86,23 +86,23 @@ func (a earlyDummyAddr) String() string { return string(a) }
func buildEarlyClientObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
base := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkReader == nil {
return base
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, packed, base)
return newDirectionalConn(raw, downlinkReader, base)
}
func buildEarlyServerObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
uplink := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkWriter == nil {
return uplink
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, uplink, packed, packed.Flush)
return newDirectionalConn(raw, uplink, downlinkWriter, closers...)
}
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
if table == nil {
return nil, fmt.Errorf("nil table")
}
@@ -120,13 +120,7 @@ func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash
var clientPub [kipHelloPubSize]byte
copy(clientPub[:], ephemeral.PublicKey().Bytes())
hello := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
hello := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint)
mem := newEarlyMemoryConn(nil)
obfsConn := buildEarlyClientObfsConn(mem, cfg, table)
@@ -208,8 +202,8 @@ func (s *EarlyClientState) Ready() bool {
return s != nil && s.responseSet
}
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
state, err := NewEarlyClientState(cfg, table, userHash, feats)
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
state, err := NewEarlyClientState(cfg, table, tableHint, hasTableHint, userHash, feats)
if err != nil {
return nil, err
}
@@ -231,7 +225,7 @@ func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table,
var firstErr error
for _, table := range tables {
state, err := processEarlyClientPayloadForTable(cfg, table, payload, allowReplay)
state, err := processEarlyClientPayloadForTable(cfg, tables, table, payload, allowReplay)
if err == nil {
return state, nil
}
@@ -245,7 +239,7 @@ func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table,
return nil, firstErr
}
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
mem := newEarlyMemoryConn(payload)
obfsConn := buildEarlyServerObfsConn(mem, cfg, table)
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
@@ -273,6 +267,10 @@ func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.T
if allowReplay != nil && !allowReplay(userHash, ch.Nonce, time.Now()) {
return nil, fmt.Errorf("replay detected")
}
resolvedTable, err := ResolveClientHelloTable(table, tables, ch)
if err != nil {
return nil, fmt.Errorf("resolve table hint failed: %w", err)
}
curve := ecdh.X25519()
serverEphemeral, err := curve.GenerateKey(rand.Reader)
@@ -297,7 +295,7 @@ func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.T
}
respMem := newEarlyMemoryConn(nil)
respObfs := buildEarlyServerObfsConn(respMem, cfg, table)
respObfs := buildEarlyServerObfsConn(respMem, cfg, resolvedTable)
respConn, err := crypto.NewRecordConn(respObfs, cfg.AEAD, pskS2C, pskC2S)
if err != nil {
return nil, fmt.Errorf("server early crypto setup failed: %w", err)
@@ -310,7 +308,7 @@ func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.T
ResponsePayload: respMem.Written(),
UserHash: userHash,
cfg: cfg,
table: table,
table: resolvedTable,
sessionC2S: sessionC2S,
sessionS2C: sessionS2C,
}, nil
@@ -92,3 +92,238 @@ func TestCustomTablesRotation_ProbedByServer(t *testing.T) {
}
}
}
func TestDirectionalTrafficRoundTrip(t *testing.T) {
tests := []struct {
name string
mode string
pure bool
}{
{name: "UpASCII_DownEntropy_Pure", mode: "up_ascii_down_entropy", pure: true},
{name: "UpASCII_DownEntropy_Packed", mode: "up_ascii_down_entropy", pure: false},
{name: "UpEntropy_DownASCII_Pure", mode: "up_entropy_down_ascii", pure: true},
{name: "UpEntropy_DownASCII_Packed", mode: "up_entropy_down_ascii", pure: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "directional-test-key-" + tt.name
target := "8.8.8.8:53"
table, err := sudokuobfs.NewTableWithCustom(ClientAEADSeed(key), tt.mode, "xpxvvpvv")
if err != nil {
t.Fatalf("table: %v", err)
}
serverCfg := DefaultConfig()
serverCfg.Key = key
serverCfg.AEADMethod = "chacha20-poly1305"
serverCfg.Table = table
serverCfg.PaddingMin = 0
serverCfg.PaddingMax = 0
serverCfg.EnablePureDownlink = tt.pure
serverCfg.HandshakeTimeoutSeconds = 5
serverCfg.DisableHTTPMask = true
clientCfg := DefaultConfig()
*clientCfg = *serverCfg
clientCfg.ServerAddress = "example.com:443"
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
serverErr := make(chan error, 1)
go func() {
defer close(serverErr)
defer serverConn.Close()
c, meta, err := ServerHandshake(serverConn, serverCfg)
if err != nil {
serverErr <- err
return
}
defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
serverErr <- err
return
}
if session.Type != SessionTypeTCP {
serverErr <- io.ErrUnexpectedEOF
return
}
if session.Target != target {
serverErr <- io.ErrClosedPipe
return
}
want := []byte("client-payload")
got := make([]byte, len(want))
if _, err := io.ReadFull(session.Conn, got); err != nil {
serverErr <- err
return
}
if !bytes.Equal(got, want) {
serverErr <- io.ErrUnexpectedEOF
return
}
if _, err := session.Conn.Write([]byte("server-reply")); err != nil {
serverErr <- err
return
}
}()
cConn, err := ClientHandshake(clientConn, clientCfg)
if err != nil {
t.Fatalf("client handshake: %v", err)
}
defer cConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode target: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write target: %v", err)
}
if _, err := cConn.Write([]byte("client-payload")); err != nil {
t.Fatalf("write payload: %v", err)
}
reply := make([]byte, len("server-reply"))
if _, err := io.ReadFull(cConn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
if !bytes.Equal(reply, []byte("server-reply")) {
t.Fatalf("unexpected reply: %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatalf("server: %v", err)
}
})
}
}
func TestDirectionalTrafficRoundTripTCP(t *testing.T) {
tests := []struct {
name string
mode string
pure bool
}{
{name: "UpASCII_DownEntropy_Pure_TCP", mode: "up_ascii_down_entropy", pure: true},
{name: "UpEntropy_DownASCII_Packed_TCP", mode: "up_entropy_down_ascii", pure: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "directional-tcp-test-key-" + tt.name
target := "127.0.0.1:18080"
table, err := sudokuobfs.NewTableWithCustom(ClientAEADSeed(key), tt.mode, "xpxvvpvv")
if err != nil {
t.Fatalf("table: %v", err)
}
serverCfg := DefaultConfig()
serverCfg.Key = key
serverCfg.AEADMethod = "chacha20-poly1305"
serverCfg.Table = table
serverCfg.PaddingMin = 0
serverCfg.PaddingMax = 0
serverCfg.EnablePureDownlink = tt.pure
serverCfg.HandshakeTimeoutSeconds = 5
serverCfg.DisableHTTPMask = true
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
serverErr := make(chan error, 1)
go func() {
defer close(serverErr)
raw, err := ln.Accept()
if err != nil {
serverErr <- err
return
}
defer raw.Close()
c, meta, err := ServerHandshake(raw, serverCfg)
if err != nil {
serverErr <- err
return
}
defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
serverErr <- err
return
}
if session.Type != SessionTypeTCP || session.Target != target {
serverErr <- io.ErrUnexpectedEOF
return
}
want := []byte("client-payload")
got := make([]byte, len(want))
if _, err := io.ReadFull(session.Conn, got); err != nil {
serverErr <- err
return
}
if !bytes.Equal(got, want) {
serverErr <- io.ErrUnexpectedEOF
return
}
if _, err := session.Conn.Write([]byte("server-reply")); err != nil {
serverErr <- err
return
}
}()
clientCfg := DefaultConfig()
*clientCfg = *serverCfg
clientCfg.ServerAddress = ln.Addr().String()
raw, err := net.Dial("tcp", clientCfg.ServerAddress)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer raw.Close()
cConn, err := ClientHandshake(raw, clientCfg)
if err != nil {
t.Fatalf("client handshake: %v", err)
}
defer cConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode target: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write target: %v", err)
}
if _, err := cConn.Write([]byte("client-payload")); err != nil {
t.Fatalf("write payload: %v", err)
}
reply := make([]byte, len("server-reply"))
if _, err := io.ReadFull(cConn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
if !bytes.Equal(reply, []byte("server-reply")) {
t.Fatalf("unexpected reply: %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatalf("server: %v", err)
}
})
}
}
+62 -9
View File
@@ -184,6 +184,14 @@ func (c *directionalConn) Write(p []byte) (int, error) {
return c.writer.Write(p)
}
func (c *directionalConn) ReplaceWriter(writer io.Writer, closers ...func() error) {
if c == nil {
return
}
c.writer = writer
c.closers = closers
}
func (c *directionalConn) Close() error {
var firstErr error
for _, fn := range c.closers {
@@ -227,22 +235,55 @@ func absInt64(v int64) int64 {
return v
}
func oppositeDirectionTable(table *sudoku.Table) *sudoku.Table {
if table == nil {
return nil
}
if other := table.OppositeDirection(); other != nil {
return other
}
return table
}
func newClientDownlinkReader(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) io.Reader {
downlinkTable := oppositeDirectionTable(table)
if pureDownlink {
if downlinkTable == table {
return nil
}
return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false)
}
return sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax)
}
func newServerDownlinkWriter(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) (io.Writer, []func() error) {
downlinkTable := oppositeDirectionTable(table)
if pureDownlink {
if downlinkTable == table {
return nil, nil
}
return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false), nil
}
packed := sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax)
return packed, []func() error{packed.Flush}
}
func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn {
baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkReader == nil {
return baseSudoku
}
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, packed, baseSudoku)
return newDirectionalConn(raw, downlinkReader, baseSudoku)
}
func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) {
uplinkSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record)
if cfg.EnablePureDownlink {
downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkWriter == nil {
return uplinkSudoku, uplinkSudoku
}
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, packed, packed.Flush)
return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, downlinkWriter, closers...)
}
func isLegacyHTTPMaskMode(mode string) bool {
@@ -269,20 +310,20 @@ func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) {
}
}
table, err := pickClientTable(cfg)
choice, err := pickClientTable(cfg)
if err != nil {
return nil, err
}
seed := ClientAEADSeed(cfg.Key)
obfsConn := buildClientObfsConn(rawConn, cfg, table)
obfsConn := buildClientObfsConn(rawConn, cfg, choice.Table)
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskC2S, pskS2C)
if err != nil {
return nil, fmt.Errorf("setup crypto failed: %w", err)
}
if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll); err != nil {
if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll, choice.Hint, choice.HasHint); err != nil {
_ = rc.Close()
return nil, err
}
@@ -393,6 +434,18 @@ func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, *Handshak
if !globalHandshakeReplay.allow(userHashHex, ch.Nonce, time.Now()) {
return nil, nil, &SuspiciousError{Err: fmt.Errorf("replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
}
resolvedTable, err := ResolveClientHelloTable(selectedTable, cfg.tableCandidates(), ch)
if err != nil {
return nil, nil, &SuspiciousError{Err: fmt.Errorf("resolve table hint failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
}
if resolvedTable != selectedTable {
downlinkWriter, closers := newServerDownlinkWriter(baseConn, resolvedTable, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
switchable, ok := obfsConn.(*directionalConn)
if !ok {
return nil, nil, &SuspiciousError{Err: fmt.Errorf("switch downlink writer failed"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
}
switchable.ReplaceWriter(downlinkWriter, closers...)
}
curve := ecdh.X25519()
serverEphemeral, err := curve.GenerateKey(rand.Reader)
+2 -8
View File
@@ -12,7 +12,7 @@ import (
const kipHandshakeSkew = 60 * time.Second
func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32) (uint32, error) {
func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32, tableHint uint32, hasTableHint bool) (uint32, error) {
if rc == nil {
return 0, fmt.Errorf("nil conn")
}
@@ -31,13 +31,7 @@ func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUs
var clientPub [kipHelloPubSize]byte
copy(clientPub[:], ephemeral.PublicKey().Bytes())
ch := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
ch := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint)
if err := WriteKIPMessage(rc, KIPTypeClientHello, ch.EncodePayload()); err != nil {
return 0, fmt.Errorf("write client hello failed: %w", err)
}
@@ -25,14 +25,16 @@ func newHTTPMaskEarlyCodecConfig(cfg *ProtocolConfig, psk string) EarlyCodecConf
}
func newClientHTTPMaskEarlyHandshake(cfg *ProtocolConfig) (*httpmask.ClientEarlyHandshake, error) {
table, err := pickClientTable(cfg)
choice, err := pickClientTable(cfg)
if err != nil {
return nil, err
}
return NewHTTPMaskClientEarlyHandshake(
newHTTPMaskEarlyCodecConfig(cfg, ClientAEADSeed(cfg.Key)),
table,
choice.Table,
choice.Hint,
choice.HasHint,
kipUserHashFromKey(cfg.Key),
KIPFeatAll,
)
@@ -451,6 +451,64 @@ func TestHTTPMaskTunnel_EarlyHandshake_TCPRoundTrip(t *testing.T) {
}
}
func TestHTTPMaskTunnel_EarlyHandshake_AutoPathRoot_TCPRoundTrip(t *testing.T) {
key := "tunnel-early-auto-pathroot"
target := "1.1.1.1:80"
serverCfg := newTunnelTestTable(t, key)
serverCfg.HTTPMaskMode = "auto"
serverCfg.HTTPMaskPathRoot = "httpmaskpath"
addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error {
if s.Type != SessionTypeTCP {
return fmt.Errorf("unexpected session type: %v", s.Type)
}
if s.Target != target {
return fmt.Errorf("target mismatch: %s", s.Target)
}
_, _ = s.Conn.Write([]byte("ok"))
return nil
})
defer stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientCfg := *serverCfg
clientCfg.ServerAddress = addr
handshakeCfg := clientCfg
handshakeCfg.DisableHTTPMask = true
tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, func(raw net.Conn) (net.Conn, error) {
return ClientHandshake(raw, &handshakeCfg)
})
if err != nil {
t.Fatalf("dial tunnel: %v", err)
}
defer tunnelConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode addr: %v", err)
}
if err := WriteKIPMessage(tunnelConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err)
}
buf := make([]byte, 2)
if _, err := io.ReadFull(tunnelConn, buf); err != nil {
t.Fatalf("read: %v", err)
}
if string(buf) != "ok" {
t.Fatalf("unexpected payload: %q", buf)
}
stop()
for err := range errCh {
t.Fatalf("server error: %v", err)
}
}
func TestHTTPMaskTunnel_Validation(t *testing.T) {
cfg := DefaultConfig()
cfg.Key = "k"
+10 -6
View File
@@ -39,16 +39,20 @@ func ClientAEADSeed(key string) string {
}
// Client-side key material can be:
// - public key: 32 bytes hex compressed point
// - split private key: 64 bytes hex (r||k)
// - master private scalar: 32 bytes hex (x)
// - PSK string: non-hex
//
// We intentionally do NOT treat a 32-byte hex as a public key here; the client is expected
// to carry private material. Server-side should use ServerAEADSeed for public keys.
switch len(b) {
case 64:
case 32:
default:
// 32-byte hex is ambiguous: it can be either a compressed public key or a
// master private scalar. Official Sudoku runtime accepts public keys directly,
// so when the bytes already decode as a point, preserve that point verbatim.
if len(b) == 32 {
if p, err := new(edwards25519.Point).SetBytes(b); err == nil {
return hex.EncodeToString(p.Bytes())
}
}
if len(b) != 64 && len(b) != 32 {
return key
}
if recovered, err := crypto.RecoverPublicKey(key); err == nil {
+22 -9
View File
@@ -15,23 +15,36 @@ func TestClientAEADSeed_IsStableForPrivAndPub(t *testing.T) {
require.NoError(t, err)
require.Equal(t, pub, ClientAEADSeed(priv))
require.Equal(t, pub, ClientAEADSeed(pub))
require.Equal(t, pub, ServerAEADSeed(pub))
require.Equal(t, pub, ServerAEADSeed(priv))
}
}
func TestClientAEADSeed_Supports32ByteMasterScalar(t *testing.T) {
var seed [64]byte
_, err := rand.Read(seed[:])
require.NoError(t, err)
for i := 0; i < 256; i++ {
var seed [64]byte
_, err := rand.Read(seed[:])
require.NoError(t, err)
s, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
require.NoError(t, err)
s, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
require.NoError(t, err)
keyHex := hex.EncodeToString(s.Bytes())
require.Len(t, keyHex, 64)
require.NotEqual(t, keyHex, ClientAEADSeed(keyHex))
require.Equal(t, ClientAEADSeed(keyHex), ServerAEADSeed(ClientAEADSeed(keyHex)))
keyHex := hex.EncodeToString(s.Bytes())
require.Len(t, keyHex, 64)
// 32-byte hex is ambiguous: it can be either a master scalar or an
// already-compressed public key. Public-key encoding wins when both parse.
if _, err := new(edwards25519.Point).SetBytes(s.Bytes()); err == nil {
continue
}
require.NotEqual(t, keyHex, ClientAEADSeed(keyHex))
require.Equal(t, ClientAEADSeed(keyHex), ServerAEADSeed(ClientAEADSeed(keyHex)))
return
}
t.Fatal("failed to generate an unambiguous 32-byte master scalar")
}
func TestServerAEADSeed_LeavesPublicKeyAsIs(t *testing.T) {
+64 -5
View File
@@ -10,6 +10,8 @@ import (
"io"
"strings"
"time"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
const (
@@ -42,6 +44,8 @@ const (
kipMaxPayload = 64 * 1024
)
const kipClientHelloTableHintSize = 4
var errKIP = errors.New("kip protocol error")
type KIPMessage struct {
@@ -98,11 +102,13 @@ func ReadKIPMessage(r io.Reader) (*KIPMessage, error) {
}
type KIPClientHello struct {
Timestamp time.Time
UserHash [kipHelloUserHashSize]byte
Nonce [kipHelloNonceSize]byte
ClientPub [kipHelloPubSize]byte
Features uint32
Timestamp time.Time
UserHash [kipHelloUserHashSize]byte
Nonce [kipHelloNonceSize]byte
ClientPub [kipHelloPubSize]byte
Features uint32
TableHint uint32
HasTableHint bool
}
type KIPServerHello struct {
@@ -111,6 +117,18 @@ type KIPServerHello struct {
SelectedFeats uint32
}
func newKIPClientHello(userHash [kipHelloUserHashSize]byte, nonce [kipHelloNonceSize]byte, clientPub [kipHelloPubSize]byte, feats uint32, tableHint uint32, hasTableHint bool) *KIPClientHello {
return &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
TableHint: tableHint,
HasTableHint: hasTableHint,
}
}
func kipUserHashFromKey(psk string) [kipHelloUserHashSize]byte {
var out [kipHelloUserHashSize]byte
psk = strings.TrimSpace(psk)
@@ -147,6 +165,11 @@ func (m *KIPClientHello) EncodePayload() []byte {
var f [4]byte
binary.BigEndian.PutUint32(f[:], m.Features)
b.Write(f[:])
if m.HasTableHint {
var hint [kipClientHelloTableHintSize]byte
binary.BigEndian.PutUint32(hint[:], m.TableHint)
b.Write(hint[:])
}
return b.Bytes()
}
@@ -166,9 +189,45 @@ func DecodeKIPClientHelloPayload(payload []byte) (*KIPClientHello, error) {
copy(h.ClientPub[:], payload[off:off+kipHelloPubSize])
off += kipHelloPubSize
h.Features = binary.BigEndian.Uint32(payload[off : off+4])
off += 4
if len(payload) >= off+kipClientHelloTableHintSize {
h.TableHint = binary.BigEndian.Uint32(payload[off : off+kipClientHelloTableHintSize])
h.HasTableHint = true
}
return &h, nil
}
func ResolveClientHelloTable(selected *sudokuobfs.Table, candidates []*sudokuobfs.Table, hello *KIPClientHello) (*sudokuobfs.Table, error) {
if selected == nil {
return nil, fmt.Errorf("nil selected table")
}
if hello == nil || !hello.HasTableHint {
return selected, nil
}
if selected.Hint() == hello.TableHint {
return selected, nil
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no table candidates")
}
var hinted *sudokuobfs.Table
for _, candidate := range candidates {
if candidate == nil || candidate.Hint() != hello.TableHint {
continue
}
hinted = candidate
break
}
if hinted == nil {
return nil, fmt.Errorf("unknown table hint: %d", hello.TableHint)
}
if hinted != selected && (!hinted.IsASCII || !selected.IsASCII) {
return nil, fmt.Errorf("table hint %d mismatches probed uplink table", hello.TableHint)
}
return hinted, nil
}
func (m *KIPServerHello) EncodePayload() []byte {
var b bytes.Buffer
b.Write(m.Nonce[:])
+64
View File
@@ -0,0 +1,64 @@
package sudoku
import (
"testing"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
func TestKIPClientHelloTableHintRoundTrip(t *testing.T) {
hello := &KIPClientHello{
Features: KIPFeatAll,
TableHint: 0x12345678,
HasTableHint: true,
}
decoded, err := DecodeKIPClientHelloPayload(hello.EncodePayload())
if err != nil {
t.Fatalf("decode client hello: %v", err)
}
if !decoded.HasTableHint {
t.Fatalf("expected decoded hello to carry table hint")
}
if decoded.TableHint != hello.TableHint {
t.Fatalf("decoded table hint = %08x, want %08x", decoded.TableHint, hello.TableHint)
}
}
func TestResolveClientHelloTableAllowsDirectionalASCIIRotation(t *testing.T) {
tables, err := NewClientTablesWithCustomPatterns("seed", "up_ascii_down_entropy", "", []string{"xpxvvpvv", "vxpvxvvp"})
if err != nil {
t.Fatalf("build tables: %v", err)
}
if len(tables) != 2 {
t.Fatalf("expected 2 tables, got %d", len(tables))
}
selected, err := ResolveClientHelloTable(tables[0], tables, &KIPClientHello{
TableHint: tables[1].Hint(),
HasTableHint: true,
})
if err != nil {
t.Fatalf("resolve client hello table: %v", err)
}
if selected != tables[1] {
t.Fatalf("resolved table mismatch")
}
}
func TestResolveClientHelloTableRejectsEntropyMismatch(t *testing.T) {
a, err := sudokuobfs.NewTableWithCustom("seed", "prefer_entropy", "xpxvvpvv")
if err != nil {
t.Fatalf("table a: %v", err)
}
b, err := sudokuobfs.NewTableWithCustom("seed", "prefer_entropy", "vxpvxvvp")
if err != nil {
t.Fatalf("table b: %v", err)
}
if _, err := ResolveClientHelloTable(a, []*sudokuobfs.Table{a, b}, &KIPClientHello{
TableHint: b.Hint(),
HasTableHint: true,
}); err == nil {
t.Fatalf("expected entropy-table mismatch to fail")
}
}
@@ -0,0 +1,93 @@
package sudoku
import (
"fmt"
"strings"
)
const (
asciiModeTokenASCII = "ascii"
asciiModeTokenEntropy = "entropy"
)
// ASCIIMode describes the preferred wire layout for each traffic direction.
// Uplink is client->server, Downlink is server->client.
type ASCIIMode struct {
Uplink string
Downlink string
}
// ParseASCIIMode accepts legacy symmetric values ("ascii"/"entropy"/"prefer_*")
// and directional values like "up_ascii_down_entropy".
func ParseASCIIMode(mode string) (ASCIIMode, error) {
raw := strings.ToLower(strings.TrimSpace(mode))
switch raw {
case "", "entropy", "prefer_entropy":
return ASCIIMode{Uplink: asciiModeTokenEntropy, Downlink: asciiModeTokenEntropy}, nil
case "ascii", "prefer_ascii":
return ASCIIMode{Uplink: asciiModeTokenASCII, Downlink: asciiModeTokenASCII}, nil
}
if !strings.HasPrefix(raw, "up_") {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
parts := strings.SplitN(strings.TrimPrefix(raw, "up_"), "_down_", 2)
if len(parts) != 2 {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
up, ok := normalizeASCIIModeToken(parts[0])
if !ok {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
down, ok := normalizeASCIIModeToken(parts[1])
if !ok {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
return ASCIIMode{Uplink: up, Downlink: down}, nil
}
// NormalizeASCIIMode returns the canonical config string for a supported mode.
func NormalizeASCIIMode(mode string) (string, error) {
parsed, err := ParseASCIIMode(mode)
if err != nil {
return "", err
}
return parsed.Canonical(), nil
}
func (m ASCIIMode) Canonical() string {
if m.Uplink == asciiModeTokenASCII && m.Downlink == asciiModeTokenASCII {
return "prefer_ascii"
}
if m.Uplink == asciiModeTokenEntropy && m.Downlink == asciiModeTokenEntropy {
return "prefer_entropy"
}
return "up_" + m.Uplink + "_down_" + m.Downlink
}
func (m ASCIIMode) uplinkPreference() string {
return singleDirectionPreference(m.Uplink)
}
func (m ASCIIMode) downlinkPreference() string {
return singleDirectionPreference(m.Downlink)
}
func normalizeASCIIModeToken(token string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(token)) {
case "ascii", "prefer_ascii":
return asciiModeTokenASCII, true
case "entropy", "prefer_entropy", "":
return asciiModeTokenEntropy, true
default:
return "", false
}
}
func singleDirectionPreference(token string) string {
if token == asciiModeTokenASCII {
return "prefer_ascii"
}
return "prefer_entropy"
}
@@ -0,0 +1,56 @@
package sudoku
import "testing"
func TestNormalizeASCIIMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", "prefer_entropy"},
{"entropy", "prefer_entropy"},
{"prefer_ascii", "prefer_ascii"},
{"up_ascii_down_entropy", "up_ascii_down_entropy"},
{"up_entropy_down_ascii", "up_entropy_down_ascii"},
{"up_prefer_ascii_down_prefer_entropy", "up_ascii_down_entropy"},
}
for _, tt := range tests {
got, err := NormalizeASCIIMode(tt.in)
if err != nil {
t.Fatalf("NormalizeASCIIMode(%q): %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("NormalizeASCIIMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
if _, err := NormalizeASCIIMode("up_ascii_down_binary"); err == nil {
t.Fatalf("expected invalid directional mode to fail")
}
}
func TestNewTableWithCustomDirectionalOpposite(t *testing.T) {
table, err := NewTableWithCustom("seed", "up_ascii_down_entropy", "xpxvvpvv")
if err != nil {
t.Fatalf("NewTableWithCustom: %v", err)
}
if !table.IsASCII {
t.Fatalf("uplink table should be ascii")
}
opposite := table.OppositeDirection()
if opposite == nil || opposite == table {
t.Fatalf("expected distinct opposite table")
}
if opposite.IsASCII {
t.Fatalf("downlink table should be entropy/custom")
}
symmetric, err := NewTableWithCustom("seed", "prefer_ascii", "xpxvvpvv")
if err != nil {
t.Fatalf("NewTableWithCustom symmetric: %v", err)
}
if symmetric.OppositeDirection() != symmetric {
t.Fatalf("symmetric table should point to itself")
}
}
@@ -163,8 +163,7 @@ func (sc *Conn) Write(p []byte) (n int, err error) {
out = append(out, pads[sc.rng.Intn(padLen)])
}
_, err = sc.Conn.Write(out)
return len(p), err
return len(p), writeFull(sc.Conn, out)
}
func (sc *Conn) Read(p []byte) (n int, err error) {
@@ -27,7 +27,7 @@ func (l *byteLayout) isHint(b byte) bool {
return l.name == "ascii" && b == '\n'
}
// resolveLayout picks the byte layout based on ASCII preference and optional custom pattern.
// resolveLayout picks the byte layout for a single traffic direction.
// ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred.
func resolveLayout(mode string, customPattern string) (*byteLayout, error) {
switch strings.ToLower(mode) {
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"errors"
"math/rand"
"strings"
)
var (
@@ -17,6 +18,8 @@ type Table struct {
PaddingPool []byte
IsASCII bool // 标记当前模式
layout *byteLayout
opposite *Table
hint uint32
}
// NewTable initializes the obfuscation tables with built-in layouts.
@@ -29,10 +32,41 @@ func NewTable(key string, mode string) *Table {
return t
}
// NewTableWithCustom initializes obfuscation tables using either predefined or custom layouts.
// mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence.
// NewTableWithCustom initializes the uplink/probe Sudoku table using either predefined
// or directional layouts. Directional modes such as "up_ascii_down_entropy" return the
// client->server table and internally attach the opposite direction table for runtime use.
// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive).
func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) {
asciiMode, err := ParseASCIIMode(mode)
if err != nil {
return nil, err
}
uplinkPattern := customPatternForToken(asciiMode.Uplink, customPattern)
downlinkPattern := customPatternForToken(asciiMode.Downlink, customPattern)
hint := tableHintFingerprint(key, asciiMode.Canonical(), uplinkPattern, downlinkPattern)
uplink, err := newSingleDirectionTable(key, asciiMode.uplinkPreference(), uplinkPattern)
if err != nil {
return nil, err
}
uplink.hint = hint
if asciiMode.Uplink == asciiMode.Downlink {
uplink.opposite = uplink
return uplink, nil
}
downlink, err := newSingleDirectionTable(key, asciiMode.downlinkPreference(), downlinkPattern)
if err != nil {
return nil, err
}
downlink.hint = hint
uplink.opposite = downlink
downlink.opposite = uplink
return uplink, nil
}
func newSingleDirectionTable(key string, mode string, customPattern string) (*Table, error) {
layout, err := resolveLayout(mode, customPattern)
if err != nil {
return nil, err
@@ -125,6 +159,38 @@ func NewTableWithCustom(key string, mode string, customPattern string) (*Table,
return t, nil
}
func customPatternForToken(token string, customPattern string) string {
if token == asciiModeTokenEntropy {
return customPattern
}
return ""
}
func (t *Table) OppositeDirection() *Table {
if t == nil || t.opposite == nil {
return t
}
return t.opposite
}
func (t *Table) Hint() uint32 {
if t == nil {
return 0
}
return t.hint
}
func tableHintFingerprint(key string, mode string, uplinkPattern string, downlinkPattern string) uint32 {
sum := sha256.Sum256([]byte(strings.Join([]string{
"sudoku-table-hint",
key,
mode,
strings.ToLower(strings.TrimSpace(uplinkPattern)),
strings.ToLower(strings.TrimSpace(downlinkPattern)),
}, "\x00")))
return binary.BigEndian.Uint32(sum[:4])
}
func packHintsToKey(hints [4]byte) uint32 {
// Sorting network for 4 elements (Bubble sort unrolled)
// Swap if a > b
+11 -5
View File
@@ -14,20 +14,26 @@ import (
"github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, error) {
type clientTableChoice struct {
Table *sudoku.Table
Hint uint32
HasHint bool
}
func pickClientTable(cfg *ProtocolConfig) (clientTableChoice, error) {
candidates := cfg.tableCandidates()
if len(candidates) == 0 {
return nil, fmt.Errorf("no table configured")
return clientTableChoice{}, fmt.Errorf("no table configured")
}
if len(candidates) == 1 {
return candidates[0], nil
return clientTableChoice{Table: candidates[0], Hint: candidates[0].Hint()}, nil
}
var b [1]byte
if _, err := crand.Read(b[:]); err != nil {
return nil, fmt.Errorf("random table pick failed: %w", err)
return clientTableChoice{}, fmt.Errorf("random table pick failed: %w", err)
}
idx := int(b[0]) % len(candidates)
return candidates[idx], nil
return clientTableChoice{Table: candidates[idx], Hint: candidates[idx].Hint(), HasHint: true}, nil
}
type readOnlyConn struct {
+23 -5
View File
@@ -17,12 +17,23 @@ func normalizeCustomPatterns(customTable string, customTables []string) []string
return patterns
}
func normalizeTablePatterns(tableType string, customTable string, customTables []string) ([]string, error) {
patterns := normalizeCustomPatterns(customTable, customTables)
if _, err := sudoku.ParseASCIIMode(tableType); err != nil {
return nil, err
}
return patterns, nil
}
// 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).
//
// Deprecated-ish: prefer NewClientTablesWithCustomPatterns / NewServerTablesWithCustomPatterns.
func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
patterns := normalizeCustomPatterns(customTable, customTables)
patterns, err := normalizeTablePatterns(tableType, customTable, customTables)
if err != nil {
return nil, err
}
tables := make([]*sudoku.Table, 0, len(patterns))
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
@@ -39,11 +50,18 @@ func NewClientTablesWithCustomPatterns(key string, tableType string, customTable
return NewTablesWithCustomPatterns(key, tableType, customTable, customTables)
}
// NewServerTablesWithCustomPatterns matches upstream server behavior: when custom table rotation is enabled,
// also accept the default table to avoid forcing clients to update in lockstep.
// NewServerTablesWithCustomPatterns matches upstream server behavior: when probeable custom table
// rotation is enabled, also accept the default table to avoid forcing clients to update in lockstep.
func NewServerTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
patterns := normalizeCustomPatterns(customTable, customTables)
if len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" {
patterns, err := normalizeTablePatterns(tableType, customTable, customTables)
if err != nil {
return nil, err
}
asciiMode, err := sudoku.ParseASCIIMode(tableType)
if err != nil {
return nil, err
}
if asciiMode.Uplink == "entropy" && len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" {
patterns = append([]string{""}, patterns...)
}
return NewTablesWithCustomPatterns(key, tableType, "", patterns)
@@ -0,0 +1,45 @@
package sudoku
import "testing"
func TestDirectionalCustomTableRotationCollapse(t *testing.T) {
patterns := []string{"xpxvvpvv", "vxpvxvvp"}
clientTables, err := NewClientTablesWithCustomPatterns("seed", "up_ascii_down_entropy", "", patterns)
if err != nil {
t.Fatalf("client tables: %v", err)
}
if len(clientTables) != 2 {
t.Fatalf("expected ascii-uplink directional rotation to keep 2 tables, got %d", len(clientTables))
}
if clientTables[0].Hint() == clientTables[1].Hint() {
t.Fatalf("expected directional custom tables to carry distinct hints")
}
if got, want := clientTables[0].EncodeTable[0][0], clientTables[1].EncodeTable[0][0]; got != want {
t.Fatalf("expected directional ascii uplink tables to share the same probe layout, got %x want %x", got, want)
}
if got, want := clientTables[0].OppositeDirection().EncodeTable[0][0], clientTables[1].OppositeDirection().EncodeTable[0][0]; got == want {
t.Fatalf("expected directional downlink custom layouts to differ, both got %x", got)
}
clientTables, err = NewClientTablesWithCustomPatterns("seed", "up_entropy_down_ascii", "", patterns)
if err != nil {
t.Fatalf("client tables entropy uplink: %v", err)
}
if len(clientTables) != 2 {
t.Fatalf("expected entropy-uplink rotation to keep 2 tables, got %d", len(clientTables))
}
serverTables, err := NewServerTablesWithCustomPatterns("seed", "up_ascii_down_entropy", "", patterns)
if err != nil {
t.Fatalf("server tables: %v", err)
}
if len(serverTables) != 2 {
t.Fatalf("expected ascii-uplink server directional table set to keep 2 tables, got %d", len(serverTables))
}
if clientTables, err = NewClientTablesWithCustomPatterns("seed", "up_ascii_down_entropy", patterns[0], nil); err != nil {
t.Fatalf("client table with single custom pattern: %v", err)
} else if got, want := serverTables[0].OppositeDirection().EncodeTable[0][0], clientTables[0].OppositeDirection().EncodeTable[0][0]; got != want {
t.Fatalf("expected server directional downlink table to preserve custom pattern, got %x want %x", got, want)
}
}
+141 -11
View File
@@ -107,7 +107,7 @@ func DialStreamOne(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
}
req.Host = cfg.Host
if err := cfg.FillStreamRequest(req); err != nil {
if err := cfg.FillStreamRequest(req, ""); err != nil {
_ = pr.Close()
_ = pw.Close()
return nil, err
@@ -129,7 +129,6 @@ func DialStreamOne(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
}
conn.reader = resp.Body
conn.onClose = func() {
_ = resp.Body.Close()
_ = pr.Close()
httputils.CloseTransport(transport)
}
@@ -137,13 +136,136 @@ func DialStreamOne(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
return conn, nil
}
func DialPacketUp(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
func DialStreamUp(cfg *Config, uploadTransport http.RoundTripper, downloadTransport http.RoundTripper) (net.Conn, error) {
downloadCfg := cfg
if ds := cfg.DownloadConfig; ds != nil {
downloadCfg = ds
}
streamURL := url.URL{
Scheme: "https",
Host: cfg.Host,
Path: cfg.NormalizedPath(),
}
downloadURL := url.URL{
Scheme: "https",
Host: downloadCfg.Host,
Path: downloadCfg.NormalizedPath(),
}
pr, pw := io.Pipe()
ctx := context.Background()
conn := &Conn{writer: pw}
sessionID := newSessionID()
downloadReq, err := http.NewRequestWithContext(
httputils.NewAddrContext(&conn.NetAddr, ctx),
http.MethodGet,
downloadURL.String(),
nil,
)
if err != nil {
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
if err := downloadCfg.FillDownloadRequest(downloadReq, sessionID); err != nil {
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
downloadReq.Host = downloadCfg.Host
downloadResp, err := downloadTransport.RoundTrip(downloadReq)
if err != nil {
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
if downloadResp.StatusCode != http.StatusOK {
_ = downloadResp.Body.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, fmt.Errorf("xhttp stream-up download bad status: %s", downloadResp.Status)
}
uploadReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
streamURL.String(),
pr,
)
if err != nil {
_ = downloadResp.Body.Close()
_ = pr.Close()
_ = pw.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
if err := cfg.FillStreamRequest(uploadReq, sessionID); err != nil {
_ = downloadResp.Body.Close()
_ = pr.Close()
_ = pw.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
uploadReq.Host = cfg.Host
go func() {
resp, err := uploadTransport.RoundTrip(uploadReq)
if err != nil {
_ = pw.CloseWithError(err)
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = pw.CloseWithError(fmt.Errorf("xhttp stream-up upload bad status: %s", resp.Status))
}
}()
conn.reader = downloadResp.Body
conn.onClose = func() {
_ = pr.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
}
return conn, nil
}
func DialPacketUp(cfg *Config, uploadTransport http.RoundTripper, downloadTransport http.RoundTripper) (net.Conn, error) {
downloadCfg := cfg
if ds := cfg.DownloadConfig; ds != nil {
downloadCfg = ds
}
sessionID := newSessionID()
downloadURL := url.URL{
Scheme: "https",
Host: cfg.Host,
Path: cfg.NormalizedPath(),
Host: downloadCfg.Host,
Path: downloadCfg.NormalizedPath(),
}
ctx := context.Background()
@@ -151,30 +273,38 @@ func DialPacketUp(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
ctx: ctx,
cfg: cfg,
sessionID: sessionID,
transport: transport,
transport: uploadTransport,
seq: 0,
}
conn := &Conn{writer: writer}
req, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, ctx), http.MethodGet, downloadURL.String(), nil)
downloadReq, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, ctx), http.MethodGet, downloadURL.String(), nil)
if err != nil {
return nil, err
}
if err := cfg.FillDownloadRequest(req, sessionID); err != nil {
if err := downloadCfg.FillDownloadRequest(downloadReq, sessionID); err != nil {
return nil, err
}
req.Host = cfg.Host
downloadReq.Host = downloadCfg.Host
resp, err := transport.RoundTrip(req)
resp, err := downloadTransport.RoundTrip(downloadReq)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
httputils.CloseTransport(transport)
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)
}
conn.reader = resp.Body
conn.onClose = func() {
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
}
return conn, nil
}
+22 -7
View File
@@ -12,12 +12,22 @@ import (
)
type Config struct {
Host string
Path string
Mode string
Headers map[string]string
NoGRPCHeader bool
XPaddingBytes string
Host string
Path string
Mode string
Headers map[string]string
NoGRPCHeader bool
XPaddingBytes string
DownloadConfig *Config
}
type DownloadConfig struct {
Host string
Path string
Mode string
ServerName string
ClientFingerprint string
SkipCertVerify bool
}
func (c *Config) NormalizedMode() string {
@@ -33,6 +43,9 @@ func (c *Config) EffectiveMode(hasReality bool) string {
return mode
}
if hasReality {
if c.DownloadConfig != nil {
return "stream-up"
}
return "stream-one"
}
return "packet-up"
@@ -126,7 +139,7 @@ func parseRange(s string) (int, int, error) {
return minVal, maxVal, nil
}
func (c *Config) FillStreamRequest(req *http.Request) error {
func (c *Config) FillStreamRequest(req *http.Request, sessionID string) error {
req.Header = c.RequestHeader()
paddingValue, err := c.RandomPadding()
@@ -143,6 +156,8 @@ func (c *Config) FillStreamRequest(req *http.Request) error {
req.Header.Set("Referer", rawURL+sep+"x_padding="+paddingValue)
}
c.ApplyMetaToRequest(req, sessionID, "")
if req.Body != nil && !c.NoGRPCHeader {
req.Header.Set("Content-Type", "application/grpc")
}
+4 -10
View File
@@ -1,6 +1,7 @@
package xhttp
import (
"errors"
"io"
"time"
@@ -26,19 +27,12 @@ func (c *Conn) Read(b []byte) (int, error) {
}
func (c *Conn) Close() error {
err := c.writer.Close()
err2 := c.reader.Close()
if c.onClose != nil {
c.onClose()
}
err := c.writer.Close()
err2 := c.reader.Close()
if err != nil {
return err
}
if err2 != nil {
return err2
}
return nil
return errors.Join(err, err2)
}
func (c *Conn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
+39
View File
@@ -242,6 +242,45 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// stream-up upload: POST /path/{session}
if r.Method == http.MethodPost && len(parts) == 1 {
sessionID := parts[0]
session := h.getSession(sessionID)
if session == nil {
http.Error(w, "unknown xhttp session", http.StatusBadRequest)
return
}
buf := make([]byte, 32*1024)
var seq uint64
for {
n, err := r.Body.Read(buf)
if n > 0 {
if pushErr := session.uploadQueue.Push(Packet{
Seq: seq,
Payload: buf[:n],
}); pushErr != nil {
http.Error(w, pushErr.Error(), http.StatusInternalServerError)
return
}
seq++
}
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
// packet-up upload: POST /path/{session}/{seq}
if r.Method == http.MethodPost && len(parts) == 2 {
sessionID := parts[0]
@@ -70,7 +70,7 @@
"@csstools/normalize.css": "12.1.1",
"@emotion/babel-plugin": "11.13.5",
"@emotion/react": "11.14.0",
"@iconify/json": "2.2.456",
"@iconify/json": "2.2.458",
"@monaco-editor/react": "4.7.0",
"@tanstack/react-query": "5.95.2",
"@tanstack/react-router": "1.168.8",
+2 -2
View File
@@ -2,7 +2,7 @@
"manifest_version": 1,
"latest": {
"mihomo": "v1.19.21",
"mihomo_alpha": "alpha-29fed6e",
"mihomo_alpha": "alpha-132800e",
"clash_rs": "v0.9.6",
"clash_premium": "2023-09-05-gdcc8d87",
"clash_rs_alpha": "0.9.6-alpha+sha.77e5fd1"
@@ -69,5 +69,5 @@
"linux-armv7hf": "clash-rs-armv7-unknown-linux-gnueabihf"
}
},
"updated_at": "2026-03-29T22:23:08.094Z"
"updated_at": "2026-03-30T22:23:53.481Z"
}
+5 -5
View File
@@ -337,8 +337,8 @@ importers:
specifier: 11.14.0
version: 11.14.0(@types/react@19.2.14)(react@19.2.4)
'@iconify/json':
specifier: 2.2.456
version: 2.2.456
specifier: 2.2.458
version: 2.2.458
'@monaco-editor/react':
specifier: 4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1721,8 +1721,8 @@ packages:
prettier-plugin-ember-template-tag:
optional: true
'@iconify/json@2.2.456':
resolution: {integrity: sha512-u2215knwHqSTgvRwkwziitJr5Js25KukLhW8C58fUGyNsD6P5EWoOb5nA0o8K8tzqUBi/622XTdf7wN2hmK+NA==}
'@iconify/json@2.2.458':
resolution: {integrity: sha512-YcJx5EjyRU3O+qpYNjx0BX/Uewxao9uSF0gd9Xa+d8YzCGGbMXosaANQ5aQV/lptnToqpu/PDTQ1c883GhvGRA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -9504,7 +9504,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@iconify/json@2.2.456':
'@iconify/json@2.2.458':
dependencies:
'@iconify/types': 2.0.0
pathe: 2.0.3
@@ -7,12 +7,16 @@ import (
"encoding/hex"
"fmt"
"net"
"os"
"os/exec"
"strings"
"sync"
eUtils "github.com/apernet/hysteria/extras/v2/utils"
)
const firewallBackendEnv = "HYSTERIA_FIREWALL_BACKEND"
type osCommandRunner struct{}
func (osCommandRunner) LookPath(file string) (string, error) {
@@ -64,28 +68,39 @@ func setupUDPPortRedirectWithRunner(r commandRunner, listenAddr *net.UDPAddr, po
if len(redirects) == 0 {
return nil, nil
}
if _, err := r.LookPath("nft"); err == nil {
switch strings.ToLower(os.Getenv(firewallBackendEnv)) {
case "nftables", "nft":
return setupNFTablesRedirect(r, listenAddr, ports, redirects)
case "iptables", "ipt":
return setupIPTablesRedirect(r, listenAddr, ports, redirects)
default:
// Auto-detect: prefer nftables, fall back to iptables.
if _, err := r.LookPath("nft"); err == nil {
return setupNFTablesRedirect(r, listenAddr, ports, redirects)
}
return setupIPTablesRedirect(r, listenAddr, ports, redirects)
}
return setupIPTablesRedirect(r, listenAddr, ports, redirects)
}
func setupNFTablesRedirect(r commandRunner, listenAddr *net.UDPAddr, ports, redirects eUtils.PortUnion) (*closerFuncs, error) {
families := nftFamiliesForAddr(listenAddr)
cleanup := &closerFuncs{}
nft := func(args ...string) error {
return r.Run("nft", args...)
}
for _, family := range families {
hash := shortHash("nft|" + family + "|" + hashInput(listenAddr, ports))
tableName := "hysteria_" + hash
if err := r.Run("nft", "add", "table", family, tableName); err != nil {
if err := nft("add", "table", family, tableName); err != nil {
_ = cleanup.Close()
return nil, err
}
cleanup.add(func() { _ = r.Run("nft", "delete", "table", family, tableName) })
cleanup.add(func() { _ = nft("delete", "table", family, tableName) })
for _, chainArgs := range [][]string{
{"add", "chain", family, tableName, "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "dstnat;", "policy", "accept;", "}"},
{"add", "chain", family, tableName, "output", "{", "type", "nat", "hook", "output", "priority", "dstnat;", "policy", "accept;", "}"},
} {
if err := r.Run("nft", chainArgs...); err != nil {
if err := nft(chainArgs...); err != nil {
_ = cleanup.Close()
return nil, err
}
@@ -97,7 +112,7 @@ func setupNFTablesRedirect(r commandRunner, listenAddr *net.UDPAddr, ports, redi
args = append(args, match...)
}
args = append(args, "udp", "dport", nftPortExpr(portRange), "redirect", "to", fmt.Sprintf(":%d", ports[0].Start))
if err := r.Run("nft", args...); err != nil {
if err := nft(args...); err != nil {
_ = cleanup.Close()
return nil, err
}
@@ -114,18 +129,20 @@ func setupIPTablesRedirect(r commandRunner, listenAddr *net.UDPAddr, ports, redi
}
cleanup := &closerFuncs{}
for _, bin := range bins {
ipt := func(args ...string) error {
return r.Run(bin, append([]string{"-w"}, args...)...)
}
hash := shortHash(bin + "|" + hashInput(listenAddr, ports))
chainName := "HYSTERIA-PR-" + hash
if err := r.Run(bin, "-t", "nat", "-N", chainName); err != nil {
if err := ipt("-t", "nat", "-N", chainName); err != nil {
_ = cleanup.Close()
return nil, err
}
cleanup.add(func() {
_ = r.Run(bin, "-t", "nat", "-F", chainName)
_ = r.Run(bin, "-t", "nat", "-X", chainName)
_ = ipt("-t", "nat", "-F", chainName)
_ = ipt("-t", "nat", "-X", chainName)
})
redirectArgs := []string{"-t", "nat", "-A", chainName, "-p", "udp", "-j", "REDIRECT", "--to-ports", fmt.Sprintf("%d", ports[0].Start)}
if err := r.Run(bin, redirectArgs...); err != nil {
if err := ipt("-t", "nat", "-A", chainName, "-p", "udp", "-j", "REDIRECT", "--to-ports", fmt.Sprintf("%d", ports[0].Start)); err != nil {
_ = cleanup.Close()
return nil, err
}
@@ -136,12 +153,12 @@ func setupIPTablesRedirect(r commandRunner, listenAddr *net.UDPAddr, ports, redi
args = append(args, match...)
}
args = append(args, "-p", "udp", "--dport", iptablesPortExpr(portRange), "-j", chainName)
if err := r.Run(bin, args...); err != nil {
if err := ipt(args...); err != nil {
_ = cleanup.Close()
return nil, err
}
deleteArgs := append([]string{"-t", "nat", "-D", baseChain}, args[4:]...)
cleanup.add(func() { _ = r.Run(bin, deleteArgs...) })
cleanup.add(func() { _ = ipt(deleteArgs...) })
}
}
}
@@ -68,8 +68,11 @@ func TestSetupUDPPortRedirectWithRunnerIPTablesFallback(t *testing.T) {
require.NoError(t, cleanup.Close())
foundDelete := false
for _, cmd := range runner.cmds {
if len(cmd) > 3 && cmd[2] == "-D" || (len(cmd) > 4 && cmd[3] == "-D") {
foundDelete = true
for _, arg := range cmd {
if arg == "-D" {
foundDelete = true
break
}
}
}
require.True(t, foundDelete)
+1 -1
View File
@@ -32,7 +32,7 @@ PROJECT_NAME=$(shell basename "${ROOT}")
# - pkg/version/current.go
#
# Use `tools/bump_version.sh` script to change all those files at one shot.
VERSION="3.29.0"
VERSION="3.30.0"
# With .ONESHELL, each recipe is executed in a single shell instance.
# This allows `cd` to affect subsequent commands in the same recipe.
@@ -1,5 +1,5 @@
Package: mieru
Version: 3.29.0
Version: 3.30.0
Section: net
Priority: optional
Architecture: amd64
@@ -1,5 +1,5 @@
Name: mieru
Version: 3.29.0
Version: 3.30.0
Release: 1%{?dist}
Summary: Mieru proxy client
License: GPLv3+
@@ -1,5 +1,5 @@
Package: mieru
Version: 3.29.0
Version: 3.30.0
Section: net
Priority: optional
Architecture: arm64
@@ -1,5 +1,5 @@
Name: mieru
Version: 3.29.0
Version: 3.30.0
Release: 1%{?dist}
Summary: Mieru proxy client
License: GPLv3+
@@ -1,5 +1,5 @@
Package: mita
Version: 3.29.0
Version: 3.30.0
Section: net
Priority: optional
Architecture: amd64
+1 -1
View File
@@ -1,5 +1,5 @@
Name: mita
Version: 3.29.0
Version: 3.30.0
Release: 1%{?dist}
Summary: Mieru proxy server
License: GPLv3+
@@ -1,5 +1,5 @@
Package: mita
Version: 3.29.0
Version: 3.30.0
Section: net
Priority: optional
Architecture: arm64
+1 -1
View File
@@ -1,5 +1,5 @@
Name: mita
Version: 3.29.0
Version: 3.30.0
Release: 1%{?dist}
Summary: Mieru proxy server
License: GPLv3+
+8 -8
View File
@@ -18,32 +18,32 @@ Or you can manually install and configure proxy server using the steps below.
```sh
# Debian / Ubuntu - X86_64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita_3.29.0_amd64.deb
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita_3.30.0_amd64.deb
# Debian / Ubuntu - ARM 64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita_3.29.0_arm64.deb
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita_3.30.0_arm64.deb
# RedHat / CentOS / Rocky Linux - X86_64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita-3.29.0-1.x86_64.rpm
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita-3.30.0-1.x86_64.rpm
# RedHat / CentOS / Rocky Linux - ARM 64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita-3.29.0-1.aarch64.rpm
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita-3.30.0-1.aarch64.rpm
```
## Install mita package
```sh
# Debian / Ubuntu - X86_64
sudo dpkg -i mita_3.29.0_amd64.deb
sudo dpkg -i mita_3.30.0_amd64.deb
# Debian / Ubuntu - ARM 64
sudo dpkg -i mita_3.29.0_arm64.deb
sudo dpkg -i mita_3.30.0_arm64.deb
# RedHat / CentOS / Rocky Linux - X86_64
sudo rpm -Uvh --force mita-3.29.0-1.x86_64.rpm
sudo rpm -Uvh --force mita-3.30.0-1.x86_64.rpm
# RedHat / CentOS / Rocky Linux - ARM 64
sudo rpm -Uvh --force mita-3.29.0-1.aarch64.rpm
sudo rpm -Uvh --force mita-3.30.0-1.aarch64.rpm
```
Those instructions can also be used to upgrade the version of mita software package.
+8 -8
View File
@@ -18,32 +18,32 @@ sudo python3 setup.py --lang=zh
```sh
# Debian / Ubuntu - X86_64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita_3.29.0_amd64.deb
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita_3.30.0_amd64.deb
# Debian / Ubuntu - ARM 64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita_3.29.0_arm64.deb
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita_3.30.0_arm64.deb
# RedHat / CentOS / Rocky Linux - X86_64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita-3.29.0-1.x86_64.rpm
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita-3.30.0-1.x86_64.rpm
# RedHat / CentOS / Rocky Linux - ARM 64
curl -LSO https://github.com/enfein/mieru/releases/download/v3.29.0/mita-3.29.0-1.aarch64.rpm
curl -LSO https://github.com/enfein/mieru/releases/download/v3.30.0/mita-3.30.0-1.aarch64.rpm
```
## 安装 mita 软件包
```sh
# Debian / Ubuntu - X86_64
sudo dpkg -i mita_3.29.0_amd64.deb
sudo dpkg -i mita_3.30.0_amd64.deb
# Debian / Ubuntu - ARM 64
sudo dpkg -i mita_3.29.0_arm64.deb
sudo dpkg -i mita_3.30.0_arm64.deb
# RedHat / CentOS / Rocky Linux - X86_64
sudo rpm -Uvh --force mita-3.29.0-1.x86_64.rpm
sudo rpm -Uvh --force mita-3.30.0-1.x86_64.rpm
# RedHat / CentOS / Rocky Linux - ARM 64
sudo rpm -Uvh --force mita-3.29.0-1.aarch64.rpm
sudo rpm -Uvh --force mita-3.30.0-1.aarch64.rpm
```
上述指令也可以用来升级 mita 软件包的版本。
+1 -1
View File
@@ -16,5 +16,5 @@
package version
const (
AppVersion = "3.29.0"
AppVersion = "3.30.0"
)
+1 -1
View File
@@ -36,7 +36,7 @@ type SudokuOption struct {
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"
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii", "prefer_entropy", or directional "up_ascii_down_entropy"/"up_entropy_down_ascii"
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
HTTPMask *bool `proxy:"http-mask,omitempty"`
HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto", "ws"
+181 -47
View File
@@ -24,6 +24,7 @@ import (
"github.com/metacubex/sing-vmess/packetaddr"
M "github.com/metacubex/sing/common/metadata"
"github.com/metacubex/tls"
"github.com/samber/lo"
)
type Vless struct {
@@ -35,6 +36,8 @@ type Vless struct {
// for gun mux
gunTransport *gun.Transport
// for xhttp
dialXHTTPConn func() (net.Conn, error)
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -72,12 +75,35 @@ type VlessOption struct {
}
type XHTTPOptions struct {
Path string `proxy:"path,omitempty"`
Host string `proxy:"host,omitempty"`
Mode string `proxy:"mode,omitempty"`
Headers map[string]string `proxy:"headers,omitempty"`
NoGRPCHeader bool `proxy:"no-grpc-header,omitempty"`
XPaddingBytes string `proxy:"x-padding-bytes,omitempty"`
Path string `proxy:"path,omitempty"`
Host string `proxy:"host,omitempty"`
Mode string `proxy:"mode,omitempty"`
Headers map[string]string `proxy:"headers,omitempty"`
NoGRPCHeader bool `proxy:"no-grpc-header,omitempty"`
XPaddingBytes string `proxy:"x-padding-bytes,omitempty"`
DownloadSettings *XHTTPDownloadSettings `proxy:"download-settings,omitempty"`
}
type XHTTPDownloadSettings struct {
// xhttp part
Path *string `proxy:"path,omitempty"`
Host *string `proxy:"host,omitempty"`
Headers *map[string]string `proxy:"headers,omitempty"`
NoGRPCHeader *bool `proxy:"no-grpc-header,omitempty"`
XPaddingBytes *string `proxy:"x-padding-bytes,omitempty"`
// proxy part
Server *string `proxy:"server,omitempty"`
Port *int `proxy:"port,omitempty"`
TLS *bool `proxy:"tls,omitempty"`
ALPN *[]string `proxy:"alpn,omitempty"`
ECHOpts *ECHOptions `proxy:"ech-opts,omitempty"`
RealityOpts *RealityOptions `proxy:"reality-opts,omitempty"`
SkipCertVerify *bool `proxy:"skip-cert-verify,omitempty"`
Fingerprint *string `proxy:"fingerprint,omitempty"`
Certificate *string `proxy:"certificate,omitempty"`
PrivateKey *string `proxy:"private-key,omitempty"`
ServerName *string `proxy:"servername,omitempty"`
ClientFingerprint *string `proxy:"client-fingerprint,omitempty"`
}
func (v *Vless) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.Metadata) (_ net.Conn, err error) {
@@ -241,51 +267,12 @@ func (v *Vless) streamTLSConn(ctx context.Context, conn net.Conn, isH2 bool) (ne
return conn, nil
}
func (v *Vless) dialXHTTPConn() (net.Conn, error) {
requestHost := v.option.XHTTPOpts.Host
if requestHost == "" {
if v.option.ServerName != "" {
requestHost = v.option.ServerName
} else {
requestHost = v.option.Server
}
}
cfg := &xhttp.Config{
Host: requestHost,
Path: v.option.XHTTPOpts.Path,
Mode: v.option.XHTTPOpts.Mode,
Headers: v.option.XHTTPOpts.Headers,
NoGRPCHeader: v.option.XHTTPOpts.NoGRPCHeader,
XPaddingBytes: v.option.XHTTPOpts.XPaddingBytes,
}
mode := cfg.EffectiveMode(v.realityConfig != nil)
transport := xhttp.NewTransport(
func(ctx context.Context) (net.Conn, error) {
return v.dialer.DialContext(ctx, "tcp", v.addr)
},
func(ctx context.Context, raw net.Conn, isH2 bool) (net.Conn, error) {
return v.streamTLSConn(ctx, raw, isH2)
},
)
switch mode {
case "stream-one":
return xhttp.DialStreamOne(cfg, transport)
case "packet-up":
return xhttp.DialPacketUp(cfg, transport)
default:
return nil, fmt.Errorf("xhttp mode %s is not implemented yet", mode)
}
}
func (v *Vless) dialContext(ctx context.Context) (c net.Conn, err error) {
switch v.option.Network {
case "xhttp":
return v.dialXHTTPConn()
case "grpc": // gun transport
return v.gunTransport.Dial()
case "xhttp":
return v.dialXHTTPConn()
default:
}
return v.dialer.DialContext(ctx, "tcp", v.addr)
@@ -501,6 +488,153 @@ func NewVless(option VlessOption) (*Vless, error) {
}
v.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
case "xhttp":
requestHost := v.option.XHTTPOpts.Host
if requestHost == "" {
if v.option.ServerName != "" {
requestHost = v.option.ServerName
} else {
requestHost = v.option.Server
}
}
cfg := &xhttp.Config{
Host: requestHost,
Path: v.option.XHTTPOpts.Path,
Mode: v.option.XHTTPOpts.Mode,
Headers: v.option.XHTTPOpts.Headers,
NoGRPCHeader: v.option.XHTTPOpts.NoGRPCHeader,
XPaddingBytes: v.option.XHTTPOpts.XPaddingBytes,
}
makeTransport := func() http.RoundTripper {
return xhttp.NewTransport(
func(ctx context.Context) (net.Conn, error) {
return v.dialer.DialContext(ctx, "tcp", v.addr)
},
func(ctx context.Context, raw net.Conn, isH2 bool) (net.Conn, error) {
return v.streamTLSConn(ctx, raw, isH2)
},
)
}
var makeDownloadTransport func() http.RoundTripper
if ds := v.option.XHTTPOpts.DownloadSettings; ds != nil {
if cfg.Mode == "stream-one" {
return nil, fmt.Errorf(`xhttp mode "stream-one" cannot be used with download-settings`)
}
downloadServer := lo.FromPtrOr(ds.Server, v.option.Server)
downloadPort := lo.FromPtrOr(ds.Port, v.option.Port)
downloadTLS := lo.FromPtrOr(ds.TLS, v.option.TLS)
downloadALPN := lo.FromPtrOr(ds.ALPN, v.option.ALPN)
downloadEchConfig := v.echConfig
if ds.ECHOpts != nil {
downloadEchConfig, err = ds.ECHOpts.Parse()
if err != nil {
return nil, err
}
}
downloadRealityCfg := v.realityConfig
if ds.RealityOpts != nil {
downloadRealityCfg, err = ds.RealityOpts.Parse()
if err != nil {
return nil, err
}
}
downloadSkipCertVerify := lo.FromPtrOr(ds.SkipCertVerify, v.option.SkipCertVerify)
downloadFingerprint := lo.FromPtrOr(ds.Fingerprint, v.option.Fingerprint)
downloadCertificate := lo.FromPtrOr(ds.Certificate, v.option.Certificate)
downloadPrivateKey := lo.FromPtrOr(ds.PrivateKey, v.option.PrivateKey)
downloadServerName := lo.FromPtrOr(ds.ServerName, v.option.ServerName)
downloadClientFingerprint := lo.FromPtrOr(ds.ClientFingerprint, v.option.ClientFingerprint)
downloadAddr := net.JoinHostPort(downloadServer, strconv.Itoa(downloadPort))
downloadHost := lo.FromPtrOr(ds.Host, v.option.XHTTPOpts.Host)
if downloadHost == "" {
if downloadServerName != "" {
downloadHost = downloadServerName
} else {
downloadHost = downloadServer
}
}
cfg.DownloadConfig = &xhttp.Config{
Host: downloadHost,
Path: lo.FromPtrOr(ds.Path, v.option.XHTTPOpts.Path),
Mode: v.option.XHTTPOpts.Mode,
Headers: lo.FromPtrOr(ds.Headers, v.option.XHTTPOpts.Headers),
NoGRPCHeader: lo.FromPtrOr(ds.NoGRPCHeader, v.option.XHTTPOpts.NoGRPCHeader),
XPaddingBytes: lo.FromPtrOr(ds.XPaddingBytes, v.option.XHTTPOpts.XPaddingBytes),
}
makeDownloadTransport = func() http.RoundTripper {
return xhttp.NewTransport(
func(ctx context.Context) (net.Conn, error) {
return v.dialer.DialContext(ctx, "tcp", downloadAddr)
},
func(ctx context.Context, conn net.Conn, isH2 bool) (net.Conn, error) {
if downloadTLS {
host, _, _ := net.SplitHostPort(downloadAddr)
tlsOpts := vmess.TLSConfig{
Host: host,
SkipCertVerify: downloadSkipCertVerify,
FingerPrint: downloadFingerprint,
Certificate: downloadCertificate,
PrivateKey: downloadPrivateKey,
ClientFingerprint: downloadClientFingerprint,
ECH: downloadEchConfig,
Reality: downloadRealityCfg,
NextProtos: downloadALPN,
}
if isH2 {
tlsOpts.NextProtos = []string{"h2"}
}
if downloadServerName != "" {
tlsOpts.Host = downloadServerName
}
return vmess.StreamTLSConn(ctx, conn, &tlsOpts)
}
return conn, nil
},
)
}
}
mode := cfg.EffectiveMode(v.realityConfig != nil)
switch mode {
case "stream-one":
v.dialXHTTPConn = func() (net.Conn, error) {
transport := makeTransport()
return xhttp.DialStreamOne(cfg, transport)
}
case "stream-up":
v.dialXHTTPConn = func() (net.Conn, error) {
transport := makeTransport()
downloadTransport := transport
if makeDownloadTransport != nil {
downloadTransport = makeDownloadTransport()
}
return xhttp.DialStreamUp(cfg, transport, downloadTransport)
}
case "packet-up":
v.dialXHTTPConn = func() (net.Conn, error) {
transport := makeTransport()
downloadTransport := transport
if makeDownloadTransport != nil {
downloadTransport = makeDownloadTransport()
}
return xhttp.DialPacketUp(cfg, transport, downloadTransport)
}
default:
return nil, fmt.Errorf("xhttp mode %s is not implemented yet", mode)
}
}
return v, nil
+69
View File
@@ -618,6 +618,75 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) {
anytls["udp"] = true
proxies = append(proxies, anytls)
case "mierus":
urlMieru, err := url.Parse(line)
if err != nil {
continue
}
query := urlMieru.Query()
server := urlMieru.Hostname()
if server == "" {
continue
}
username := urlMieru.User.Username()
password, _ := urlMieru.User.Password()
baseName := urlMieru.Fragment
if baseName == "" {
baseName = query.Get("profile")
}
if baseName == "" {
baseName = server
}
multiplexing := query.Get("multiplexing")
handshakeMode := query.Get("handshake-mode")
trafficPattern := query.Get("traffic-pattern")
portList := query["port"]
protocolList := query["protocol"]
if len(portList) == 0 || len(portList) != len(protocolList) {
continue
}
for i, port := range portList {
protocol := protocolList[i]
name := uniqueName(names, fmt.Sprintf("%s:%s/%s", baseName, port, protocol))
mieru := make(map[string]any, 15)
mieru["name"] = name
mieru["type"] = "mieru"
mieru["server"] = server
mieru["transport"] = protocol
mieru["udp"] = true
mieru["username"] = username
mieru["password"] = password
if strings.Contains(port, "-") {
mieru["port-range"] = port
} else {
portNum, err := strconv.Atoi(port)
if err != nil {
continue
}
mieru["port"] = portNum
}
if multiplexing != "" {
mieru["multiplexing"] = multiplexing
}
if handshakeMode != "" {
mieru["handshake-mode"] = handshakeMode
}
if trafficPattern != "" {
mieru["traffic-pattern"] = trafficPattern
}
proxies = append(proxies, mieru)
}
}
}
+96
View File
@@ -33,3 +33,99 @@ func TestConvertsV2Ray_normal(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, expected, proxies)
}
func TestConvertsV2RayMieru(t *testing.T) {
mierusTest := "mierus://user:pass@1.2.3.4?handshake-mode=HANDSHAKE_NO_WAIT&mtu=1400&multiplexing=MULTIPLEXING_HIGH&port=6666&port=9998-9999&port=6489&port=4896&profile=default&protocol=TCP&protocol=TCP&protocol=UDP&protocol=UDP&traffic-pattern=CCoQAQ"
expected := []map[string]any{
{
"name": "default:6666/TCP",
"type": "mieru",
"server": "1.2.3.4",
"port": 6666,
"transport": "TCP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
{
"name": "default:9998-9999/TCP",
"type": "mieru",
"server": "1.2.3.4",
"port-range": "9998-9999",
"transport": "TCP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
{
"name": "default:6489/UDP",
"type": "mieru",
"server": "1.2.3.4",
"port": 6489,
"transport": "UDP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
{
"name": "default:4896/UDP",
"type": "mieru",
"server": "1.2.3.4",
"port": 4896,
"transport": "UDP",
"udp": true,
"username": "user",
"password": "pass",
"multiplexing": "MULTIPLEXING_HIGH",
"handshake-mode": "HANDSHAKE_NO_WAIT",
"traffic-pattern": "CCoQAQ",
},
}
proxies, err := ConvertsV2Ray([]byte(mierusTest))
assert.Nil(t, err)
assert.Equal(t, expected, proxies)
}
func TestConvertsV2RayMieruMinimal(t *testing.T) {
mierusTest := "mierus://user:pass@example.com?port=443&protocol=TCP&profile=simple"
expected := []map[string]any{
{
"name": "simple:443/TCP",
"type": "mieru",
"server": "example.com",
"port": 443,
"transport": "TCP",
"udp": true,
"username": "user",
"password": "pass",
},
}
proxies, err := ConvertsV2Ray([]byte(mierusTest))
assert.Nil(t, err)
assert.Equal(t, expected, proxies)
}
func TestConvertsV2RayMieruFragment(t *testing.T) {
mierusTest := "mierus://user:pass@example.com?port=443&protocol=TCP&profile=default#myproxy"
proxies, err := ConvertsV2Ray([]byte(mierusTest))
assert.Nil(t, err)
assert.Len(t, proxies, 1)
assert.Equal(t, "myproxy:443/TCP", proxies[0]["name"])
}
+39 -25
View File
@@ -799,17 +799,45 @@ proxies: # socks5
network: xhttp
alpn:
- h2
client-fingerprint: chrome
# ech-opts: ...
# reality-opts: ...
# skip-cert-verify: false
# fingerprint: ...
# certificate: ...
# private-key: ...
servername: xxx.com
client-fingerprint: chrome
encryption: ""
xhttp-opts:
path: "/"
host: xxx.com
# mode: "stream-one" # Available: "stream-one" or "packet-up"
# mode: "stream-one" # Available: "stream-one", "stream-up" or "packet-up"
# headers:
# X-Forwarded-For: ""
# no-grpc-header: false
# x-padding-bytes: "100-1000"
# download-settings:
# ## xhttp part
# path: "/"
# host: xxx.com
# headers:
# X-Forwarded-For: ""
# no-grpc-header: false
# x-padding-bytes: "100-1000"
# ## proxy part
# server: server
# port: 443
# tls: true
# alpn:
# - h2
# ech-opts: ...
# reality-opts: ...
# skip-cert-verify: false
# fingerprint: ...
# certificate: ...
# private-key: ...
# servername: xxx.com
# client-fingerprint: chrome
# Trojan
@@ -1115,25 +1143,17 @@ proxies: # socks5
aead-method: chacha20-poly1305 # 可选:chacha20-poly1305、aes-128-gcm、none(不建议;且 enable-pure-downlink=false 时不可用)
padding-min: 2 # 最小填充率(0-100
padding-max: 7 # 最大填充率(0-100,必须 >= padding-min
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
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy、up_ascii_down_entropy、up_entropy_down_ascii
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合;只对 entropy 方向生效
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),非空时覆盖 custom-table
# 推荐:使用 httpmask 对象统一管理 HTTPMask 相关字段:
httpmask:
disable: false # true 禁用所有 HTTP 伪装/隧道
mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# tls: true # 可选:仅在 mode 为 stream/poll/auto/ws 时生效;true 强制 https/wssfalse 强制 http/ws(不会根据端口自动推断)
# tls: true # 可选:按需开启 HTTPS/WSS
# host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 mode 为 stream/poll/auto/ws 时生效
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload、/aabbcc/ws
# multiplex: off # 可选:off(默认)、auto(复用底层 HTTP 连接,减少建链 RTT)、on(Sudoku mux 单隧道多目标;仅在 mode=stream/poll/auto 生效;ws 强制 off
#
# 向后兼容旧写法:
# http-mask: true # 是否启用 http 掩码
# http-mask-mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto/ws 时生效;true 强制 https/wssfalse 强制 http/ws
# http-mask-host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 http-mask-mode 为 stream/poll/auto/ws 时生效
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致)
# http-mask-multiplex: off # 可选:off(默认)、auto(复用底层 HTTP 连接)、onSudoku mux 单隧道多目标;ws 强制 off)
# multiplex: "off" # 可选字符串:off(默认)、auto(复用底层 HTTP 连接,减少建链 RTT)、on(Sudoku mux 单隧道多目标;仅在 mode=stream/poll/auto 生效;ws 强制 off
enable-pure-downlink: false # 可选:false=带宽优化下行(更快,要求 aead-method != none);true=纯 Sudoku 下行
# anytls
@@ -1694,9 +1714,9 @@ listeners:
aead-method: chacha20-poly1305 # 可选:chacha20-poly1305、aes-128-gcm、none(不建议;且 enable-pure-downlink=false 时不可用)
padding-min: 1 # 最小填充率(0-100
padding-max: 15 # 最大填充率(0-100,必须 >= padding-min
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
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy、up_ascii_down_entropy、up_entropy_down_ascii
# custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合;只对 entropy 方向生效
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于多表轮换;非空时覆盖 custom-table
handshake-timeout: 5 # 可选(秒)
enable-pure-downlink: false # 可选:false=带宽优化下行(更快,要求 aead-method != none);true=纯 Sudoku 下行
# 推荐:使用 httpmask 对象统一管理 HTTPMask 相关字段:
@@ -1705,13 +1725,7 @@ listeners:
mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload、/aabbcc/ws
#
# 可选:当启用 HTTPMask 且识别到“像 HTTP 但不符合 tunnel/auth”的请求时,将原始字节透传给 fallback(常用于与其他服务共端口):
# fallback: "127.0.0.1:80"
#
# 向后兼容旧写法:
# disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false
# http-mask-mode: legacy # 可选:legacy(默认)、streamsplit-stream)、poll、auto(先 stream 再 poll)、wsWebSocket 隧道)
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致)
# fallback: "127.0.0.1:80" # 可选:用于可连接请求的回落转发,可与其他服务共端口
+1 -1
View File
@@ -6,7 +6,7 @@ require (
github.com/bahlo/generic-list-go v0.2.0
github.com/coreos/go-iptables v0.8.0
github.com/dlclark/regexp2 v1.11.5
github.com/enfein/mieru/v3 v3.29.0
github.com/enfein/mieru/v3 v3.30.0
github.com/gobwas/ws v1.4.0
github.com/gofrs/uuid/v5 v5.4.0
github.com/golang/snappy v1.0.0
+2 -2
View File
@@ -20,8 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/enfein/mieru/v3 v3.29.0 h1:i5Hwl5spEWg4ydvYW86zWSYVJ2uGTf5sLYQmFXHdulQ=
github.com/enfein/mieru/v3 v3.29.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/enfein/mieru/v3 v3.30.0 h1:g7v0TuK7y0ZMn6TOdjOs8WEUQk8bvs6WYPBJ16SKdBU=
github.com/enfein/mieru/v3 v3.30.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
+1 -1
View File
@@ -17,7 +17,7 @@ type SudokuOption struct {
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"
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii", "prefer_entropy", or directional "up_ascii_down_entropy"/"up_entropy_down_ascii"
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
+53
View File
@@ -391,3 +391,56 @@ func TestInboundVless_Reality_XHTTP(t *testing.T) {
}
testInboundVless(t, inboundOptions, outboundOptions)
}
func TestInboundVless_XHTTP_DownloadSettings(t *testing.T) {
for _, mode := range []string{"stream-up", "packet-up"} {
t.Run(mode, func(t *testing.T) {
inboundOptions := inbound.VlessOption{
Certificate: tlsCertificate,
PrivateKey: tlsPrivateKey,
XHTTPConfig: inbound.XHTTPConfig{
Path: "/vless-xhttp",
Host: "example.com",
Mode: mode,
},
}
outboundOptions := outbound.VlessOption{
TLS: true,
Fingerprint: tlsFingerprint,
ServerName: "example.org",
ClientFingerprint: "chrome",
Network: "xhttp",
XHTTPOpts: outbound.XHTTPOptions{
Path: "/vless-xhttp",
Host: "example.com",
Mode: mode,
DownloadSettings: &outbound.XHTTPDownloadSettings{},
},
}
testInboundVlessTLS(t, inboundOptions, outboundOptions, false)
})
}
}
func TestInboundVless_XHTTP_StreamUp(t *testing.T) {
inboundOptions := inbound.VlessOption{
Certificate: tlsCertificate,
PrivateKey: tlsPrivateKey,
XHTTPConfig: inbound.XHTTPConfig{
Path: "/vless-xhttp",
Host: "example.com",
Mode: "stream-up",
},
}
outboundOptions := outbound.VlessOption{
TLS: true,
Fingerprint: tlsFingerprint,
Network: "xhttp",
XHTTPOpts: outbound.XHTTPOptions{
Path: "/vless-xhttp",
Host: "example.com",
Mode: "stream-up",
},
}
testInboundVlessTLS(t, inboundOptions, outboundOptions, false)
}
+1 -1
View File
@@ -155,7 +155,7 @@ func New(options LC.Tun, tunnel C.Tunnel, additions ...inbound.Addition) (l *Lis
}
forwarderBindInterface := false
if options.FileDescriptor > 0 {
if tunnelName, err := getTunnelName(int32(options.FileDescriptor)); err != nil {
if tunnelName, err := getTunnelName(int32(options.FileDescriptor)); err == nil {
tunName = tunnelName // sing-tun must have the truth tun interface name even it from a fd
forwarderBindInterface = true
}
+1 -1
View File
@@ -148,7 +148,7 @@ func New(config LC.VlessServer, tunnel C.Tunnel, additions ...inbound.Addition)
}
if config.XHTTPConfig.Mode != "" {
switch config.XHTTPConfig.Mode {
case "auto":
case "auto", "stream-up", "stream-one", "packet-up":
default:
return nil, errors.New("unsupported xhttp mode")
}
+4 -7
View File
@@ -195,14 +195,11 @@ func ResolvePadding(min, max *int, defMin, defMax int) (int, int) {
}
func NormalizeTableType(tableType string) (string, error) {
switch t := strings.ToLower(strings.TrimSpace(tableType)); t {
case "", "prefer_ascii":
return "prefer_ascii", nil
case "prefer_entropy":
return "prefer_entropy", nil
default:
return "", fmt.Errorf("table-type must be prefer_ascii or prefer_entropy")
normalized, err := sudoku.NormalizeASCIIMode(tableType)
if err != nil {
return "", fmt.Errorf("table-type must be prefer_ascii, prefer_entropy, up_ascii_down_entropy, or up_entropy_down_ascii")
}
return normalized, nil
}
func (c *ProtocolConfig) tableCandidates() []*sudoku.Table {
@@ -0,0 +1,119 @@
package sudoku
import (
"bytes"
"io"
"net"
"testing"
"github.com/metacubex/mihomo/transport/sudoku/crypto"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
func TestDirectionalCustomTableRotationHintRoundTrip(t *testing.T) {
key := "directional-rotate-key"
target := "8.8.8.8:53"
serverTables, err := NewServerTablesWithCustomPatterns(ClientAEADSeed(key), "up_ascii_down_entropy", "", []string{"xpxvvpvv", "vxpvxvvp"})
if err != nil {
t.Fatalf("server tables: %v", err)
}
if len(serverTables) != 2 {
t.Fatalf("expected 2 server tables, got %d", len(serverTables))
}
clientTable, err := sudokuobfs.NewTableWithCustom(ClientAEADSeed(key), "up_ascii_down_entropy", "vxpvxvvp")
if err != nil {
t.Fatalf("client table: %v", err)
}
serverCfg := DefaultConfig()
serverCfg.Key = key
serverCfg.AEADMethod = "chacha20-poly1305"
serverCfg.Tables = serverTables
serverCfg.PaddingMin = 0
serverCfg.PaddingMax = 0
serverCfg.EnablePureDownlink = true
serverCfg.HandshakeTimeoutSeconds = 5
serverCfg.DisableHTTPMask = true
clientCfg := DefaultConfig()
*clientCfg = *serverCfg
clientCfg.ServerAddress = "example.com:443"
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
serverErr := make(chan error, 1)
go func() {
defer close(serverErr)
defer serverConn.Close()
c, meta, err := ServerHandshake(serverConn, serverCfg)
if err != nil {
serverErr <- err
return
}
defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
serverErr <- err
return
}
if session.Type != SessionTypeTCP || session.Target != target {
serverErr <- io.ErrUnexpectedEOF
return
}
payload := make([]byte, len("client-payload"))
if _, err := io.ReadFull(session.Conn, payload); err != nil {
serverErr <- err
return
}
if !bytes.Equal(payload, []byte("client-payload")) {
serverErr <- io.ErrUnexpectedEOF
return
}
if _, err := session.Conn.Write([]byte("server-reply")); err != nil {
serverErr <- err
}
}()
seed := ClientAEADSeed(clientCfg.Key)
obfsConn := buildClientObfsConn(clientConn, clientCfg, clientTable)
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
cConn, err := crypto.NewRecordConn(obfsConn, clientCfg.AEADMethod, pskC2S, pskS2C)
if err != nil {
t.Fatalf("setup crypto: %v", err)
}
defer cConn.Close()
if _, err := kipHandshakeClient(cConn, seed, kipUserHashFromKey(clientCfg.Key), KIPFeatAll, clientTable.Hint(), true); err != nil {
t.Fatalf("client handshake: %v", err)
}
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode target: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write target: %v", err)
}
if _, err := cConn.Write([]byte("client-payload")); err != nil {
t.Fatalf("write payload: %v", err)
}
reply := make([]byte, len("server-reply"))
if _, err := io.ReadFull(cConn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
if !bytes.Equal(reply, []byte("server-reply")) {
t.Fatalf("unexpected reply: %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatalf("server: %v", err)
}
}
+18 -20
View File
@@ -86,23 +86,23 @@ func (a earlyDummyAddr) String() string { return string(a) }
func buildEarlyClientObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
base := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkReader == nil {
return base
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, packed, base)
return newDirectionalConn(raw, downlinkReader, base)
}
func buildEarlyServerObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
uplink := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkWriter == nil {
return uplink
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, uplink, packed, packed.Flush)
return newDirectionalConn(raw, uplink, downlinkWriter, closers...)
}
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
if table == nil {
return nil, fmt.Errorf("nil table")
}
@@ -120,13 +120,7 @@ func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash
var clientPub [kipHelloPubSize]byte
copy(clientPub[:], ephemeral.PublicKey().Bytes())
hello := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
hello := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint)
mem := newEarlyMemoryConn(nil)
obfsConn := buildEarlyClientObfsConn(mem, cfg, table)
@@ -208,8 +202,8 @@ func (s *EarlyClientState) Ready() bool {
return s != nil && s.responseSet
}
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
state, err := NewEarlyClientState(cfg, table, userHash, feats)
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
state, err := NewEarlyClientState(cfg, table, tableHint, hasTableHint, userHash, feats)
if err != nil {
return nil, err
}
@@ -231,7 +225,7 @@ func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table,
var firstErr error
for _, table := range tables {
state, err := processEarlyClientPayloadForTable(cfg, table, payload, allowReplay)
state, err := processEarlyClientPayloadForTable(cfg, tables, table, payload, allowReplay)
if err == nil {
return state, nil
}
@@ -245,7 +239,7 @@ func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table,
return nil, firstErr
}
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
mem := newEarlyMemoryConn(payload)
obfsConn := buildEarlyServerObfsConn(mem, cfg, table)
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
@@ -273,6 +267,10 @@ func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.T
if allowReplay != nil && !allowReplay(userHash, ch.Nonce, time.Now()) {
return nil, fmt.Errorf("replay detected")
}
resolvedTable, err := ResolveClientHelloTable(table, tables, ch)
if err != nil {
return nil, fmt.Errorf("resolve table hint failed: %w", err)
}
curve := ecdh.X25519()
serverEphemeral, err := curve.GenerateKey(rand.Reader)
@@ -297,7 +295,7 @@ func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.T
}
respMem := newEarlyMemoryConn(nil)
respObfs := buildEarlyServerObfsConn(respMem, cfg, table)
respObfs := buildEarlyServerObfsConn(respMem, cfg, resolvedTable)
respConn, err := crypto.NewRecordConn(respObfs, cfg.AEAD, pskS2C, pskC2S)
if err != nil {
return nil, fmt.Errorf("server early crypto setup failed: %w", err)
@@ -310,7 +308,7 @@ func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.T
ResponsePayload: respMem.Written(),
UserHash: userHash,
cfg: cfg,
table: table,
table: resolvedTable,
sessionC2S: sessionC2S,
sessionS2C: sessionS2C,
}, nil
+235
View File
@@ -92,3 +92,238 @@ func TestCustomTablesRotation_ProbedByServer(t *testing.T) {
}
}
}
func TestDirectionalTrafficRoundTrip(t *testing.T) {
tests := []struct {
name string
mode string
pure bool
}{
{name: "UpASCII_DownEntropy_Pure", mode: "up_ascii_down_entropy", pure: true},
{name: "UpASCII_DownEntropy_Packed", mode: "up_ascii_down_entropy", pure: false},
{name: "UpEntropy_DownASCII_Pure", mode: "up_entropy_down_ascii", pure: true},
{name: "UpEntropy_DownASCII_Packed", mode: "up_entropy_down_ascii", pure: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "directional-test-key-" + tt.name
target := "8.8.8.8:53"
table, err := sudokuobfs.NewTableWithCustom(ClientAEADSeed(key), tt.mode, "xpxvvpvv")
if err != nil {
t.Fatalf("table: %v", err)
}
serverCfg := DefaultConfig()
serverCfg.Key = key
serverCfg.AEADMethod = "chacha20-poly1305"
serverCfg.Table = table
serverCfg.PaddingMin = 0
serverCfg.PaddingMax = 0
serverCfg.EnablePureDownlink = tt.pure
serverCfg.HandshakeTimeoutSeconds = 5
serverCfg.DisableHTTPMask = true
clientCfg := DefaultConfig()
*clientCfg = *serverCfg
clientCfg.ServerAddress = "example.com:443"
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
serverErr := make(chan error, 1)
go func() {
defer close(serverErr)
defer serverConn.Close()
c, meta, err := ServerHandshake(serverConn, serverCfg)
if err != nil {
serverErr <- err
return
}
defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
serverErr <- err
return
}
if session.Type != SessionTypeTCP {
serverErr <- io.ErrUnexpectedEOF
return
}
if session.Target != target {
serverErr <- io.ErrClosedPipe
return
}
want := []byte("client-payload")
got := make([]byte, len(want))
if _, err := io.ReadFull(session.Conn, got); err != nil {
serverErr <- err
return
}
if !bytes.Equal(got, want) {
serverErr <- io.ErrUnexpectedEOF
return
}
if _, err := session.Conn.Write([]byte("server-reply")); err != nil {
serverErr <- err
return
}
}()
cConn, err := ClientHandshake(clientConn, clientCfg)
if err != nil {
t.Fatalf("client handshake: %v", err)
}
defer cConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode target: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write target: %v", err)
}
if _, err := cConn.Write([]byte("client-payload")); err != nil {
t.Fatalf("write payload: %v", err)
}
reply := make([]byte, len("server-reply"))
if _, err := io.ReadFull(cConn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
if !bytes.Equal(reply, []byte("server-reply")) {
t.Fatalf("unexpected reply: %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatalf("server: %v", err)
}
})
}
}
func TestDirectionalTrafficRoundTripTCP(t *testing.T) {
tests := []struct {
name string
mode string
pure bool
}{
{name: "UpASCII_DownEntropy_Pure_TCP", mode: "up_ascii_down_entropy", pure: true},
{name: "UpEntropy_DownASCII_Packed_TCP", mode: "up_entropy_down_ascii", pure: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "directional-tcp-test-key-" + tt.name
target := "127.0.0.1:18080"
table, err := sudokuobfs.NewTableWithCustom(ClientAEADSeed(key), tt.mode, "xpxvvpvv")
if err != nil {
t.Fatalf("table: %v", err)
}
serverCfg := DefaultConfig()
serverCfg.Key = key
serverCfg.AEADMethod = "chacha20-poly1305"
serverCfg.Table = table
serverCfg.PaddingMin = 0
serverCfg.PaddingMax = 0
serverCfg.EnablePureDownlink = tt.pure
serverCfg.HandshakeTimeoutSeconds = 5
serverCfg.DisableHTTPMask = true
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
serverErr := make(chan error, 1)
go func() {
defer close(serverErr)
raw, err := ln.Accept()
if err != nil {
serverErr <- err
return
}
defer raw.Close()
c, meta, err := ServerHandshake(raw, serverCfg)
if err != nil {
serverErr <- err
return
}
defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
serverErr <- err
return
}
if session.Type != SessionTypeTCP || session.Target != target {
serverErr <- io.ErrUnexpectedEOF
return
}
want := []byte("client-payload")
got := make([]byte, len(want))
if _, err := io.ReadFull(session.Conn, got); err != nil {
serverErr <- err
return
}
if !bytes.Equal(got, want) {
serverErr <- io.ErrUnexpectedEOF
return
}
if _, err := session.Conn.Write([]byte("server-reply")); err != nil {
serverErr <- err
return
}
}()
clientCfg := DefaultConfig()
*clientCfg = *serverCfg
clientCfg.ServerAddress = ln.Addr().String()
raw, err := net.Dial("tcp", clientCfg.ServerAddress)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer raw.Close()
cConn, err := ClientHandshake(raw, clientCfg)
if err != nil {
t.Fatalf("client handshake: %v", err)
}
defer cConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode target: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write target: %v", err)
}
if _, err := cConn.Write([]byte("client-payload")); err != nil {
t.Fatalf("write payload: %v", err)
}
reply := make([]byte, len("server-reply"))
if _, err := io.ReadFull(cConn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
if !bytes.Equal(reply, []byte("server-reply")) {
t.Fatalf("unexpected reply: %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatalf("server: %v", err)
}
})
}
}
+62 -9
View File
@@ -184,6 +184,14 @@ func (c *directionalConn) Write(p []byte) (int, error) {
return c.writer.Write(p)
}
func (c *directionalConn) ReplaceWriter(writer io.Writer, closers ...func() error) {
if c == nil {
return
}
c.writer = writer
c.closers = closers
}
func (c *directionalConn) Close() error {
var firstErr error
for _, fn := range c.closers {
@@ -227,22 +235,55 @@ func absInt64(v int64) int64 {
return v
}
func oppositeDirectionTable(table *sudoku.Table) *sudoku.Table {
if table == nil {
return nil
}
if other := table.OppositeDirection(); other != nil {
return other
}
return table
}
func newClientDownlinkReader(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) io.Reader {
downlinkTable := oppositeDirectionTable(table)
if pureDownlink {
if downlinkTable == table {
return nil
}
return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false)
}
return sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax)
}
func newServerDownlinkWriter(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) (io.Writer, []func() error) {
downlinkTable := oppositeDirectionTable(table)
if pureDownlink {
if downlinkTable == table {
return nil, nil
}
return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false), nil
}
packed := sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax)
return packed, []func() error{packed.Flush}
}
func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn {
baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkReader == nil {
return baseSudoku
}
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, packed, baseSudoku)
return newDirectionalConn(raw, downlinkReader, baseSudoku)
}
func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) {
uplinkSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record)
if cfg.EnablePureDownlink {
downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
if downlinkWriter == nil {
return uplinkSudoku, uplinkSudoku
}
packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, packed, packed.Flush)
return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, downlinkWriter, closers...)
}
func isLegacyHTTPMaskMode(mode string) bool {
@@ -269,20 +310,20 @@ func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) {
}
}
table, err := pickClientTable(cfg)
choice, err := pickClientTable(cfg)
if err != nil {
return nil, err
}
seed := ClientAEADSeed(cfg.Key)
obfsConn := buildClientObfsConn(rawConn, cfg, table)
obfsConn := buildClientObfsConn(rawConn, cfg, choice.Table)
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskC2S, pskS2C)
if err != nil {
return nil, fmt.Errorf("setup crypto failed: %w", err)
}
if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll); err != nil {
if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll, choice.Hint, choice.HasHint); err != nil {
_ = rc.Close()
return nil, err
}
@@ -393,6 +434,18 @@ func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, *Handshak
if !globalHandshakeReplay.allow(userHashHex, ch.Nonce, time.Now()) {
return nil, nil, &SuspiciousError{Err: fmt.Errorf("replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
}
resolvedTable, err := ResolveClientHelloTable(selectedTable, cfg.tableCandidates(), ch)
if err != nil {
return nil, nil, &SuspiciousError{Err: fmt.Errorf("resolve table hint failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
}
if resolvedTable != selectedTable {
downlinkWriter, closers := newServerDownlinkWriter(baseConn, resolvedTable, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
switchable, ok := obfsConn.(*directionalConn)
if !ok {
return nil, nil, &SuspiciousError{Err: fmt.Errorf("switch downlink writer failed"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
}
switchable.ReplaceWriter(downlinkWriter, closers...)
}
curve := ecdh.X25519()
serverEphemeral, err := curve.GenerateKey(rand.Reader)
+2 -8
View File
@@ -12,7 +12,7 @@ import (
const kipHandshakeSkew = 60 * time.Second
func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32) (uint32, error) {
func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32, tableHint uint32, hasTableHint bool) (uint32, error) {
if rc == nil {
return 0, fmt.Errorf("nil conn")
}
@@ -31,13 +31,7 @@ func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUs
var clientPub [kipHelloPubSize]byte
copy(clientPub[:], ephemeral.PublicKey().Bytes())
ch := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
ch := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint)
if err := WriteKIPMessage(rc, KIPTypeClientHello, ch.EncodePayload()); err != nil {
return 0, fmt.Errorf("write client hello failed: %w", err)
}
+4 -2
View File
@@ -25,14 +25,16 @@ func newHTTPMaskEarlyCodecConfig(cfg *ProtocolConfig, psk string) EarlyCodecConf
}
func newClientHTTPMaskEarlyHandshake(cfg *ProtocolConfig) (*httpmask.ClientEarlyHandshake, error) {
table, err := pickClientTable(cfg)
choice, err := pickClientTable(cfg)
if err != nil {
return nil, err
}
return NewHTTPMaskClientEarlyHandshake(
newHTTPMaskEarlyCodecConfig(cfg, ClientAEADSeed(cfg.Key)),
table,
choice.Table,
choice.Hint,
choice.HasHint,
kipUserHashFromKey(cfg.Key),
KIPFeatAll,
)
@@ -451,6 +451,64 @@ func TestHTTPMaskTunnel_EarlyHandshake_TCPRoundTrip(t *testing.T) {
}
}
func TestHTTPMaskTunnel_EarlyHandshake_AutoPathRoot_TCPRoundTrip(t *testing.T) {
key := "tunnel-early-auto-pathroot"
target := "1.1.1.1:80"
serverCfg := newTunnelTestTable(t, key)
serverCfg.HTTPMaskMode = "auto"
serverCfg.HTTPMaskPathRoot = "httpmaskpath"
addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error {
if s.Type != SessionTypeTCP {
return fmt.Errorf("unexpected session type: %v", s.Type)
}
if s.Target != target {
return fmt.Errorf("target mismatch: %s", s.Target)
}
_, _ = s.Conn.Write([]byte("ok"))
return nil
})
defer stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientCfg := *serverCfg
clientCfg.ServerAddress = addr
handshakeCfg := clientCfg
handshakeCfg.DisableHTTPMask = true
tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, func(raw net.Conn) (net.Conn, error) {
return ClientHandshake(raw, &handshakeCfg)
})
if err != nil {
t.Fatalf("dial tunnel: %v", err)
}
defer tunnelConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode addr: %v", err)
}
if err := WriteKIPMessage(tunnelConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err)
}
buf := make([]byte, 2)
if _, err := io.ReadFull(tunnelConn, buf); err != nil {
t.Fatalf("read: %v", err)
}
if string(buf) != "ok" {
t.Fatalf("unexpected payload: %q", buf)
}
stop()
for err := range errCh {
t.Fatalf("server error: %v", err)
}
}
func TestHTTPMaskTunnel_Validation(t *testing.T) {
cfg := DefaultConfig()
cfg.Key = "k"
+10 -6
View File
@@ -39,16 +39,20 @@ func ClientAEADSeed(key string) string {
}
// Client-side key material can be:
// - public key: 32 bytes hex compressed point
// - split private key: 64 bytes hex (r||k)
// - master private scalar: 32 bytes hex (x)
// - PSK string: non-hex
//
// We intentionally do NOT treat a 32-byte hex as a public key here; the client is expected
// to carry private material. Server-side should use ServerAEADSeed for public keys.
switch len(b) {
case 64:
case 32:
default:
// 32-byte hex is ambiguous: it can be either a compressed public key or a
// master private scalar. Official Sudoku runtime accepts public keys directly,
// so when the bytes already decode as a point, preserve that point verbatim.
if len(b) == 32 {
if p, err := new(edwards25519.Point).SetBytes(b); err == nil {
return hex.EncodeToString(p.Bytes())
}
}
if len(b) != 64 && len(b) != 32 {
return key
}
if recovered, err := crypto.RecoverPublicKey(key); err == nil {
+22 -9
View File
@@ -15,23 +15,36 @@ func TestClientAEADSeed_IsStableForPrivAndPub(t *testing.T) {
require.NoError(t, err)
require.Equal(t, pub, ClientAEADSeed(priv))
require.Equal(t, pub, ClientAEADSeed(pub))
require.Equal(t, pub, ServerAEADSeed(pub))
require.Equal(t, pub, ServerAEADSeed(priv))
}
}
func TestClientAEADSeed_Supports32ByteMasterScalar(t *testing.T) {
var seed [64]byte
_, err := rand.Read(seed[:])
require.NoError(t, err)
for i := 0; i < 256; i++ {
var seed [64]byte
_, err := rand.Read(seed[:])
require.NoError(t, err)
s, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
require.NoError(t, err)
s, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
require.NoError(t, err)
keyHex := hex.EncodeToString(s.Bytes())
require.Len(t, keyHex, 64)
require.NotEqual(t, keyHex, ClientAEADSeed(keyHex))
require.Equal(t, ClientAEADSeed(keyHex), ServerAEADSeed(ClientAEADSeed(keyHex)))
keyHex := hex.EncodeToString(s.Bytes())
require.Len(t, keyHex, 64)
// 32-byte hex is ambiguous: it can be either a master scalar or an
// already-compressed public key. Public-key encoding wins when both parse.
if _, err := new(edwards25519.Point).SetBytes(s.Bytes()); err == nil {
continue
}
require.NotEqual(t, keyHex, ClientAEADSeed(keyHex))
require.Equal(t, ClientAEADSeed(keyHex), ServerAEADSeed(ClientAEADSeed(keyHex)))
return
}
t.Fatal("failed to generate an unambiguous 32-byte master scalar")
}
func TestServerAEADSeed_LeavesPublicKeyAsIs(t *testing.T) {
+64 -5
View File
@@ -10,6 +10,8 @@ import (
"io"
"strings"
"time"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
const (
@@ -42,6 +44,8 @@ const (
kipMaxPayload = 64 * 1024
)
const kipClientHelloTableHintSize = 4
var errKIP = errors.New("kip protocol error")
type KIPMessage struct {
@@ -98,11 +102,13 @@ func ReadKIPMessage(r io.Reader) (*KIPMessage, error) {
}
type KIPClientHello struct {
Timestamp time.Time
UserHash [kipHelloUserHashSize]byte
Nonce [kipHelloNonceSize]byte
ClientPub [kipHelloPubSize]byte
Features uint32
Timestamp time.Time
UserHash [kipHelloUserHashSize]byte
Nonce [kipHelloNonceSize]byte
ClientPub [kipHelloPubSize]byte
Features uint32
TableHint uint32
HasTableHint bool
}
type KIPServerHello struct {
@@ -111,6 +117,18 @@ type KIPServerHello struct {
SelectedFeats uint32
}
func newKIPClientHello(userHash [kipHelloUserHashSize]byte, nonce [kipHelloNonceSize]byte, clientPub [kipHelloPubSize]byte, feats uint32, tableHint uint32, hasTableHint bool) *KIPClientHello {
return &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
TableHint: tableHint,
HasTableHint: hasTableHint,
}
}
func kipUserHashFromKey(psk string) [kipHelloUserHashSize]byte {
var out [kipHelloUserHashSize]byte
psk = strings.TrimSpace(psk)
@@ -147,6 +165,11 @@ func (m *KIPClientHello) EncodePayload() []byte {
var f [4]byte
binary.BigEndian.PutUint32(f[:], m.Features)
b.Write(f[:])
if m.HasTableHint {
var hint [kipClientHelloTableHintSize]byte
binary.BigEndian.PutUint32(hint[:], m.TableHint)
b.Write(hint[:])
}
return b.Bytes()
}
@@ -166,9 +189,45 @@ func DecodeKIPClientHelloPayload(payload []byte) (*KIPClientHello, error) {
copy(h.ClientPub[:], payload[off:off+kipHelloPubSize])
off += kipHelloPubSize
h.Features = binary.BigEndian.Uint32(payload[off : off+4])
off += 4
if len(payload) >= off+kipClientHelloTableHintSize {
h.TableHint = binary.BigEndian.Uint32(payload[off : off+kipClientHelloTableHintSize])
h.HasTableHint = true
}
return &h, nil
}
func ResolveClientHelloTable(selected *sudokuobfs.Table, candidates []*sudokuobfs.Table, hello *KIPClientHello) (*sudokuobfs.Table, error) {
if selected == nil {
return nil, fmt.Errorf("nil selected table")
}
if hello == nil || !hello.HasTableHint {
return selected, nil
}
if selected.Hint() == hello.TableHint {
return selected, nil
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no table candidates")
}
var hinted *sudokuobfs.Table
for _, candidate := range candidates {
if candidate == nil || candidate.Hint() != hello.TableHint {
continue
}
hinted = candidate
break
}
if hinted == nil {
return nil, fmt.Errorf("unknown table hint: %d", hello.TableHint)
}
if hinted != selected && (!hinted.IsASCII || !selected.IsASCII) {
return nil, fmt.Errorf("table hint %d mismatches probed uplink table", hello.TableHint)
}
return hinted, nil
}
func (m *KIPServerHello) EncodePayload() []byte {
var b bytes.Buffer
b.Write(m.Nonce[:])
+64
View File
@@ -0,0 +1,64 @@
package sudoku
import (
"testing"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
func TestKIPClientHelloTableHintRoundTrip(t *testing.T) {
hello := &KIPClientHello{
Features: KIPFeatAll,
TableHint: 0x12345678,
HasTableHint: true,
}
decoded, err := DecodeKIPClientHelloPayload(hello.EncodePayload())
if err != nil {
t.Fatalf("decode client hello: %v", err)
}
if !decoded.HasTableHint {
t.Fatalf("expected decoded hello to carry table hint")
}
if decoded.TableHint != hello.TableHint {
t.Fatalf("decoded table hint = %08x, want %08x", decoded.TableHint, hello.TableHint)
}
}
func TestResolveClientHelloTableAllowsDirectionalASCIIRotation(t *testing.T) {
tables, err := NewClientTablesWithCustomPatterns("seed", "up_ascii_down_entropy", "", []string{"xpxvvpvv", "vxpvxvvp"})
if err != nil {
t.Fatalf("build tables: %v", err)
}
if len(tables) != 2 {
t.Fatalf("expected 2 tables, got %d", len(tables))
}
selected, err := ResolveClientHelloTable(tables[0], tables, &KIPClientHello{
TableHint: tables[1].Hint(),
HasTableHint: true,
})
if err != nil {
t.Fatalf("resolve client hello table: %v", err)
}
if selected != tables[1] {
t.Fatalf("resolved table mismatch")
}
}
func TestResolveClientHelloTableRejectsEntropyMismatch(t *testing.T) {
a, err := sudokuobfs.NewTableWithCustom("seed", "prefer_entropy", "xpxvvpvv")
if err != nil {
t.Fatalf("table a: %v", err)
}
b, err := sudokuobfs.NewTableWithCustom("seed", "prefer_entropy", "vxpvxvvp")
if err != nil {
t.Fatalf("table b: %v", err)
}
if _, err := ResolveClientHelloTable(a, []*sudokuobfs.Table{a, b}, &KIPClientHello{
TableHint: b.Hint(),
HasTableHint: true,
}); err == nil {
t.Fatalf("expected entropy-table mismatch to fail")
}
}
@@ -0,0 +1,93 @@
package sudoku
import (
"fmt"
"strings"
)
const (
asciiModeTokenASCII = "ascii"
asciiModeTokenEntropy = "entropy"
)
// ASCIIMode describes the preferred wire layout for each traffic direction.
// Uplink is client->server, Downlink is server->client.
type ASCIIMode struct {
Uplink string
Downlink string
}
// ParseASCIIMode accepts legacy symmetric values ("ascii"/"entropy"/"prefer_*")
// and directional values like "up_ascii_down_entropy".
func ParseASCIIMode(mode string) (ASCIIMode, error) {
raw := strings.ToLower(strings.TrimSpace(mode))
switch raw {
case "", "entropy", "prefer_entropy":
return ASCIIMode{Uplink: asciiModeTokenEntropy, Downlink: asciiModeTokenEntropy}, nil
case "ascii", "prefer_ascii":
return ASCIIMode{Uplink: asciiModeTokenASCII, Downlink: asciiModeTokenASCII}, nil
}
if !strings.HasPrefix(raw, "up_") {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
parts := strings.SplitN(strings.TrimPrefix(raw, "up_"), "_down_", 2)
if len(parts) != 2 {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
up, ok := normalizeASCIIModeToken(parts[0])
if !ok {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
down, ok := normalizeASCIIModeToken(parts[1])
if !ok {
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
}
return ASCIIMode{Uplink: up, Downlink: down}, nil
}
// NormalizeASCIIMode returns the canonical config string for a supported mode.
func NormalizeASCIIMode(mode string) (string, error) {
parsed, err := ParseASCIIMode(mode)
if err != nil {
return "", err
}
return parsed.Canonical(), nil
}
func (m ASCIIMode) Canonical() string {
if m.Uplink == asciiModeTokenASCII && m.Downlink == asciiModeTokenASCII {
return "prefer_ascii"
}
if m.Uplink == asciiModeTokenEntropy && m.Downlink == asciiModeTokenEntropy {
return "prefer_entropy"
}
return "up_" + m.Uplink + "_down_" + m.Downlink
}
func (m ASCIIMode) uplinkPreference() string {
return singleDirectionPreference(m.Uplink)
}
func (m ASCIIMode) downlinkPreference() string {
return singleDirectionPreference(m.Downlink)
}
func normalizeASCIIModeToken(token string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(token)) {
case "ascii", "prefer_ascii":
return asciiModeTokenASCII, true
case "entropy", "prefer_entropy", "":
return asciiModeTokenEntropy, true
default:
return "", false
}
}
func singleDirectionPreference(token string) string {
if token == asciiModeTokenASCII {
return "prefer_ascii"
}
return "prefer_entropy"
}
@@ -0,0 +1,56 @@
package sudoku
import "testing"
func TestNormalizeASCIIMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", "prefer_entropy"},
{"entropy", "prefer_entropy"},
{"prefer_ascii", "prefer_ascii"},
{"up_ascii_down_entropy", "up_ascii_down_entropy"},
{"up_entropy_down_ascii", "up_entropy_down_ascii"},
{"up_prefer_ascii_down_prefer_entropy", "up_ascii_down_entropy"},
}
for _, tt := range tests {
got, err := NormalizeASCIIMode(tt.in)
if err != nil {
t.Fatalf("NormalizeASCIIMode(%q): %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("NormalizeASCIIMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
if _, err := NormalizeASCIIMode("up_ascii_down_binary"); err == nil {
t.Fatalf("expected invalid directional mode to fail")
}
}
func TestNewTableWithCustomDirectionalOpposite(t *testing.T) {
table, err := NewTableWithCustom("seed", "up_ascii_down_entropy", "xpxvvpvv")
if err != nil {
t.Fatalf("NewTableWithCustom: %v", err)
}
if !table.IsASCII {
t.Fatalf("uplink table should be ascii")
}
opposite := table.OppositeDirection()
if opposite == nil || opposite == table {
t.Fatalf("expected distinct opposite table")
}
if opposite.IsASCII {
t.Fatalf("downlink table should be entropy/custom")
}
symmetric, err := NewTableWithCustom("seed", "prefer_ascii", "xpxvvpvv")
if err != nil {
t.Fatalf("NewTableWithCustom symmetric: %v", err)
}
if symmetric.OppositeDirection() != symmetric {
t.Fatalf("symmetric table should point to itself")
}
}
+1 -2
View File
@@ -163,8 +163,7 @@ func (sc *Conn) Write(p []byte) (n int, err error) {
out = append(out, pads[sc.rng.Intn(padLen)])
}
_, err = sc.Conn.Write(out)
return len(p), err
return len(p), writeFull(sc.Conn, out)
}
func (sc *Conn) Read(p []byte) (n int, err error) {
@@ -27,7 +27,7 @@ func (l *byteLayout) isHint(b byte) bool {
return l.name == "ascii" && b == '\n'
}
// resolveLayout picks the byte layout based on ASCII preference and optional custom pattern.
// resolveLayout picks the byte layout for a single traffic direction.
// ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred.
func resolveLayout(mode string, customPattern string) (*byteLayout, error) {
switch strings.ToLower(mode) {
+68 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"errors"
"math/rand"
"strings"
)
var (
@@ -17,6 +18,8 @@ type Table struct {
PaddingPool []byte
IsASCII bool // 标记当前模式
layout *byteLayout
opposite *Table
hint uint32
}
// NewTable initializes the obfuscation tables with built-in layouts.
@@ -29,10 +32,41 @@ func NewTable(key string, mode string) *Table {
return t
}
// NewTableWithCustom initializes obfuscation tables using either predefined or custom layouts.
// mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence.
// NewTableWithCustom initializes the uplink/probe Sudoku table using either predefined
// or directional layouts. Directional modes such as "up_ascii_down_entropy" return the
// client->server table and internally attach the opposite direction table for runtime use.
// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive).
func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) {
asciiMode, err := ParseASCIIMode(mode)
if err != nil {
return nil, err
}
uplinkPattern := customPatternForToken(asciiMode.Uplink, customPattern)
downlinkPattern := customPatternForToken(asciiMode.Downlink, customPattern)
hint := tableHintFingerprint(key, asciiMode.Canonical(), uplinkPattern, downlinkPattern)
uplink, err := newSingleDirectionTable(key, asciiMode.uplinkPreference(), uplinkPattern)
if err != nil {
return nil, err
}
uplink.hint = hint
if asciiMode.Uplink == asciiMode.Downlink {
uplink.opposite = uplink
return uplink, nil
}
downlink, err := newSingleDirectionTable(key, asciiMode.downlinkPreference(), downlinkPattern)
if err != nil {
return nil, err
}
downlink.hint = hint
uplink.opposite = downlink
downlink.opposite = uplink
return uplink, nil
}
func newSingleDirectionTable(key string, mode string, customPattern string) (*Table, error) {
layout, err := resolveLayout(mode, customPattern)
if err != nil {
return nil, err
@@ -125,6 +159,38 @@ func NewTableWithCustom(key string, mode string, customPattern string) (*Table,
return t, nil
}
func customPatternForToken(token string, customPattern string) string {
if token == asciiModeTokenEntropy {
return customPattern
}
return ""
}
func (t *Table) OppositeDirection() *Table {
if t == nil || t.opposite == nil {
return t
}
return t.opposite
}
func (t *Table) Hint() uint32 {
if t == nil {
return 0
}
return t.hint
}
func tableHintFingerprint(key string, mode string, uplinkPattern string, downlinkPattern string) uint32 {
sum := sha256.Sum256([]byte(strings.Join([]string{
"sudoku-table-hint",
key,
mode,
strings.ToLower(strings.TrimSpace(uplinkPattern)),
strings.ToLower(strings.TrimSpace(downlinkPattern)),
}, "\x00")))
return binary.BigEndian.Uint32(sum[:4])
}
func packHintsToKey(hints [4]byte) uint32 {
// Sorting network for 4 elements (Bubble sort unrolled)
// Swap if a > b
+11 -5
View File
@@ -14,20 +14,26 @@ import (
"github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, error) {
type clientTableChoice struct {
Table *sudoku.Table
Hint uint32
HasHint bool
}
func pickClientTable(cfg *ProtocolConfig) (clientTableChoice, error) {
candidates := cfg.tableCandidates()
if len(candidates) == 0 {
return nil, fmt.Errorf("no table configured")
return clientTableChoice{}, fmt.Errorf("no table configured")
}
if len(candidates) == 1 {
return candidates[0], nil
return clientTableChoice{Table: candidates[0], Hint: candidates[0].Hint()}, nil
}
var b [1]byte
if _, err := crand.Read(b[:]); err != nil {
return nil, fmt.Errorf("random table pick failed: %w", err)
return clientTableChoice{}, fmt.Errorf("random table pick failed: %w", err)
}
idx := int(b[0]) % len(candidates)
return candidates[idx], nil
return clientTableChoice{Table: candidates[idx], Hint: candidates[idx].Hint(), HasHint: true}, nil
}
type readOnlyConn struct {
+23 -5
View File
@@ -17,12 +17,23 @@ func normalizeCustomPatterns(customTable string, customTables []string) []string
return patterns
}
func normalizeTablePatterns(tableType string, customTable string, customTables []string) ([]string, error) {
patterns := normalizeCustomPatterns(customTable, customTables)
if _, err := sudoku.ParseASCIIMode(tableType); err != nil {
return nil, err
}
return patterns, nil
}
// 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).
//
// Deprecated-ish: prefer NewClientTablesWithCustomPatterns / NewServerTablesWithCustomPatterns.
func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
patterns := normalizeCustomPatterns(customTable, customTables)
patterns, err := normalizeTablePatterns(tableType, customTable, customTables)
if err != nil {
return nil, err
}
tables := make([]*sudoku.Table, 0, len(patterns))
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
@@ -39,11 +50,18 @@ func NewClientTablesWithCustomPatterns(key string, tableType string, customTable
return NewTablesWithCustomPatterns(key, tableType, customTable, customTables)
}
// NewServerTablesWithCustomPatterns matches upstream server behavior: when custom table rotation is enabled,
// also accept the default table to avoid forcing clients to update in lockstep.
// NewServerTablesWithCustomPatterns matches upstream server behavior: when probeable custom table
// rotation is enabled, also accept the default table to avoid forcing clients to update in lockstep.
func NewServerTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
patterns := normalizeCustomPatterns(customTable, customTables)
if len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" {
patterns, err := normalizeTablePatterns(tableType, customTable, customTables)
if err != nil {
return nil, err
}
asciiMode, err := sudoku.ParseASCIIMode(tableType)
if err != nil {
return nil, err
}
if asciiMode.Uplink == "entropy" && len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" {
patterns = append([]string{""}, patterns...)
}
return NewTablesWithCustomPatterns(key, tableType, "", patterns)
@@ -0,0 +1,45 @@
package sudoku
import "testing"
func TestDirectionalCustomTableRotationCollapse(t *testing.T) {
patterns := []string{"xpxvvpvv", "vxpvxvvp"}
clientTables, err := NewClientTablesWithCustomPatterns("seed", "up_ascii_down_entropy", "", patterns)
if err != nil {
t.Fatalf("client tables: %v", err)
}
if len(clientTables) != 2 {
t.Fatalf("expected ascii-uplink directional rotation to keep 2 tables, got %d", len(clientTables))
}
if clientTables[0].Hint() == clientTables[1].Hint() {
t.Fatalf("expected directional custom tables to carry distinct hints")
}
if got, want := clientTables[0].EncodeTable[0][0], clientTables[1].EncodeTable[0][0]; got != want {
t.Fatalf("expected directional ascii uplink tables to share the same probe layout, got %x want %x", got, want)
}
if got, want := clientTables[0].OppositeDirection().EncodeTable[0][0], clientTables[1].OppositeDirection().EncodeTable[0][0]; got == want {
t.Fatalf("expected directional downlink custom layouts to differ, both got %x", got)
}
clientTables, err = NewClientTablesWithCustomPatterns("seed", "up_entropy_down_ascii", "", patterns)
if err != nil {
t.Fatalf("client tables entropy uplink: %v", err)
}
if len(clientTables) != 2 {
t.Fatalf("expected entropy-uplink rotation to keep 2 tables, got %d", len(clientTables))
}
serverTables, err := NewServerTablesWithCustomPatterns("seed", "up_ascii_down_entropy", "", patterns)
if err != nil {
t.Fatalf("server tables: %v", err)
}
if len(serverTables) != 2 {
t.Fatalf("expected ascii-uplink server directional table set to keep 2 tables, got %d", len(serverTables))
}
if clientTables, err = NewClientTablesWithCustomPatterns("seed", "up_ascii_down_entropy", patterns[0], nil); err != nil {
t.Fatalf("client table with single custom pattern: %v", err)
} else if got, want := serverTables[0].OppositeDirection().EncodeTable[0][0], clientTables[0].OppositeDirection().EncodeTable[0][0]; got != want {
t.Fatalf("expected server directional downlink table to preserve custom pattern, got %x want %x", got, want)
}
}
+141 -11
View File
@@ -107,7 +107,7 @@ func DialStreamOne(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
}
req.Host = cfg.Host
if err := cfg.FillStreamRequest(req); err != nil {
if err := cfg.FillStreamRequest(req, ""); err != nil {
_ = pr.Close()
_ = pw.Close()
return nil, err
@@ -129,7 +129,6 @@ func DialStreamOne(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
}
conn.reader = resp.Body
conn.onClose = func() {
_ = resp.Body.Close()
_ = pr.Close()
httputils.CloseTransport(transport)
}
@@ -137,13 +136,136 @@ func DialStreamOne(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
return conn, nil
}
func DialPacketUp(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
func DialStreamUp(cfg *Config, uploadTransport http.RoundTripper, downloadTransport http.RoundTripper) (net.Conn, error) {
downloadCfg := cfg
if ds := cfg.DownloadConfig; ds != nil {
downloadCfg = ds
}
streamURL := url.URL{
Scheme: "https",
Host: cfg.Host,
Path: cfg.NormalizedPath(),
}
downloadURL := url.URL{
Scheme: "https",
Host: downloadCfg.Host,
Path: downloadCfg.NormalizedPath(),
}
pr, pw := io.Pipe()
ctx := context.Background()
conn := &Conn{writer: pw}
sessionID := newSessionID()
downloadReq, err := http.NewRequestWithContext(
httputils.NewAddrContext(&conn.NetAddr, ctx),
http.MethodGet,
downloadURL.String(),
nil,
)
if err != nil {
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
if err := downloadCfg.FillDownloadRequest(downloadReq, sessionID); err != nil {
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
downloadReq.Host = downloadCfg.Host
downloadResp, err := downloadTransport.RoundTrip(downloadReq)
if err != nil {
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
if downloadResp.StatusCode != http.StatusOK {
_ = downloadResp.Body.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, fmt.Errorf("xhttp stream-up download bad status: %s", downloadResp.Status)
}
uploadReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
streamURL.String(),
pr,
)
if err != nil {
_ = downloadResp.Body.Close()
_ = pr.Close()
_ = pw.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
if err := cfg.FillStreamRequest(uploadReq, sessionID); err != nil {
_ = downloadResp.Body.Close()
_ = pr.Close()
_ = pw.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, err
}
uploadReq.Host = cfg.Host
go func() {
resp, err := uploadTransport.RoundTrip(uploadReq)
if err != nil {
_ = pw.CloseWithError(err)
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = pw.CloseWithError(fmt.Errorf("xhttp stream-up upload bad status: %s", resp.Status))
}
}()
conn.reader = downloadResp.Body
conn.onClose = func() {
_ = pr.Close()
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
}
return conn, nil
}
func DialPacketUp(cfg *Config, uploadTransport http.RoundTripper, downloadTransport http.RoundTripper) (net.Conn, error) {
downloadCfg := cfg
if ds := cfg.DownloadConfig; ds != nil {
downloadCfg = ds
}
sessionID := newSessionID()
downloadURL := url.URL{
Scheme: "https",
Host: cfg.Host,
Path: cfg.NormalizedPath(),
Host: downloadCfg.Host,
Path: downloadCfg.NormalizedPath(),
}
ctx := context.Background()
@@ -151,30 +273,38 @@ func DialPacketUp(cfg *Config, transport http.RoundTripper) (net.Conn, error) {
ctx: ctx,
cfg: cfg,
sessionID: sessionID,
transport: transport,
transport: uploadTransport,
seq: 0,
}
conn := &Conn{writer: writer}
req, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, ctx), http.MethodGet, downloadURL.String(), nil)
downloadReq, err := http.NewRequestWithContext(httputils.NewAddrContext(&conn.NetAddr, ctx), http.MethodGet, downloadURL.String(), nil)
if err != nil {
return nil, err
}
if err := cfg.FillDownloadRequest(req, sessionID); err != nil {
if err := downloadCfg.FillDownloadRequest(downloadReq, sessionID); err != nil {
return nil, err
}
req.Host = cfg.Host
downloadReq.Host = downloadCfg.Host
resp, err := transport.RoundTrip(req)
resp, err := downloadTransport.RoundTrip(downloadReq)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
httputils.CloseTransport(transport)
httputils.CloseTransport(uploadTransport)
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
return nil, fmt.Errorf("xhttp packet-up download bad status: %s", resp.Status)
}
conn.reader = resp.Body
conn.onClose = func() {
if downloadTransport != uploadTransport {
httputils.CloseTransport(downloadTransport)
}
}
return conn, nil
}
+22 -7
View File
@@ -12,12 +12,22 @@ import (
)
type Config struct {
Host string
Path string
Mode string
Headers map[string]string
NoGRPCHeader bool
XPaddingBytes string
Host string
Path string
Mode string
Headers map[string]string
NoGRPCHeader bool
XPaddingBytes string
DownloadConfig *Config
}
type DownloadConfig struct {
Host string
Path string
Mode string
ServerName string
ClientFingerprint string
SkipCertVerify bool
}
func (c *Config) NormalizedMode() string {
@@ -33,6 +43,9 @@ func (c *Config) EffectiveMode(hasReality bool) string {
return mode
}
if hasReality {
if c.DownloadConfig != nil {
return "stream-up"
}
return "stream-one"
}
return "packet-up"
@@ -126,7 +139,7 @@ func parseRange(s string) (int, int, error) {
return minVal, maxVal, nil
}
func (c *Config) FillStreamRequest(req *http.Request) error {
func (c *Config) FillStreamRequest(req *http.Request, sessionID string) error {
req.Header = c.RequestHeader()
paddingValue, err := c.RandomPadding()
@@ -143,6 +156,8 @@ func (c *Config) FillStreamRequest(req *http.Request) error {
req.Header.Set("Referer", rawURL+sep+"x_padding="+paddingValue)
}
c.ApplyMetaToRequest(req, sessionID, "")
if req.Body != nil && !c.NoGRPCHeader {
req.Header.Set("Content-Type", "application/grpc")
}
+4 -10
View File
@@ -1,6 +1,7 @@
package xhttp
import (
"errors"
"io"
"time"
@@ -26,19 +27,12 @@ func (c *Conn) Read(b []byte) (int, error) {
}
func (c *Conn) Close() error {
err := c.writer.Close()
err2 := c.reader.Close()
if c.onClose != nil {
c.onClose()
}
err := c.writer.Close()
err2 := c.reader.Close()
if err != nil {
return err
}
if err2 != nil {
return err2
}
return nil
return errors.Join(err, err2)
}
func (c *Conn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) }
+39
View File
@@ -242,6 +242,45 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// stream-up upload: POST /path/{session}
if r.Method == http.MethodPost && len(parts) == 1 {
sessionID := parts[0]
session := h.getSession(sessionID)
if session == nil {
http.Error(w, "unknown xhttp session", http.StatusBadRequest)
return
}
buf := make([]byte, 32*1024)
var seq uint64
for {
n, err := r.Body.Read(buf)
if n > 0 {
if pushErr := session.uploadQueue.Push(Packet{
Seq: seq,
Payload: buf[:n],
}); pushErr != nil {
http.Error(w, pushErr.Error(), http.StatusInternalServerError)
return
}
seq++
}
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
// packet-up upload: POST /path/{session}/{seq}
if r.Method == http.MethodPost && len(parts) == 2 {
sessionID := parts[0]
+1 -1
View File
@@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.3.6
PKG_VERSION:=26.4.1
PKG_RELEASE:=1
PKG_PO_VERSION:=$(PKG_VERSION)
@@ -1063,7 +1063,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.allowInsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0')
);
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
@@ -1234,7 +1237,7 @@ local current_node = map:get(section)
var params;
for (var i = 0; i < queryArray.length; i++) {
params = queryArray[i].split('=');
queryParam[decodeURIComponent(params[0]).toLowerCase()] = decodeURIComponent(params[1] || '');
queryParam[decodeURIComponent(params[0])] = decodeURIComponent(params[1] || '');
}
}
opt.set(dom_prefix + 'address', unbracketIP(m.hostname));
@@ -1314,7 +1317,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls_CertSha', queryParam.pcs || '');
opt.set(dom_prefix + 'tls_CertByName', queryParam.vcn || '');
}
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.allowInsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0')
);
opt.set(dom_prefix + 'tcp_fast_open', queryParam.tfo);
opt.set(dom_prefix + 'use_finalmask', !!queryParam.fm);
opt.set(dom_prefix + 'finalmask', queryParam.fm || "");
@@ -1347,7 +1353,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls', ssm.tls === "tls");
if (ssm.tls === "tls") {
opt.set(dom_prefix + 'tls_serverName', ssm.sni || ssm.host);
opt.set(dom_prefix + 'tls_allowInsecure', !((ssm.allowinsecure ?? '0') === '0' && (ssm.insecure ?? '0') === '0'));
opt.set(
dom_prefix + 'tls_allowInsecure',
!((ssm.allowinsecure ?? '0') === '0' && (ssm.allowInsecure ?? '0') === '0' && (ssm.insecure ?? '0') === '0')
);
}
ssm.net = ssm.net.toLowerCase();
if (ssm.net === "kcp" || ssm.net === "mkcp")
@@ -1459,7 +1468,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.allowInsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0')
);
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
@@ -1617,7 +1629,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'port', m.port || "443");
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || "");
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.allowInsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0')
);
opt.set(dom_prefix + 'tls_CertSha', queryParam.pcs || '');
opt.set(dom_prefix + 'tls_CertByName', queryParam.vcn || '');
if (m.hash) {
@@ -1657,7 +1672,7 @@ local current_node = map:get(section)
var params;
for (i = 0; i < queryArray.length; i++) {
params = queryArray[i].split('=');
queryParam[decodeURIComponent(params[0])] = decodeURIComponent(params[1] || '');
queryParam[decodeURIComponent(params[0]).toLowerCase()] = decodeURIComponent(params[1] || '');
}
}
opt.set(dom_prefix + 'tuic_congestion_control', queryParam.congestion_control || 'cubic');
@@ -1665,7 +1680,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tuic_alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_disable_sni', queryParam.disable_sni === "1");
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0'));
opt.set(
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0')
);
if (hash) {
opt.set('remarks', decodeURIComponent(hash.substr(1)));
}
@@ -1693,7 +1711,7 @@ local current_node = map:get(section)
var params;
for (i = 0; i < queryArray.length; i++) {
params = queryArray[i].split('=');
queryParam[decodeURIComponent(params[0])] = decodeURIComponent(params[1] || '');
queryParam[decodeURIComponent(params[0]).toLowerCase()] = decodeURIComponent(params[1] || '');
}
}
if ((!queryParam.security || queryParam.security == "") && queryParam.sni && queryParam.sni != "") {
@@ -604,7 +604,7 @@ local function processData(szType, content, add_mode, group)
result.tls_serverName = (info.sni and info.sni ~= "") and info.sni or info.host
result.tls_CertSha = info.pcs
result.tls_CertByName = info.vcn
local insecure = info.allowinsecure or info.insecure
local insecure = info.allowinsecure or info.allowInsecure or info.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
else
result.tls = "0"
@@ -890,7 +890,7 @@ local function processData(szType, content, add_mode, group)
result.reality_mldsa65Verify = params.pqv or nil
end
end
local insecure = params.allowinsecure or params.insecure
local insecure = params.allowinsecure or params.allowInsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
result.uot = params.udp
elseif (params.type ~= "tcp" and params.type ~= "raw") and (params.headerType and params.headerType ~= "none") then
@@ -995,7 +995,7 @@ local function processData(szType, content, add_mode, group)
result.tls_serverName = params.peer or params.sni or ""
result.tls_CertSha = params.pcs
result.tls_CertByName = params.vcn
local insecure = params.allowinsecure or params.insecure
local insecure = params.allowinsecure or params.allowInsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
if not params.type then params.type = "tcp" end
@@ -1064,8 +1064,7 @@ local function processData(szType, content, add_mode, group)
result.quic_security = params.quicSecurity or "none"
end
if params.type == 'grpc' then
if params.path then result.grpc_serviceName = params.path end
if params.serviceName then result.grpc_serviceName = params.serviceName end
result.grpc_serviceName = params.serviceName or params.path
result.grpc_mode = params.mode or "gun"
end
if params.type == 'xhttp' then
@@ -1262,7 +1261,7 @@ local function processData(szType, content, add_mode, group)
result.use_mldsa65Verify = (params.pqv and params.pqv ~= "") and "1" or nil
result.reality_mldsa65Verify = params.pqv or nil
end
local insecure = params.allowinsecure or params.insecure
local insecure = params.allowinsecure or params.allowInsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
end
@@ -1319,7 +1318,7 @@ local function processData(szType, content, add_mode, group)
result.hysteria_auth_type = "string"
result.hysteria_auth_password = params.auth
result.tls_serverName = params.peer or params.sni or ""
local insecure = params.allowinsecure or params.insecure
local insecure = params.allowinsecure or params.allowInsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
result.alpn = params.alpn
result.hysteria_up_mbps = params.upmbps
@@ -1474,7 +1473,7 @@ local function processData(szType, content, add_mode, group)
for _, v in pairs(split(query[2], '&')) do
local s = v:find("=", 1, true)
if s and s > 1 then
params[v:sub(1, s - 1)] = UrlDecode(v:sub(s + 1))
params[v:sub(1, s - 1):lower()] = UrlDecode(v:sub(s + 1))
end
end
-- [2001:4860:4860::8888]:443
+3 -3
View File
@@ -300,16 +300,16 @@ dependencies = [
[[package]]
name = "blake3"
version = "1.8.3"
version = "1.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures 0.2.17",
"cpufeatures 0.3.0",
]
[[package]]
+2 -2
View File
@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=hysteria
PKG_VERSION:=2.8.0
PKG_VERSION:=2.8.1
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/apernet/hysteria/tar.gz/app/v$(PKG_VERSION)?
PKG_HASH:=7509c0ad77ec3f5828a962b7136d51c36e0b0ddde4b5fccdabc6403a75562720
PKG_HASH:=11cec7a7c0e366e1cc2ac9e0a83eb89fada88bb9089bc4b1f842dbf720dedf8d
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-app-v$(PKG_VERSION)
PKG_LICENSE:=MIT
@@ -133,11 +133,9 @@ o.validate = function(self, value)
return value:gsub("%s+", ""):gsub("%z", "")
end
o = s:option(Flag, "allowInsecure", translate("allowInsecure"))
o = s:option(Flag, "allowInsecure", translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped."))
o.default = "0"
o.rmempty = false
o.description = translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped.") .. "<br>" ..
translate("Used when the node link does not include this parameter.")
o = s:option(ListValue, "filter_keyword_mode", translate("Filter keyword Mode"))
o.default = "5"
@@ -1063,7 +1063,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_allowInsecure', true);
if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') {
opt.set(dom_prefix + 'tls_allowInsecure', false);
}
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
@@ -1314,7 +1317,11 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls_CertSha', queryParam.pcs || '');
opt.set(dom_prefix + 'tls_CertByName', queryParam.vcn || '');
}
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_allowInsecure', true);
if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') {
opt.set(dom_prefix + 'tls_allowInsecure', false);
}
opt.set(dom_prefix + 'tcp_fast_open', queryParam.tfo);
opt.set(dom_prefix + 'use_finalmask', !!queryParam.fm);
opt.set(dom_prefix + 'finalmask', queryParam.fm || "");
@@ -1346,8 +1353,11 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'uuid', ssm.id);
opt.set(dom_prefix + 'tls', ssm.tls === "tls");
if (ssm.tls === "tls") {
opt.set(dom_prefix + 'tls_serverName', ssm.sni || ssm.host);
opt.set(dom_prefix + 'tls_allowInsecure', !((ssm.allowinsecure ?? '0') === '0' && (ssm.insecure ?? '0') === '0'));
var tls_serverName = ssm.host;
if (ssm.sni) {
tls_serverName = ssm.sni
}
opt.set(dom_prefix + 'tls_serverName', tls_serverName);
}
ssm.net = ssm.net.toLowerCase();
if (ssm.net === "kcp" || ssm.net === "mkcp")
@@ -1459,7 +1469,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_allowInsecure', true);
if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') {
opt.set(dom_prefix + 'tls_allowInsecure', false);
}
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
@@ -1617,7 +1630,9 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'port', m.port || "443");
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || "");
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
if (queryParam.insecure && queryParam.insecure == "1") {
opt.set(dom_prefix + 'tls_allowInsecure', true);
}
opt.set(dom_prefix + 'tls_CertSha', queryParam.pcs || '');
opt.set(dom_prefix + 'tls_CertByName', queryParam.vcn || '');
if (m.hash) {
@@ -1665,7 +1680,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tuic_alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_disable_sni', queryParam.disable_sni === "1");
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_allowInsecure', true);
if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') {
opt.set(dom_prefix + 'tls_allowInsecure', false);
}
if (hash) {
opt.set('remarks', decodeURIComponent(hash.substr(1)));
}
@@ -1705,7 +1723,10 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_allowInsecure', true);
if ((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0') {
opt.set(dom_prefix + 'tls_allowInsecure', false);
}
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
@@ -1228,9 +1228,6 @@ msgstr "使用全局配置"
msgid "User-Agent"
msgstr "用户代理(User-Agent)"
msgid "Used when the node link does not include this parameter."
msgstr "当节点链接未包含该参数时,将使用此设置。"
msgid "Add"
msgstr "添加"
@@ -602,10 +602,14 @@ local function processData(szType, content, add_mode, group)
if info.tls == "tls" or info.tls == "1" then
result.tls = "1"
result.tls_serverName = (info.sni and info.sni ~= "") and info.sni or info.host
info.allowinsecure = info.allowinsecure or info.insecure
if info.allowinsecure and (info.allowinsecure == "1" or info.allowinsecure == "0") then
result.tls_allowInsecure = info.allowinsecure
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
result.tls_CertSha = info.pcs
result.tls_CertByName = info.vcn
local insecure = info.allowinsecure or info.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
else
result.tls = "0"
end
@@ -890,8 +894,12 @@ local function processData(szType, content, add_mode, group)
result.reality_mldsa65Verify = params.pqv or nil
end
end
local insecure = params.allowinsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure and (params.allowinsecure == "1" or params.allowinsecure == "0") then
result.tls_allowInsecure = params.allowinsecure
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
result.uot = params.udp
elseif (params.type ~= "tcp" and params.type ~= "raw") and (params.headerType and params.headerType ~= "none") then
result.error_msg = "请更换 Xray 或 Sing-Box 来支持 SS 更多的传输方式。"
@@ -995,8 +1003,18 @@ local function processData(szType, content, add_mode, group)
result.tls_serverName = params.peer or params.sni or ""
result.tls_CertSha = params.pcs
result.tls_CertByName = params.vcn
local insecure = params.allowinsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure then
if params.allowinsecure == "1" or params.allowinsecure == "0" then
result.tls_allowInsecure = params.allowinsecure
else
result.tls_allowInsecure = string.lower(params.allowinsecure) == "true" and "1" or "0"
end
--log(result.remarks .. ' 使用节点AllowInsecure设定: '.. result.tls_allowInsecure)
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
if not params.type then params.type = "tcp" end
params.type = string.lower(params.type)
@@ -1262,11 +1280,17 @@ local function processData(szType, content, add_mode, group)
result.use_mldsa65Verify = (params.pqv and params.pqv ~= "") and "1" or nil
result.reality_mldsa65Verify = params.pqv or nil
end
local insecure = params.allowinsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
end
result.port = port
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure and (params.allowinsecure == "1" or params.allowinsecure == "0") then
result.tls_allowInsecure = params.allowinsecure
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
result.tcp_fast_open = params.tfo
result.use_finalmask = (params.fm and params.fm ~= "") and "1" or nil
result.finalmask = (params.fm and params.fm ~= "") and api.base64Encode(params.fm) or nil
@@ -1319,8 +1343,13 @@ local function processData(szType, content, add_mode, group)
result.hysteria_auth_type = "string"
result.hysteria_auth_password = params.auth
result.tls_serverName = params.peer or params.sni or ""
local insecure = params.allowinsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure and (params.allowinsecure == "1" or params.allowinsecure == "0") then
result.tls_allowInsecure = params.allowinsecure
--log(result.remarks ..' 使用节点AllowInsecure设定: '.. result.tls_allowInsecure)
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
result.alpn = params.alpn
result.hysteria_up_mbps = params.upmbps
result.hysteria_down_mbps = params.downmbps
@@ -1365,8 +1394,13 @@ local function processData(szType, content, add_mode, group)
result.tls_serverName = params.sni
result.tls_CertSha = params.pcs
result.tls_CertByName = params.vcn
local insecure = params.allowinsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure and (params.allowinsecure == "1" or params.allowinsecure == "0") then
result.tls_allowInsecure = params.allowinsecure
--log(result.remarks ..' 使用节点AllowInsecure设定: '.. result.tls_allowInsecure)
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
result.hysteria2_tls_pinSHA256 = params.pinSHA256
result.hysteria2_hop = params.mport
@@ -1445,8 +1479,17 @@ local function processData(szType, content, add_mode, group)
result.tuic_alpn = params.alpn or "default"
result.tuic_congestion_control = params.congestion_control or "cubic"
result.tuic_udp_relay_mode = params.udp_relay_mode or "native"
local insecure = params.allowinsecure or params.insecure or params.allow_insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure then
if params.allowinsecure == "1" or params.allowinsecure == "0" then
result.tls_allowInsecure = params.allowinsecure
else
result.tls_allowInsecure = string.lower(params.allowinsecure) == "true" and "1" or "0"
end
--log(result.remarks .. ' 使用节点AllowInsecure设定: '.. result.tls_allowInsecure)
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
elseif szType == "anytls" then
if has_singbox then
result.type = 'sing-box'
@@ -1509,8 +1552,18 @@ local function processData(szType, content, add_mode, group)
end
end
result.port = port
local insecure = params.allowinsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (allowInsecure_default and "1" or "0")
params.allowinsecure = params.allowinsecure or params.insecure
if params.allowinsecure and (params.allowinsecure == "1" or params.allowinsecure == "0") then
result.tls_allowInsecure = params.allowinsecure
else
result.tls_allowInsecure = allowInsecure_default and "1" or "0"
end
local singbox_version = api.get_app_version("sing-box")
local version_ge_1_12 = api.compare_versions(singbox_version:match("[^v]+"), ">=", "1.12.0")
if not has_singbox or not version_ge_1_12 then
log("跳过节点:" .. result.remarks ..",因 " .. szType .. " 类型的节点需要 Sing-Box 1.12 以上版本支持。")
return nil
end
end
elseif szType == 'naive+https' or szType == 'naive+quic' then
if has_singbox then

Some files were not shown because too many files have changed in this diff Show More