diff --git a/.github/workflows/.golangci.yml b/.github/workflows/.golangci.yml new file mode 100644 index 0000000..db48597 --- /dev/null +++ b/.github/workflows/.golangci.yml @@ -0,0 +1,65 @@ +version: "2" +run: + tests: true +linters: + enable: + - bodyclose + - misspell + - revive + settings: + errcheck: + check-type-assertions: true + check-blank: true + exclude-functions: + - (net.PacketConn).WriteTo + - (net.Conn).Write + - encoding/json.MarshalIndent + - (*github.com/pion/dtls/v3.Conn).SetDeadline + govet: + disable: + - fieldalignment + enable-all: true + revive: + rules: + - name: blank-imports + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + exclude-rules: + - linters: + - errcheck + source: "doRequest|packetPool\\.Get" +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e26950..c913016 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25.5' + go-version: 'stable' - name: Cache Go modules uses: actions/cache@v5 @@ -47,7 +47,7 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.8.0 + version: latest args: --timeout=5m ./... env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -87,7 +87,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25.5' + go-version: 'stable' # Install Android NDK for android rows and expose ndk-path output - name: Setup Android NDK diff --git a/README.md b/README.md index 678875c..a21876c 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,120 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down +## VLESS-режим + +Если WireGuard блокируется DPI, можно использовать VLESS через флаг `-vless`. В этом режиме вместо UDP-пакетов пробрасываются TCP-соединения через TURN-туннель с помощью KCP и smux. + +### Настройка +1. На VPS установить Xray с VLESS inbound +2. Запустить `server` с флагом `-vless` +3. На клиенте запустить `client` с флагом `-vless` +4. Настроить Xray/v2rayN клиент с VLESS outbound на `127.0.0.1:9000` + +### Сервер (VPS) +``` +./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless +``` + +#### Docker +``` +docker run -p 56000:56000/udp -e CONNECT_ADDR=127.0.0.1:443 -e VLESS_MODE=true vk-turn-proxy +``` + +### Клиент +``` +./client -peer :56000 -vk-link -listen 127.0.0.1:9000 -vless +``` + +
+ + +Xray клиент (config.json) + + +```json +{ + "inbounds": [ + { + "protocol": "socks", + "listen": "127.0.0.1", + "port": 1080, + "settings": { + "udp": true + }, + "sniffing": { + "enabled": true, + "destOverride": ["http", "tls"] + } + } + ], + "outbounds": [ + { + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 9000, + "users": [ + { + "id": "", + "encryption": "none" + } + ] + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + } + ] +} +``` + +
+ +
+ + +Xray сервер (config.json) + + +```json +{ + "inbounds": [ + { + "protocol": "vless", + "listen": "127.0.0.1", + "port": 443, + "settings": { + "clients": [ + { + "id": "<тот же UUID>", + "level": 0 + } + ], + "decryption": "none" + } + } + ], + "outbounds": [ + { + "protocol": "freedom", + "settings": { + "domainStrategy": "UseIPv4" + } + } + ] +} +``` + +
+ + ## Direct mode С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от вк/яндекса. + diff --git a/client/main.go b/client/main.go index 5e8a3d2..6186914 100644 --- a/client/main.go +++ b/client/main.go @@ -33,6 +33,7 @@ import ( "github.com/bogdanfinn/tls-client/profiles" "github.com/bschaatsbergen/dnsdialer" + "github.com/cacggghp/vk-turn-proxy/tcputil" "github.com/cbeuw/connutil" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -41,6 +42,7 @@ import ( "github.com/pion/logging" "github.com/pion/transport/v4" "github.com/pion/turn/v5" + "github.com/xtaci/smux" ) type getCredsFunc func(ctx context.Context, link string, streamID int) (string, string, string, error) @@ -277,7 +279,7 @@ type VkCaptchaError struct { ErrorMsg string CaptchaSid string CaptchaImg string - RedirectUri string + RedirectURI string IsSoundCaptchaAvailable bool SessionToken string CaptchaTs string @@ -285,29 +287,65 @@ type VkCaptchaError struct { } func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { - codeFloat, _ := errData["error_code"].(float64) + // Extract error_code + codeFloat, ok := errData["error_code"].(float64) + if !ok { + log.Printf("missing error_code in captcha error data") + return nil + } code := int(codeFloat) - redirectUri, _ := errData["redirect_uri"].(string) - captchaSid, _ := errData["captcha_sid"].(string) - if captchaSid == "" { - if sidNum, ok := errData["captcha_sid"].(float64); ok { + // Extract redirect_uri + RedirectURI, ok := errData["redirect_uri"].(string) + if !ok { + log.Printf("missing redirect_uri in captcha error data") + return nil + } + + // Extract captcha_sid + captchaSid, ok := errData["captcha_sid"].(string) + if !ok { + // try numeric + if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 { captchaSid = fmt.Sprintf("%.0f", sidNum) + } else { + log.Printf("missing captcha_sid in captcha error data") + return nil } } - captchaImg, _ := errData["captcha_img"].(string) - errorMsg, _ := errData["error_msg"].(string) + // Extract captcha_img + captchaImg, ok := errData["captcha_img"].(string) + if !ok { + log.Printf("missing captcha_img in captcha error data") + return nil + } + + // Extract error_msg + errorMsg, ok := errData["error_msg"].(string) + if !ok { + log.Printf("missing error_msg in captcha error data") + return nil + } + // Extract session token if redirect_uri present var sessionToken string - if redirectUri != "" { - if parsed, err := neturl.Parse(redirectUri); err == nil { + if RedirectURI != "" { + if parsed, err := neturl.Parse(RedirectURI); err == nil { sessionToken = parsed.Query().Get("session_token") + } else { + log.Printf("failed to parse redirect_uri: %v", err) + return nil } } - isSound, _ := errData["is_sound_captcha_available"].(bool) + // Extract is_sound_captcha_available + isSound, ok := errData["is_sound_captcha_available"].(bool) + if !ok { + isSound = false + } + // Extract captcha_ts var captchaTs string if tsFloat, ok := errData["captcha_ts"].(float64); ok { captchaTs = fmt.Sprintf("%.0f", tsFloat) @@ -315,6 +353,7 @@ func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { captchaTs = tsStr } + // Extract captcha_attempt var captchaAttempt string if attFloat, ok := errData["captcha_attempt"].(float64); ok { captchaAttempt = fmt.Sprintf("%.0f", attFloat) @@ -322,12 +361,13 @@ func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { captchaAttempt = attStr } + // Build VkCaptchaError return &VkCaptchaError{ ErrorCode: code, ErrorMsg: errorMsg, CaptchaSid: captchaSid, CaptchaImg: captchaImg, - RedirectUri: redirectUri, + RedirectURI: RedirectURI, IsSoundCaptchaAvailable: isSound, SessionToken: sessionToken, CaptchaTs: captchaTs, @@ -336,7 +376,7 @@ func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { } func (e *VkCaptchaError) IsCaptchaError() bool { - return e.ErrorCode == 14 && e.RedirectUri != "" && e.SessionToken != "" + return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != "" } func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) { @@ -349,11 +389,11 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in if captchaErr.SessionToken == "" { return "", fmt.Errorf("no session_token in redirect_uri for auto-solve") } - if captchaErr.RedirectUri == "" { + if captchaErr.RedirectURI == "" { return "", fmt.Errorf("no redirect_uri for auto-solve") } - bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectUri, client, profile) + bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile) if err != nil { return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) } @@ -385,14 +425,14 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in return successToken, nil } -func fetchCaptchaBootstrap(ctx context.Context, redirectUri string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) { - parsedURL, err := neturl.Parse(redirectUri) +func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) { + parsedURL, err := neturl.Parse(redirectURI) if err != nil { return nil, err } domain := parsedURL.Hostname() - req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectUri, nil) + req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectURI, nil) if err != nil { return nil, err } @@ -435,7 +475,10 @@ func solvePoW(powInput string, difficulty int) string { func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) { vkReq := func(method string, postData string) (map[string]interface{}, error) { reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" - parsedURL, _ := neturl.Parse(reqURL) + parsedURL, err := neturl.Parse(reqURL) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } domain := parsedURL.Hostname() req, err := fhttp.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData)) @@ -522,8 +565,8 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI if !ok { return "", fmt.Errorf("invalid check response: %v", checkResp) } - status, _ := respObj["status"].(string) - if status != "OK" { + status, ok := respObj["status"].(string) + if !ok || status != "OK" { return "", fmt.Errorf("check status: %s", status) } successToken, ok := respObj["success_token"].(string) @@ -789,7 +832,10 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent) doRequest := func(data string, url string) (resp map[string]interface{}, err error) { - parsedURL, _ := neturl.Parse(url) + parsedURL, err := neturl.Parse(url) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } domain := parsedURL.Hostname() req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) @@ -849,7 +895,10 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede // Token 1 -> getCallPreview data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) - _, _ = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID) + _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID) + if err != nil { + log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err) + } vkDelayRandom(200, 400) @@ -878,7 +927,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede switch solveMode { case captchaSolveModeAuto: - if captchaErr.SessionToken != "" && captchaErr.RedirectUri != "" { + if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false) if solveErr != nil { log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr) @@ -887,7 +936,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede solveErr = fmt.Errorf("missing fields for auto solve") } case captchaSolveModeSliderPOC: - if captchaErr.SessionToken != "" && captchaErr.RedirectUri != "" { + if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true) if solveErr != nil { log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr) @@ -909,8 +958,8 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede go func() { var t, k string var e error - if captchaErr.RedirectUri != "" { - t, e = solveCaptchaViaProxy(captchaErr.RedirectUri, dialer) + if captchaErr.RedirectURI != "" { + t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer) } else if captchaErr.CaptchaImg != "" { k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg) } else { @@ -968,12 +1017,12 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede return "", "", "", fmt.Errorf("VK API error: %v", errObj) } - respMap, ok := resp["response"].(map[string]interface{}) - if !ok { + respMap, okLoop := resp["response"].(map[string]interface{}) + if !okLoop { return "", "", "", fmt.Errorf("unexpected getAnonymousToken response: %v", resp) } - token2, ok = respMap["token"].(string) - if !ok { + token2, okLoop = respMap["token"].(string) + if !okLoop { return "", "", "", fmt.Errorf("missing token in response: %v", resp) } break @@ -988,7 +1037,10 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede if err != nil { return "", "", "", err } - token3 := resp["session_key"].(string) + token3, ok := resp["session_key"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing session_key in response: %v", resp) + } vkDelayRandom(100, 150) @@ -999,11 +1051,26 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede return "", "", "", err } - ts := resp["turn_server"].(map[string]interface{}) - user := ts["username"].(string) - pass := ts["credential"].(string) - urls := ts["urls"].([]interface{}) - urlStr := urls[0].(string) + tsRaw, ok := resp["turn_server"].(map[string]interface{}) + if !ok { + return "", "", "", fmt.Errorf("missing turn_server in response: %v", resp) + } + user, ok := tsRaw["username"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing username in turn_server") + } + pass, ok := tsRaw["credential"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing credential in turn_server") + } + urlsRaw, ok := tsRaw["urls"].([]interface{}) + if !ok || len(urlsRaw) == 0 { + return "", "", "", fmt.Errorf("missing or empty urls in turn_server") + } + urlStr, ok := urlsRaw[0].(string) + if !ok { + return "", "", "", fmt.Errorf("turn server url is not a string") + } clean := strings.Split(urlStr, "?")[0] address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") @@ -1116,7 +1183,7 @@ func getYandexCreds(link string) (string, string, string, error) { } type WSSAck struct { - Uid string `json:"uid"` + UID string `json:"uid"` Ack struct { Status struct { Code string `json:"code"` @@ -1125,8 +1192,8 @@ func getYandexCreds(link string) (string, string, string, error) { } type WSSData struct { - ParticipantId string - RoomId string + ParticipantID string + RoomID string Credentials string Wss string } @@ -1163,8 +1230,11 @@ func getYandexCreds(link string) (string, string, string, error) { } }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", "", "", fmt.Errorf("GetConference: status=%s body=%s", resp.Status, string(body)) + readBody, err2 := io.ReadAll(resp.Body) + if err2 != nil { + return "", "", "", fmt.Errorf("GetConference: status=%s (failed to read body: %v)", resp.Status, err2) + } + return "", "", "", fmt.Errorf("GetConference: status=%s body=%s", resp.Status, string(readBody)) } var result ConferenceResponse @@ -1172,8 +1242,8 @@ func getYandexCreds(link string) (string, string, string, error) { return "", "", "", fmt.Errorf("decode conf: %v", err) } data := WSSData{ - ParticipantId: result.PeerID, - RoomId: result.RoomID, + ParticipantID: result.PeerID, + RoomID: result.RoomID, Credentials: result.Credentials, Wss: result.ClientConfiguration.MediaServerURL, } @@ -1185,10 +1255,17 @@ func getYandexCreds(link string) (string, string, string, error) { defer cancel() dialer := websocket.Dialer{} - conn, _, err := dialer.DialContext(ctx, data.Wss, h) + var conn *websocket.Conn + conn, resp, err = dialer.DialContext(ctx, data.Wss, h) if err != nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } return "", "", "", fmt.Errorf("ws dial: %w", err) } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } defer func() { if closeErr := conn.Close(); closeErr != nil { log.Printf("close websocket: %s", closeErr) @@ -1214,8 +1291,8 @@ func getYandexCreds(link string) (string, string, string, error) { SendVideo: false, SendSharing: false, - ParticipantID: data.ParticipantId, - RoomID: data.RoomId, + ParticipantID: data.ParticipantID, + RoomID: data.RoomID, ServiceName: "telemost", Credentials: data.Credentials, SdkInfo: SdkInfo{ @@ -1344,6 +1421,7 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net. func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, inboundChan <-chan *UDPPacket, connchan chan<- net.PacketConn, okchan chan<- struct{}, streamID int) error { time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond) + dtlsctx, dtlscancel := context.WithCancel(ctx) defer dtlscancel() @@ -1381,7 +1459,9 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa wg := sync.WaitGroup{} wg.Add(1) context.AfterFunc(dtlsctx, func() { - _ = dtlsConn.SetDeadline(time.Now()) + if err := dtlsConn.SetDeadline(time.Now()); err != nil { + log.Printf("[STREAM %d] Warning: SetDeadline failed: %v", streamID, err) + } }) go func() { @@ -1409,7 +1489,11 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa // Send back to the active WG client if peerAddr := activeLocalPeer.Load(); peerAddr != nil { - _, _ = listenConn.WriteTo(buf[:n], peerAddr.(net.Addr)) + if addr, ok := peerAddr.(net.Addr); ok { + if _, err := listenConn.WriteTo(buf[:n], addr); err != nil { + log.Printf("[STREAM %d] failed to forward packet to local peer: %v", streamID, err) + } + } } } }() @@ -1439,7 +1523,7 @@ type turnParams struct { func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) { time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond) - var err error = nil + var err error defer func() { c <- err }() user, pass, urlTarget, err1 := turnParams.getCreds(ctx, turnParams.link, streamID) if err1 != nil { @@ -1459,20 +1543,19 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD } var turnServerAddr string turnServerAddr = net.JoinHostPort(urlhost, urlport) - turnServerUdpAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr) + turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr) if err1 != nil { err = fmt.Errorf("failed to resolve TURN server address: %s", err1) return } - turnServerAddr = turnServerUdpAddr.String() - + turnServerAddr = turnServerUDPAddr.String() var cfg *turn.ClientConfig var turnConn net.PacketConn var d net.Dialer ctx1, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if turnParams.udp { - conn, err2 := net.DialUDP("udp", nil, turnServerUdpAddr) + conn, err2 := net.DialUDP("udp", nil, turnServerUDPAddr) // nolint: noctx if err2 != nil { err = fmt.Errorf("failed to connect to TURN server: %s", err2) return @@ -1603,9 +1686,10 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD continue } - _, err1 = conn2.WriteTo(buf[:n], addr1.(net.Addr)) - if err1 != nil { - return + if addr, ok := addr1.(net.Addr); ok { + if _, err := conn2.WriteTo(buf[:n], addr); err != nil { + return + } } } }() @@ -1714,6 +1798,7 @@ func main() { n := flag.Int("n", 0, "connections to TURN (default 10 for VK, 1 for Yandex)") udp := flag.Bool("udp", false, "connect to TURN with UDP") direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") + vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") debugFlag := flag.Bool("debug", false, "enable debug logging") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") flag.Parse() @@ -1772,6 +1857,11 @@ func main() { getCreds: getCreds, } + if *vlessMode { + runVLESSMode(ctx, params, peer, *listen, *n) + return + } + listenConn, err := net.ListenPacket("udp", *listen) if err != nil { log.Panicf("Failed to listen: %s", err) @@ -1792,7 +1882,12 @@ func main() { go func() { for { - pkt := packetPool.Get().(*UDPPacket) + pktIface := packetPool.Get() + pkt, ok := pktIface.(*UDPPacket) + if !ok { + log.Printf("packetPool returned unexpected type: %T", pktIface) + continue + } nRead, addr, err := listenConn.ReadFrom(pkt.Data) if err != nil { return @@ -1800,7 +1895,13 @@ func main() { // Save the local WireGuard peer address current := activeLocalPeer.Load() - if current == nil || current.(net.Addr).String() != addr.String() { + if current == nil { + activeLocalPeer.Store(addr) + } else if addrStr, ok := current.(net.Addr); ok { + if addrStr.String() != addr.String() { + activeLocalPeer.Store(addr) + } + } else { activeLocalPeer.Store(addr) } @@ -1856,3 +1957,359 @@ func main() { wg1.Wait() } + +// sessionPool manages a pool of smux sessions for round-robin TCP distribution. +type sessionPool struct { + mu sync.RWMutex + sessions []*smux.Session + counter atomic.Uint64 +} + +func (p *sessionPool) add(s *smux.Session) { + p.mu.Lock() + p.sessions = append(p.sessions, s) + p.mu.Unlock() +} + +func (p *sessionPool) remove(s *smux.Session) { + p.mu.Lock() + for i, sess := range p.sessions { + if sess == s { + p.sessions = append(p.sessions[:i], p.sessions[i+1:]...) + break + } + } + p.mu.Unlock() +} + +func (p *sessionPool) pick() *smux.Session { + p.mu.RLock() + defer p.mu.RUnlock() + n := len(p.sessions) + if n == 0 { + return nil + } + idx := p.counter.Add(1) % uint64(n) + return p.sessions[idx] +} + +func (p *sessionPool) count() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.sessions) +} + +// runVLESSMode implements TCP forwarding with round-robin across N TURN sessions. +func runVLESSMode(ctx context.Context, tp *turnParams, peer *net.UDPAddr, listenAddr string, numSessions int) { + pool := &sessionPool{} + + // Start N session maintainers with staggered startup + var wgMaint sync.WaitGroup + for i := 0; i < numSessions; i++ { + wgMaint.Add(1) + go func(id int) { + defer wgMaint.Done() + select { + case <-ctx.Done(): + return + case <-time.After(time.Duration(id) * 300 * time.Millisecond): + } + maintainVLESSSession(ctx, tp, peer, id, pool) + }(i) + } + + // Wait for at least one session + log.Printf("VLESS mode: waiting for sessions to connect (total: %d)...", numSessions) + for { + select { + case <-ctx.Done(): + wgMaint.Wait() + return + case <-time.After(100 * time.Millisecond): + } + if pool.count() > 0 { + break + } + } + + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + log.Panicf("TCP listen: %s", err) + } + context.AfterFunc(ctx, func() { _ = listener.Close() }) + log.Printf("VLESS mode: listening on %s (round-robin across %d sessions)", listenAddr, numSessions) + + var wgConn sync.WaitGroup + for { + tcpConn, err := listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + wgConn.Wait() + wgMaint.Wait() + return + default: + } + log.Printf("TCP accept error: %s", err) + continue + } + + sess := pool.pick() + if sess == nil || sess.IsClosed() { + log.Printf("No active sessions, rejecting connection") + _ = tcpConn.Close() + continue + } + + wgConn.Add(1) + go func(tc net.Conn, s *smux.Session) { + defer wgConn.Done() + defer func() { _ = tc.Close() }() + stream, err := s.OpenStream() + if err != nil { + log.Printf("smux open stream error: %s", err) + return + } + defer func() { _ = stream.Close() }() + pipe(ctx, tc, stream) + }(tcpConn, sess) + } +} + +// maintainVLESSSession keeps one TURN+DTLS+KCP+smux session alive, reconnecting on failure. +func maintainVLESSSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, id int, pool *sessionPool) { + for { + select { + case <-ctx.Done(): + return + default: + } + + smuxSess, cleanup, err := createSmuxSession(ctx, tp, peer, id) + if err != nil { + log.Printf("[session %d] setup error: %s, retrying...", id, err) + select { + case <-ctx.Done(): + return + case <-time.After(3 * time.Second): + } + continue + } + + pool.add(smuxSess) + log.Printf("[session %d] connected (active: %d)", id, pool.count()) + + for !smuxSess.IsClosed() { + select { + case <-ctx.Done(): + pool.remove(smuxSess) + cleanup() + return + case <-time.After(1 * time.Second): + } + } + + pool.remove(smuxSess) + cleanup() + log.Printf("[session %d] disconnected (active: %d), reconnecting...", id, pool.count()) + + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + } + } +} + +// createSmuxSession establishes a full TURN+DTLS+KCP+smux pipeline and returns +// the smux session along with a cleanup function to tear down all layers. +func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, id int) (*smux.Session, func(), error) { + var cleanupFns []func() + cleanup := func() { + for i := len(cleanupFns) - 1; i >= 0; i-- { + cleanupFns[i]() + } + } + + // 1. Get TURN credentials + user, pass, rawURL, err := tp.getCreds(ctx, tp.link, id) + if err != nil { + return nil, nil, fmt.Errorf("get TURN creds: %w", err) + } + urlhost, urlport, err := net.SplitHostPort(rawURL) + if err != nil { + return nil, nil, fmt.Errorf("parse TURN addr: %w", err) + } + if tp.host != "" { + urlhost = tp.host + } + if tp.port != "" { + urlport = tp.port + } + turnServerAddr := net.JoinHostPort(urlhost, urlport) + turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr) + if err != nil { + return nil, nil, fmt.Errorf("resolve TURN addr: %w", err) + } + turnServerAddr = turnServerUDPAddr.String() + + // 2. Connect to TURN server + var turnConn net.PacketConn + ctx1, cancel1 := context.WithTimeout(ctx, 5*time.Second) + defer cancel1() + if tp.udp { + c, err1 := net.DialUDP("udp", nil, turnServerUDPAddr) + if err1 != nil { + return nil, nil, fmt.Errorf("dial TURN (udp): %w", err1) + } + cleanupFns = append(cleanupFns, func() { _ = c.Close() }) + turnConn = &connectedUDPConn{c} + } else { + var d net.Dialer + c, err1 := d.DialContext(ctx1, "tcp", turnServerAddr) + if err1 != nil { + return nil, nil, fmt.Errorf("dial TURN (tcp): %w", err1) + } + cleanupFns = append(cleanupFns, func() { _ = c.Close() }) + turnConn = turn.NewSTUNConn(c) + } + + // 3. Create TURN client and allocate relay + var addrFamily turn.RequestedAddressFamily + if peer.IP.To4() != nil { + addrFamily = turn.RequestedAddressFamilyIPv4 + } else { + addrFamily = turn.RequestedAddressFamilyIPv6 + } + cfg := &turn.ClientConfig{ + STUNServerAddr: turnServerAddr, + TURNServerAddr: turnServerAddr, + Conn: turnConn, + Username: user, + Password: pass, + RequestedAddressFamily: addrFamily, + LoggerFactory: logging.NewDefaultLoggerFactory(), + } + turnClient, err := turn.NewClient(cfg) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("create TURN client: %w", err) + } + cleanupFns = append(cleanupFns, func() { turnClient.Close() }) + if err = turnClient.Listen(); err != nil { + cleanup() + return nil, nil, fmt.Errorf("TURN listen: %w", err) + } + relayConn, err := turnClient.Allocate() + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("TURN allocate: %w", err) + } + cleanupFns = append(cleanupFns, func() { _ = relayConn.Close() }) + log.Printf("relayed-address=%s", relayConn.LocalAddr().String()) + + // 4. Establish DTLS over TURN relay + certificate, err := selfsign.GenerateSelfSigned() + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("generate cert: %w", err) + } + dtlsPC := &relayPacketConn{relay: relayConn, peer: peer} + dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer, + dtls.WithCertificates(certificate), + dtls.WithInsecureSkipVerify(true), + dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret), + dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), + dtls.WithConnectionIDGenerator(dtls.OnlySendCIDGenerator()), + ) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("DTLS client create: %w", err) + } + ctx2, cancel2 := context.WithTimeout(ctx, 30*time.Second) + defer cancel2() + if err = dtlsConn.HandshakeContext(ctx2); err != nil { + _ = dtlsConn.Close() + cleanup() + return nil, nil, fmt.Errorf("DTLS handshake: %w", err) + } + cleanupFns = append(cleanupFns, func() { _ = dtlsConn.Close() }) + log.Printf("DTLS connection established") + + // 5. Create KCP session over DTLS + kcpSess, err := tcputil.NewKCPOverDTLS(dtlsConn, false) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("KCP session: %w", err) + } + cleanupFns = append(cleanupFns, func() { _ = kcpSess.Close() }) + log.Printf("KCP session established") + + // 6. Create smux client session over KCP + smuxSess, err := smux.Client(kcpSess, tcputil.DefaultSmuxConfig()) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("smux client: %w", err) + } + cleanupFns = append(cleanupFns, func() { _ = smuxSess.Close() }) + log.Printf("smux session established") + + return smuxSess, cleanup, nil +} + +// relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer. +type relayPacketConn struct { + relay net.PacketConn + peer net.Addr +} + +func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + return r.relay.ReadFrom(b) +} + +func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { + return r.relay.WriteTo(b, r.peer) +} + +func (r *relayPacketConn) Close() error { return r.relay.Close() } +func (r *relayPacketConn) LocalAddr() net.Addr { return r.relay.LocalAddr() } +func (r *relayPacketConn) SetDeadline(t time.Time) error { return r.relay.SetDeadline(t) } +func (r *relayPacketConn) SetReadDeadline(t time.Time) error { return r.relay.SetReadDeadline(t) } +func (r *relayPacketConn) SetWriteDeadline(t time.Time) error { return r.relay.SetWriteDeadline(t) } + +// pipe copies data bidirectionally between two connections. +func pipe(ctx context.Context, c1, c2 net.Conn) { + ctx2, cancel := context.WithCancel(ctx) + context.AfterFunc(ctx2, func() { + if err := c1.SetDeadline(time.Now()); err != nil { + log.Printf("pipe: failed to set deadline c1: %v", err) + } + if err := c2.SetDeadline(time.Now()); err != nil { + log.Printf("pipe: failed to set deadline c2: %v", err) + } + }) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + defer cancel() + if _, err := io.Copy(c1, c2); err != nil { + log.Printf("pipe: c1<-c2 copy error: %v", err) + } + }() + go func() { + defer wg.Done() + defer cancel() + if _, err := io.Copy(c2, c1); err != nil { + log.Printf("pipe: c2<-c1 copy error: %v", err) + } + }() + wg.Wait() + if err := c1.SetDeadline(time.Time{}); err != nil { + log.Printf("pipe: failed to reset deadline c1: %v", err) + } + if err := c2.SetDeadline(time.Time{}); err != nil { + log.Printf("pipe: failed to reset deadline c2: %v", err) + } +} diff --git a/client/manual_captcha.go b/client/manual_captcha.go index 7455573..27f958d 100644 --- a/client/manual_captcha.go +++ b/client/manual_captcha.go @@ -541,8 +541,8 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, }) mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) { - targetAuthUrl := r.URL.Query().Get("proxy_url") - targetParsed, err := neturl.Parse(targetAuthUrl) + targetAuthURL := r.URL.Query().Get("proxy_url") + targetParsed, err := neturl.Parse(targetAuthURL) if err != nil || targetParsed.Host == "" { http.Error(w, "Bad URL", http.StatusBadRequest) return diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index e67abae..941bf24 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,4 +3,9 @@ set -e CONNECT="${CONNECT_ADDR:?CONNECT_ADDR is required}" -exec ./vk-turn-proxy -listen 0.0.0.0:56000 -connect "$CONNECT" +VLESS_FLAG="" +if [ "${VLESS_MODE}" = "true" ]; then + VLESS_FLAG="-vless" +fi + +exec ./vk-turn-proxy -listen 0.0.0.0:56000 -connect "$CONNECT" $VLESS_FLAG diff --git a/go.mod b/go.mod index b89aa57..e6e5929 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( github.com/pion/logging v0.2.4 github.com/pion/transport/v4 v4.0.1 github.com/pion/turn/v5 v5.0.3 + github.com/xtaci/kcp-go/v5 v5.6.18 + github.com/xtaci/smux v1.5.34 ) require ( @@ -22,13 +24,20 @@ require ( github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect github.com/bogdanfinn/utls v1.7.7-barnius // indirect github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/reedsolomon v1.12.4 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.1.2 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/templexxx/cpu v0.1.1 // indirect + github.com/templexxx/xorsimd v0.4.3 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/mod v0.34.0 // indirect @@ -37,4 +46,6 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/grpc v1.80.0 // indirect ) diff --git a/go.sum b/go.sum index e65871c..aef2d97 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= @@ -18,10 +20,31 @@ github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 h1:0b2i5T github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45/go.mod h1:NU7MdmhQD8Ounc0760w90fL6nxI2lxjlnIaN6qWzNIU= github.com/cbeuw/connutil v1.0.1 h1:LWuNYjwm7JEDYG/ISAO1TfU4G+q2dA5NhR97eq2roCA= github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgLVI1QtA= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -30,6 +53,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA= +github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= @@ -44,47 +71,106 @@ github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak= github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI= +github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU= +github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xtaci/kcp-go/v5 v5.6.18 h1:7oV4mc272pcnn39/13BB11Bx7hJM4ogMIEokJYVWn4g= +github.com/xtaci/kcp-go/v5 v5.6.18/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE= +github.com/xtaci/smux v1.5.34 h1:OUA9JaDFHJDT8ZT3ebwLWPAgEfE6sWo2LaTy3anXqwg= +github.com/xtaci/smux v1.5.34/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/routes.ps1 b/routes.ps1 index c492e6d..0e34b6c 100644 --- a/routes.ps1 +++ b/routes.ps1 @@ -1,4 +1,4 @@ -# Получаем default gateway (IPv4) +# Получаем default gateway (IPv4) $gateway = Get-NetRoute ` -DestinationPrefix "0.0.0.0/0" ` | Sort-Object RouteMetric ` diff --git a/server/main.go b/server/main.go index 751e163..cdf91d4 100644 --- a/server/main.go +++ b/server/main.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "io" "log" "net" "os" @@ -12,15 +13,16 @@ import ( "syscall" "time" + "github.com/cacggghp/vk-turn-proxy/tcputil" "github.com/pion/dtls/v3" "github.com/pion/dtls/v3/pkg/crypto/selfsign" + "github.com/xtaci/smux" ) -const idleTimeout = 2 * time.Minute - func main() { listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port") connect := flag.String("connect", "", "connect to ip:port") + vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") flag.Parse() ctx, cancel := context.WithCancel(context.Background()) @@ -45,7 +47,7 @@ func main() { // Generate a certificate and private key to secure the connection certificate, genErr := selfsign.GenerateSelfSigned() if genErr != nil { - panic(err) + panic(genErr) } // @@ -94,116 +96,229 @@ func main() { log.Printf("failed to close incoming connection: %s", closeErr) } }() - var err error = nil log.Printf("Connection from %s\n", conn.RemoteAddr()) - // `conn` is of type `net.Conn` but may be casted to `dtls.Conn` - // using `dtlsConn := conn.(*dtls.Conn)` in order to to expose - // functions like `ConnectionState` etc. // Perform the handshake with a 30-second timeout ctx1, cancel1 := context.WithTimeout(ctx, 30*time.Second) + defer cancel1() + dtlsConn, ok := conn.(*dtls.Conn) if !ok { - log.Println("Type error") - cancel1() + log.Println("Type error: expected *dtls.Conn") return } log.Println("Start handshake") - if err = dtlsConn.HandshakeContext(ctx1); err != nil { - log.Println(err) - cancel1() + if err := dtlsConn.HandshakeContext(ctx1); err != nil { + log.Printf("Handshake failed: %v", err) return } - cancel1() log.Println("Handshake done") - serverConn, err := net.Dial("udp", *connect) - if err != nil { - log.Println(err) + if *vlessMode { + handleVLESSConnection(ctx, dtlsConn, *connect) + } else { + handleUDPConnection(ctx, conn, *connect) + } + + log.Printf("Connection closed: %s\n", conn.RemoteAddr()) + }(conn) + } +} + +// handleUDPConnection forwards DTLS packets to a UDP backend (WireGuard). +func handleUDPConnection(ctx context.Context, conn net.Conn, connectAddr string) { + serverConn, err := net.Dial("udp", connectAddr) + if err != nil { + log.Println(err) + return + } + defer func() { + if err = serverConn.Close(); err != nil { + log.Printf("failed to close outgoing connection: %s", err) + } + }() + + var wg sync.WaitGroup + wg.Add(2) + ctx2, cancel2 := context.WithCancel(ctx) + context.AfterFunc(ctx2, func() { + if err := conn.SetDeadline(time.Now()); err != nil { + log.Printf("failed to set incoming deadline: %s", err) + } + if err := serverConn.SetDeadline(time.Now()); err != nil { + log.Printf("failed to set outgoing deadline: %s", err) + } + }) + go func() { + defer wg.Done() + defer cancel2() + buf := make([]byte, 1600) + for { + select { + case <-ctx2.Done(): + return + default: + } + if err1 := conn.SetReadDeadline(time.Now().Add(time.Minute * 30)); err1 != nil { + log.Printf("Failed: %s", err1) + return + } + n, err1 := conn.Read(buf) + if err1 != nil { + log.Printf("Failed: %s", err1) + return + } + + if err1 = serverConn.SetWriteDeadline(time.Now().Add(time.Minute * 30)); err1 != nil { + log.Printf("Failed: %s", err1) + return + } + _, err1 = serverConn.Write(buf[:n]) + if err1 != nil { + log.Printf("Failed: %s", err1) return } + } + }() + go func() { + defer wg.Done() + defer cancel2() + buf := make([]byte, 1600) + for { + select { + case <-ctx2.Done(): + return + default: + } + if err1 := serverConn.SetReadDeadline(time.Now().Add(time.Minute * 30)); err1 != nil { + log.Printf("Failed: %s", err1) + return + } + n, err1 := serverConn.Read(buf) + if err1 != nil { + log.Printf("Failed: %s", err1) + return + } + + if err1 = conn.SetWriteDeadline(time.Now().Add(time.Minute * 30)); err1 != nil { + log.Printf("Failed: %s", err1) + return + } + _, err1 = conn.Write(buf[:n]) + if err1 != nil { + log.Printf("Failed: %s", err1) + return + } + } + }() + wg.Wait() +} + +// handleVLESSConnection creates a KCP+smux session over DTLS and forwards +// each smux stream as a TCP connection to the backend (Xray/VLESS). +func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr string) { + // 1. Create KCP session over DTLS + kcpSess, err := tcputil.NewKCPOverDTLS(dtlsConn, true) + if err != nil { + log.Printf("KCP session error: %s", err) + return + } + defer func() { + if err := kcpSess.Close(); err != nil { + log.Printf("failed to close KCP session: %v", err) + } + }() + log.Printf("KCP session established (server)") + + // 2. Create smux server session over KCP + smuxSess, err := smux.Server(kcpSess, tcputil.DefaultSmuxConfig()) + if err != nil { + log.Printf("smux server error: %s", err) + return + } + defer func() { + if err := smuxSess.Close(); err != nil { + log.Printf("failed to close smux session: %v", err) + } + }() + log.Printf("smux session established (server)") + + // 3. Accept smux streams and forward to backend via TCP + var wg sync.WaitGroup + for { + stream, err := smuxSess.AcceptStream() + if err != nil { + select { + case <-ctx.Done(): + default: + log.Printf("smux accept error: %s", err) + } + break + } + + wg.Add(1) + go func(s *smux.Stream) { + defer wg.Done() + defer func() { - if err = serverConn.Close(); err != nil { - log.Printf("failed to close outgoing connection: %s", err) - return + if err := s.Close(); err != nil && err != smux.ErrGoAway { + log.Printf("failed to close smux stream: %v", err) } }() - var wg sync.WaitGroup - wg.Add(2) - ctx2, cancel2 := context.WithCancel(ctx) - context.AfterFunc(ctx2, func() { - if err := conn.SetDeadline(time.Now()); err != nil { - log.Printf("failed to set incoming deadline: %s", err) - } - if err := serverConn.SetDeadline(time.Now()); err != nil { - log.Printf("failed to set outgoing deadline: %s", err) - } - }) - go func() { - defer wg.Done() - defer cancel2() - buf := make([]byte, 1600) - for { - select { - case <-ctx2.Done(): - return - default: - } - if err1 := conn.SetReadDeadline(time.Now().Add(idleTimeout)); err1 != nil { - log.Printf("Failed: %s", err1) - return - } - n, err1 := conn.Read(buf) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } - - if err1 := serverConn.SetWriteDeadline(time.Now().Add(idleTimeout)); err1 != nil { - log.Printf("Failed: %s", err1) - return - } - _, err1 = serverConn.Write(buf[:n]) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } - } - }() - go func() { - defer wg.Done() - defer cancel2() - buf := make([]byte, 1600) - for { - select { - case <-ctx2.Done(): - return - default: - } - if err1 := serverConn.SetReadDeadline(time.Now().Add(idleTimeout)); err1 != nil { - log.Printf("Failed: %s", err1) - return - } - n, err1 := serverConn.Read(buf) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } - - if err1 := conn.SetWriteDeadline(time.Now().Add(idleTimeout)); err1 != nil { - log.Printf("Failed: %s", err1) - return - } - _, err1 = conn.Write(buf[:n]) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } + // Connect to backend (Xray/VLESS) + backendConn, err := net.DialTimeout("tcp", connectAddr, 10*time.Second) + if err != nil { + log.Printf("backend dial error: %s", err) + return + } + defer func() { + if err := backendConn.Close(); err != nil { + log.Printf("failed to close backend connection: %v", err) } }() - wg.Wait() - log.Printf("Connection closed: %s\n", conn.RemoteAddr()) - }(conn) + + // Bidirectional copy + pipeConn(ctx, s, backendConn) + }(stream) } + wg.Wait() +} + +// pipeConn copies data bidirectionally between two connections. +func pipeConn(ctx context.Context, c1, c2 net.Conn) { + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + + context.AfterFunc(ctx2, func() { + if err := c1.SetDeadline(time.Now()); err != nil { + log.Printf("pipeConn: failed to set deadline c1: %v", err) + } + if err := c2.SetDeadline(time.Now()); err != nil { + log.Printf("pipeConn: failed to set deadline c2: %v", err) + } + }) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if _, err := io.Copy(c1, c2); err != nil { + log.Printf("pipeConn: c1<-c2 copy error: %v", err) + } + }() + + go func() { + defer wg.Done() + if _, err := io.Copy(c2, c1); err != nil { + log.Printf("pipeConn: c2<-c1 copy error: %v", err) + } + }() + + wg.Wait() + + // Reset deadlines + _ = c1.SetDeadline(time.Time{}) + _ = c2.SetDeadline(time.Time{}) } diff --git a/tcputil/tcputil.go b/tcputil/tcputil.go new file mode 100644 index 0000000..4e37ea3 --- /dev/null +++ b/tcputil/tcputil.go @@ -0,0 +1,103 @@ +package tcputil + +import ( + "net" + "time" + + "github.com/xtaci/kcp-go/v5" + "github.com/xtaci/smux" +) + +// DtlsPacketConn wraps a net.Conn (DTLS) as a net.PacketConn for KCP. +// Each DTLS Read/Write preserves message boundaries (datagram semantics). +type DtlsPacketConn struct { + conn net.Conn +} + +func NewDtlsPacketConn(conn net.Conn) *DtlsPacketConn { + return &DtlsPacketConn{conn: conn} +} + +func (d *DtlsPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + n, err := d.conn.Read(b) + return n, d.conn.RemoteAddr(), err +} + +func (d *DtlsPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { + return d.conn.Write(b) +} + +func (d *DtlsPacketConn) Close() error { + return d.conn.Close() +} + +func (d *DtlsPacketConn) LocalAddr() net.Addr { + return d.conn.LocalAddr() +} + +func (d *DtlsPacketConn) SetDeadline(t time.Time) error { + return d.conn.SetDeadline(t) +} + +func (d *DtlsPacketConn) SetReadDeadline(t time.Time) error { + return d.conn.SetReadDeadline(t) +} + +func (d *DtlsPacketConn) SetWriteDeadline(t time.Time) error { + return d.conn.SetWriteDeadline(t) +} + +// NewKCPOverDTLS creates a KCP session over a DTLS connection. +// isServer: true for server-side (listener), false for client-side (dialer). +func NewKCPOverDTLS(dtlsConn net.Conn, isServer bool) (*kcp.UDPSession, error) { + pc := NewDtlsPacketConn(dtlsConn) + + block, err := kcp.NewNoneBlockCrypt(nil) // DTLS already encrypts + if err != nil { + return nil, err + } + + var sess *kcp.UDPSession + + if isServer { + // Server: listen on the PacketConn and accept one session + var listener *kcp.Listener + listener, err = kcp.ServeConn(block, 0, 0, pc) + if err != nil { + return nil, err + } + if err = listener.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + return nil, err + } + sess, err = listener.AcceptKCP() + if err != nil { + return nil, err + } + } else { + // Client: dial through the PacketConn + sess, err = kcp.NewConn2(dtlsConn.RemoteAddr(), block, 0, 0, pc) + if err != nil { + return nil, err + } + } + + // Tune KCP for TURN tunnel: + // - NoDelay mode for lower latency + // - Window sizes suitable for ~5Mbit/s + sess.SetNoDelay(1, 20, 2, 1) // nodelay, interval(ms), resend, nc + sess.SetWindowSize(256, 256) + sess.SetMtu(1200) // conservative MTU to fit inside DTLS+TURN + sess.SetACKNoDelay(true) + + return sess, nil +} + +// DefaultSmuxConfig returns smux config tuned for TURN tunnel. +func DefaultSmuxConfig() *smux.Config { + cfg := smux.DefaultConfig() + cfg.MaxReceiveBuffer = 4 * 1024 * 1024 + cfg.MaxStreamBuffer = 1 * 1024 * 1024 + cfg.KeepAliveInterval = 10 * time.Second + cfg.KeepAliveTimeout = 30 * time.Second + return cfg +}