mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-22 16:07:49 +08:00
Update On Tue Mar 31 21:15:58 CEST 2026
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# tls: true # 可选:仅在 mode 为 stream/poll/auto/ws 时生效;true 强制 https/wss;false 强制 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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto/ws 时生效;true 强制 https/wss;false 强制 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 连接)、on(Sudoku 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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# 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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致)
|
||||
# fallback: "127.0.0.1:80" # 可选:用于可连接请求的回落转发,可与其他服务共端口
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[:])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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,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,5 +1,5 @@
|
||||
Name: mita
|
||||
Version: 3.29.0
|
||||
Version: 3.30.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Mieru proxy server
|
||||
License: GPLv3+
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 软件包的版本。
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
package version
|
||||
|
||||
const (
|
||||
AppVersion = "3.29.0"
|
||||
AppVersion = "3.30.0"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# tls: true # 可选:仅在 mode 为 stream/poll/auto/ws 时生效;true 强制 https/wss;false 强制 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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto/ws 时生效;true 强制 https/wss;false 强制 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 连接)、on(Sudoku 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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# 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(默认)、stream(split-stream)、poll、auto(先 stream 再 poll)、ws(WebSocket 隧道)
|
||||
# path-root: "" # 可选:HTTP 隧道端点一级路径前缀(双方需一致)
|
||||
# fallback: "127.0.0.1:80" # 可选:用于可连接请求的回落转发,可与其他服务共端口
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[:])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+27
-9
@@ -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
|
||||
|
||||
Generated
+3
-3
@@ -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]]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user