diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9acc858 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run Client", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go", + "args": ["--action", "client"] + }, + { + "name": "Run Proxy", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go", + "args": [] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..94479f8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "cSpell.words": [ + "DGRAM", + "libragen", + "mojotv", + "socksocket", + "unchainese", + "Upgrader" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 90d3a64..af9d4e5 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,195 @@ -# Unchain Proxy Server +# Unchain -Unchain is a lightweight and easy-to-use proxy server designed to bypass network restrictions, censorship, and surveillance effectively. +A lightweight, high-performance proxy server for bypassing network restrictions using VLESS over WebSocket with TLS. +[![Go Version](https://img.shields.io/badge/Go-1.23+-blue.svg)](https://golang.org/) +[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE) +[![Docker](https://img.shields.io/badge/Docker-Supported-blue.svg)](Dockerfile) +## Features +- **VLESS Protocol Support**: Full VLESS over WebSocket with TLS encryption +- **Client Compatible**: Works with v2rayN, v2rayA, Clash, ShadowRocket, and more +- **Lightweight**: Minimal resource footprint with core logic in ~200 lines +- **Production Ready**: Includes traffic metering, health checks, and graceful shutdown +- **Flexible Deployment**: Standalone or integrated with admin servers +- **Memory Efficient**: Optimized goroutine management and resource cleanup +- **High Performance**: Concurrent connections optimized for Go 1.23 -## Key Features -- **Protocol Support**: Seamlessly handles TCP and UDP (VLESS) packets over WebSocket with TLS/Cloudflare support. -- **Build Your Own VPN Business**: Provides a robust platform for starting your own VPN services. -- **Compatibility**: Fully compatible with popular proxy clients like v2rayN or any application supporting the VLESS + WebSocket protocol. +## Quick Start +### Prerequisites +- Go 1.23+ or Docker +- Basic proxy/VPN knowledge -## How It Works - -Unchain operates as a proxy/VPN server, compatible with popular proxy clients such as v2rayN or any application that supports the VLESS+WebSocket protocol. It accepts traffic from various client applications, including: - -- [v2rayN](https://github.com/2dust/v2rayN) -- [v2rayA](https://github.com/v2rayA/v2rayA) -- [Clash](https://github.com/Dreamacro/clash) -- [v2rayNG](https://github.com/2dust/v2rayNG) -- [iOS app ShadowRocket](https://apps.apple.com/us/app/shadowrocket/id932747118) - -Unchain processes incoming traffic and securely forwards it to the destination server, ensuring both security and efficiency in communication. - -## Unchain Architecture - - - - - -Unchain is a dead simple VLESS over websocket proxy server. -The core biz logic is only 200 lines of code. [app_ws_vless.go](/node/app_ws_vless.go). - -Unchain server uses a simple architecture that is VLESS over WebSocket (WS) + TLS. - - -``` - V2rayN,V2rayA,Clash or ShadowRocket - +------------------+ - | VLESS Client | - | +-----------+ | - | | TLS Layer | | - | +-----------+ | - | | WebSocket | | - | +-----------+ | - +--------|---------+ - | - | Encrypted VLESS Traffic (wss://) - | - +--------------------------------------+ - | Internet (TLS Secured) | - +--------------------------------------+ - | - | - +-----------------------------------+ - | Reverse Proxy Server | - | (e.g., Nginx or Cloudflare) | - | | - | +---------------------------+ | - | | HTTPS/TLS Termination | | - | +---------------------------+ | - | | WebSocket Proxy (wss://) | | - | +---------------------------+ | - | Forward to VLESS Server | - +------------------|----------------+ - | - +--------------------------------+ - | Unchain Server | - | | - | +------------------------+ | - | | WebSocket Handler | | - | +------------------------+ | - | | VLESS Core Processing | | - | +------------------------+ | - | | - | Forward Traffic to Target | - +------------------|-------------+ - | - +-----------------+ - | Target Server | - | or Destination | - +-----------------+ +### Install from Source +```bash +git clone https://github.com/unchainese/unchain.git +cd unchain +go mod download +cp config.example.standalone.toml config.toml +# Edit config.toml +go run main.go ``` +### Docker +```bash +docker build -t unchain . +docker run -p 80:80 \ + -e SUB_ADDRESSES="your-domain.com:443" \ + -e ALLOW_USERS="your-uuid" \ + unchain +``` + +## Configuration + +Unchain uses TOML config or environment variables. + +### config.toml + +```toml +SubAddresses = 'domain.com:443' +AppPort = '80' +AllowUsers = 'uuid1,uuid2' +LogFile = '' +DebugLevel = 'info' +EnableDataUsageMetering = 'true' +``` + +### Environment Variables + +```bash +APP_PORT=80 +SUB_ADDRESSES=domain.com:443 +ALLOW_USERS=uuid1,uuid2 +``` ## Usage -### 1. Build from Source +### Endpoints +- `/wsv/{uid}` - VLESS WebSocket endpoint +- `/sub/{uid}` - Subscription URL generator +- `/` - Health check -To build from source, follow these steps: +### Get VLESS URLs +```bash +curl http://localhost:80/sub/your-uuid +``` -1. Clone the repository and navigate to the `cmd/node` directory: - ```sh - cd cmd/node - ``` -2. Copy the example configuration file and customize it: - ```sh - cp config.example.standalone.toml config.toml - ``` -3. Run the application: - ```sh - go run main.go - ``` +### Client Setup +Import the generated VLESS URL into your client (v2rayN, Clash, etc.). -### 2. Deploying on Your Own Ubuntu Server Using GitHub Actions +## Architecture -You can deploy the application on an Ubuntu server using GitHub Actions. Here's how: +``` +Client --VLESS/WS/TLS--> Reverse Proxy --WS--> Unchain --TCP/UDP--> Target +``` -1. **Fork the repository** to your GitHub account. -2. **Create an Environment** named `production` in your repository settings. -3. **Add the following SSH connection details** to the Environment Secrets: - - `EC2_HOST`: The SSH host with port (e.g., `1.1.1.1:20`). - - `EC2_USER`: The SSH user (e.g., `ubuntu`). - - `EC2_KEY`: Your SSH private key. +Unchain runs behind a reverse proxy (Nginx/Cloudflare) handling TLS and WebSocket upgrades. -4. **Add your TOML configuration file content** to the Environment Variables: - - `CONFIG_TOML`: Copy the content of your `config.toml` file, replace all `"` with `'`, and paste it here. +## Project Structure -learn more in [.github/workflows/deploy.sh](/.github/workflows/deploy.sh) +``` +├── main.go # Entry point +├── server/ # Core server +│ ├── app.go # HTTP server +│ ├── app_ws_vless.go # VLESS handler +│ ├── app_ping.go # Health check +│ └── app_sub.go # Subscription +├── global/ # Utilities +│ ├── config.go # Config management +│ └── logger.go # Logging +├── schema/ # Protocols +│ ├── vless.go # VLESS parser +│ └── trojan.go # Trojan support +├── client/ # Client utilities +│ ├── client.go # SOCKS5 proxy +│ ├── websocket.go # WS client +│ ├── proxy.go # Coordination +│ ├── relay_*.go # Relays +│ ├── socks5_*.go # SOCKS5 +│ └── geo.go # GeoIP +├── config.example.standalone.toml +└── Dockerfile +``` +## Technology Stack -[Click to view Chinese deployment tutorial video](https://www.bilibili.com/video/BV1wBrmYmEiN/?share_source=copy_web&vd_source=aec70c249b680fe47ccf03c2051714fe) +- **Go 1.23** +- **gorilla/websocket** v1.5.3 +- **BurntSushi/toml** v1.4.0 +- **google/uuid** v1.6.0 +- **oschwald/geoip2-golang** v1.11.0 +- **sirupsen/logrus** v1.9.3 +## Troubleshooting -### 3. Running the Application +### Common Issues -Once the application is running, you will see a VLESS connection schema URL in the standard output. Copy and paste this URL into your V2rayN client. +1. **Connection Failed** + - Check server status: `curl http://localhost:80/` + - Verify firewall and DNS + - Ensure reverse proxy is configured -Congratulations! You now have your self-hosted proxy server up and running. +2. **WebSocket Errors** + - Confirm proxy supports WS upgrades + - Check TLS certificates +3. **Auth Failed** + - Verify UUID in `AllowUsers` + - Check admin server if used +4. **High Memory** + - Monitor goroutines via `/` + - Restart if >1000 goroutines +### Debug +Set `DebugLevel = 'debug'` and check logs. -### 4. (Optional) create your own admin app for Auth and Data-traffic +## Client Compatibility -create an RESTful API for [chain proxy server push](https://github.com/unchainese/unchain/blob/5ece8c39814684a8a54e8e009d7c888e5988a017/internal/node/app.go#L161) : -[Register API example code](https://github.com/unchainese/unchainadmin/blob/035b2232d4262c24ef70b8ad7abb9faebaaecc96/functions/api/nodes.ts#L34) +- v2rayN (Windows) +- v2rayA (Cross-platform) +- Clash (Cross-platform) +- v2rayNG (Android) +- ShadowRocket (iOS) +## Business Use -## Build your own VPN business +Integrate with admin server for user management, traffic metering, and billing. -Using [the cloudflare page UnchainAdmin](https://github.com/unchainese/unchainadmin) start your own VPN business. +See [UnchainAdmin](https://github.com/unchainese/unchainadmin) for example. + +## Performance + +- **RAM**: 512MB min, 1GB+ recommended +- **CPU**: 1 core min, 2+ for production +- **Connections**: Thousands concurrent +- **Memory**: ~20MB base + ~1-2MB/100 connections + +## Contributing + +1. Fork the repo +2. Create feature branch +3. Commit changes +4. Push and open PR + +### Development + +```bash +git clone https://github.com/yourusername/unchain.git +cd unchain +go mod download +go test ./... +go build +``` + +## License + +Apache License 2.0 - see [LICENSE](LICENSE) + +--- + +⭐ Star if useful! [Issues](https://github.com/unchainese/unchain/issues) diff --git a/client/GeoLite2-Country.mmdb b/client/GeoLite2-Country.mmdb deleted file mode 100644 index d3088b5..0000000 Binary files a/client/GeoLite2-Country.mmdb and /dev/null differ diff --git a/client/client.go b/client/client.go deleted file mode 100644 index a2feba9..0000000 --- a/client/client.go +++ /dev/null @@ -1,276 +0,0 @@ -package client - -import ( - "context" - "errors" - "fmt" - "io" - "log" - "log/slog" - "net" - "sync" - "time" -) - -type ClientLocalSocks5Server struct { - AddrSocks5 string - Timeout time.Duration - proxy *Proxy -} - -func NewClientLocalSocks5Server(addr string) (*ClientLocalSocks5Server, error) { - return &ClientLocalSocks5Server{ - AddrSocks5: addr, - Timeout: 5 * time.Minute, - }, nil -} - -func (ss *ClientLocalSocks5Server) fetchActiveProxy() { - - ss.proxy = &Proxy{} -} - -func (ss *ClientLocalSocks5Server) Run(ctx context.Context) { - ss.fetchActiveProxy() - - listener, err := net.Listen("tcp", ss.AddrSocks5) - if err != nil { - listener, err = net.Listen("tcp4", "127.0.0.1:0") - } - if err != nil { - log.Fatalf("Failed to listen on %s: %v", ss.AddrSocks5, err) - } - ss.AddrSocks5 = listener.Addr().String() - slog.Info("socks5 server listening on", "addr", ss.AddrSocks5) - - defer listener.Close() - log.Println("SOCKS5 server listening on: " + ss.AddrSocks5) - //proxySettingOn(ss.AddrSocks5) - //defer proxySettingOff() - - // Channel to receive new connections - connCh := make(chan net.Conn, 1) - // Channel to signal accept goroutine to stop - done := make(chan struct{}) - defer close(done) - - // Start accept goroutine - go func() { - defer close(connCh) - for { - select { - case <-done: - return - default: - conn, err := listener.Accept() - if err != nil { - // Check if the error is due to listener being closed - select { - case <-done: - return // Expected closure - default: - log.Printf("Failed to accept connection: %v", err) - continue - } - } - select { - case connCh <- conn: - case <-done: - conn.Close() // Close connection if we can't send it - return - } - } - } - }() - - for { - select { - case <-ctx.Done(): - log.Println("socks5 server exit") - return - case conn, ok := <-connCh: - if !ok { - // Connection channel closed, exit - return - } - go ss.handleConnection(ctx, conn) - } - } -} - -func (ss *ClientLocalSocks5Server) socks5HandShake(conn net.Conn) error { - buf := make([]byte, 2) - if _, err := io.ReadFull(conn, buf); err != nil { - return fmt.Errorf("failed to read version and nmethods: %w", err) - } - if buf[0] != socks5Version { - return fmt.Errorf("socks5 only. unsupported SOCKS version: %d", buf[0]) - } - - // Read the supported authentication methods - nMethods := int(buf[1]) - nMethodsData := make([]byte, nMethods) - if _, err := io.ReadFull(conn, nMethodsData); err != nil { - return fmt.Errorf("failed to read methods: %w", err) - } - - // Select no authentication (0x00) - if _, err := conn.Write([]byte{socks5Version, 0x00}); err != nil { - return fmt.Errorf("failed to write method selection: %w", err) - } - return nil -} - -func (ss *ClientLocalSocks5Server) socks5Request(conn net.Conn) (*Socks5Request, error) { - buf := make([]byte, 8<<10) - n, err := conn.Read(buf) - if err != nil { - return nil, fmt.Errorf("failed to read request: %w", err) - } - data := buf[:n] - if len(data) < 4 { - return nil, fmt.Errorf("request too short") - } - return parseSocks5Request(data) -} - -func (ss *ClientLocalSocks5Server) handleConnection(outerCtx context.Context, conn net.Conn) { - defer conn.Close() // the outer for loop is not suitable for defer, so defer close here - ctx, cf := context.WithTimeout(outerCtx, ss.Timeout) - defer cf() - - err := ss.socks5HandShake(conn) - if err != nil { - slog.Error("failed to handshake", "err", err.Error()) - socks5Response(conn, net.IPv4zero, 0, socks5ReplyFail) - return - } - req, err := ss.socks5Request(conn) - if err != nil { - slog.Error("failed to parse socks5 request", "err", err.Error()) - socks5Response(conn, net.IPv4zero, 0, socks5ReplyFail) - return - } - req.Logger().Info("remote target") - if req.socks5Cmd == socks5CmdConnect { //tcp - relayTcpSvr, err := ss.dispatchRelayTcpServer(ctx, req) - if err != nil { - slog.Error("failed to dispatch relay tcp server", "err", err.Error()) - socks5Response(conn, net.IPv4zero, 0, socks5ReplyFail) - return - } - socks5Response(conn, net.IPv4zero, 0, socks5ReplyOkay) - defer relayTcpSvr.Close() - ss.pipeTcp(ctx, conn, relayTcpSvr) - return - } else if req.socks5Cmd == socks5CmdUdpAssoc { - udpH, err := NewRelayUdpDirect(conn) - if err != nil { - slog.Error("failed to create udp handler", "err", err.Error()) - socks5Response(conn, net.IPv4zero, 0, socks5ReplyFail) - return - } - - defer udpH.Close() - udpH.PipeUdp() - return - } else if req.socks5Cmd == socks5CmdBind { - relayBind(conn, req) - return - } else { - err = fmt.Errorf("unknown command: %d", req.socks5Cmd) - slog.Error("unknown command", "err", err.Error()) - socks5Response(conn, net.IPv4zero, 0, socks5ReplyFail) - } -} - -func (ss *ClientLocalSocks5Server) shouldGoDirect(req *Socks5Request) (goDirect bool) { - - if req.CountryCode == "CN" || req.CountryCode == "" { - //empty means geo ip failed or local address - return true - } - - return false -} - -func (ss *ClientLocalSocks5Server) dispatchRelayTcpServer(ctx context.Context, req *Socks5Request) (io.ReadWriteCloser, error) { - if ss.shouldGoDirect(req) { - req.Logger().Info("go direct") - return NewRelayTcpDirect(req) - } - return NewRelayTcpSocks5e(ctx, ss.proxy, req) -} - -func (ss *ClientLocalSocks5Server) pipeTcp(ctx context.Context, s5 net.Conn, relayRw io.ReadWriter) { - // Create cancellable context for proper goroutine cleanup - ctx, cancel := context.WithCancel(ctx) - defer cancel() // Ensure both goroutines exit when function returns - - wg := sync.WaitGroup{} - wg.Add(2) - go func() { - span := slog.With("fn", "ws -> s5") - defer func() { - span.Debug("wg1 done") - cancel() // Cancel context if this goroutine exits - wg.Done() - }() - for { - select { - case <-ctx.Done(): - span.Info("ctx.Done exit") - return - default: - //ws.SetReadDeadline(time.Now().Add(1 * time.Second)) - buf := make([]byte, 8<<10) - n, err := relayRw.Read(buf) - if err != nil { - span.Error("relay read", "err", err.Error()) - return - } - _, err = s5.Write(buf[:n]) - if err != nil { - span.Error("s5 write", "err", err.Error()) - return - } - } - } - }() - go func() { // s5 -> ws - span := slog.With("fn", "s5 -> ws") - defer func() { - span.Debug("wg2 done") - cancel() // Cancel context if this goroutine exits - wg.Done() - }() - for { - select { - case <-ctx.Done(): - span.Debug("ctx.Done exit") - return - default: - buf := make([]byte, 8<<10) - //s5.SetReadDeadline(time.Now().Add(20 * time.Millisecond)) - n, err := s5.Read(buf) - if errors.Is(err, io.EOF) { - slog.Info("s5 read EOF") - return - } - if err != nil { - et := fmt.Sprintf("%T", err) - span.With("errType", et).Error("s5 read", "err", err.Error()) - return - } - //ws.SetWriteDeadline(time.Now().Add(1 * time.Second)) - n, err = relayRw.Write(buf[:n]) - if err != nil { - span.Error("relay write", "err", err.Error()) - return - } - } - } - }() - wg.Wait() - slog.Debug("2 goroutines is Done") -} diff --git a/client/const.go b/client/const.go deleted file mode 100644 index d6b61f5..0000000 --- a/client/const.go +++ /dev/null @@ -1,42 +0,0 @@ -package client - -import ( - _ "embed" - "log/slog" - "net" -) - -const ( - socks5Version = 0x05 - socks5ReplyOkay = 0x00 - socks5ReplyFail = 0x01 - socks5ReplyReserved = 0x00 - socks5CmdConnect = 0x01 - socks5CmdBind = 0x02 - socks5CmdUdpAssoc = 0x03 - socks5AtypeIPv4 = 0x01 - socks5AtypeDomain = 0x03 - socks5AtypeIPv6 = 0x04 - socks5UdpFragNotSupported = 0x00 - socks5UdpFragEnd = 0x80 - - bufferSize = 64 << 10 -) - -func socks5Response(conn net.Conn, ipv4 net.IP, port int, socks5OkayOrFail byte) { - if socks5OkayOrFail != socks5ReplyOkay { - ipv4 = net.IPv4zero - port = 0 - } - if ipv4 == nil { - ipv4 = net.IPv4zero - } - if port < 0 || port > 65535 { - port = 0 - } - response := []byte{socks5Version, socks5OkayOrFail, socks5ReplyReserved, socks5AtypeIPv4, ipv4[0], ipv4[1], ipv4[2], ipv4[3], byte(port >> 8), byte(port & 0xff)} - _, err := conn.Write(response) - if err != nil { - slog.Error("socks5 request rely failed to write", "err", err.Error()) - } -} diff --git a/client/geo.go b/client/geo.go deleted file mode 100644 index 4ad00c8..0000000 --- a/client/geo.go +++ /dev/null @@ -1,51 +0,0 @@ -package client - -import ( - _ "embed" - "fmt" - "github.com/oschwald/geoip2-golang" - "log/slog" - "net" -) - -//go:embed GeoLite2-Country.mmdb -var geoData []byte - -var geoDB *geoip2.Reader - -func init() { - db, err := geoip2.FromBytes(geoData) - if err != nil { - slog.Error("failed to load geo database", "err", err) - } else { - geoDB = db - } -} - -func GeoDbClose() { - if geoDB != nil { - geoDB.Close() - } -} - -func Country(hostOrIp string) (isoCountryCode string, err error) { - if geoDB == nil { - return "", fmt.Errorf("geo databse is nil") - } - ip := net.ParseIP(hostOrIp) - if ip == nil { - ips, err := net.LookupIP(hostOrIp) - if err != nil { - return "", fmt.Errorf("failed to lookup IP: %w", err) - } - if len(ips) == 0 { - return "", fmt.Errorf("no IP found for %s", hostOrIp) - } - ip = ips[0] - } - record, err := geoDB.Country(ip) - if err != nil { - return "", fmt.Errorf("failed to get Country: %w", err) - } - return record.Country.IsoCode, nil -} diff --git a/client/proxy.go b/client/proxy.go deleted file mode 100644 index fa2104a..0000000 --- a/client/proxy.go +++ /dev/null @@ -1,42 +0,0 @@ -package client - -import ( - "fmt" - "strings" -) - -type Proxy struct { - Name string - Protocol string //ws,wss,http2,tls,http3 - Host string - Uri string - Sni string - Version string // one socks5 - - UserID string - Password string - TrafficKb int64 - SpeedMs int64 - Status string //active, inactive -} - -func (p *Proxy) RelayURL() string { - switch p.Protocol { - case "ws": - return fmt.Sprintf("ws://%s/%s", p.Host, strings.TrimPrefix(p.Uri, "/")) - case "wss": - return fmt.Sprintf("wss://%s/%s", p.Host, strings.TrimPrefix(p.Uri, "/")) - case "tcp+tls": - return fmt.Sprintf("tcp-tls://%s/%s", p.Host, strings.TrimPrefix(p.Uri, "/")) - default: - return "" - } -} - -func (p *Proxy) EnableEarlyData() bool { - //https://xtls.github.io/config/transports/websocket.html#websocketobject - if strings.Contains(p.Uri, "?ed=") { - return true - } - return false -} diff --git a/client/relay_svr.go b/client/relay_svr.go deleted file mode 100644 index be87391..0000000 --- a/client/relay_svr.go +++ /dev/null @@ -1,9 +0,0 @@ -package client - -import "io" - -type RelayTcp interface { - io.Reader - io.Writer - io.Closer -} diff --git a/client/relay_tcp_direct.go b/client/relay_tcp_direct.go deleted file mode 100644 index ed3f60d..0000000 --- a/client/relay_tcp_direct.go +++ /dev/null @@ -1,29 +0,0 @@ -package client - -import "net" - -var _ RelayTcp = (*RelayTcpDirect)(nil) - -type RelayTcpDirect struct { - conn net.Conn -} - -func NewRelayTcpDirect(req *Socks5Request) (*RelayTcpDirect, error) { - conn, err := net.Dial("tcp", req.addr()) - if err != nil { - return nil, err - } - return &RelayTcpDirect{conn: conn}, nil -} - -func (r *RelayTcpDirect) Read(p []byte) (n int, err error) { - return r.conn.Read(p) -} - -func (r *RelayTcpDirect) Write(p []byte) (n int, err error) { - return r.conn.Write(p) -} - -func (r *RelayTcpDirect) Close() error { - return r.conn.Close() -} diff --git a/client/relay_tcp_socks5e.go b/client/relay_tcp_socks5e.go deleted file mode 100644 index 1b75139..0000000 --- a/client/relay_tcp_socks5e.go +++ /dev/null @@ -1,61 +0,0 @@ -package client - -import ( - "context" - "github.com/gorilla/websocket" - "log/slog" - "time" -) - -type RelayTcpSocks5e struct { - cfg *Proxy - req *Socks5Request - conn *websocket.Conn -} - -func NewRelayTcpSocks5e(ctx context.Context, cfg *Proxy, req *Socks5Request) (*RelayTcpSocks5e, error) { - ws, err := webSocketConn(ctx, cfg, req) - if err != nil { - return nil, err - } - ws.SetCloseHandler(func(code int, text string) error { - slog.Debug("ws has closed", "code", code, "text", text) - return nil - }) - return &RelayTcpSocks5e{cfg: cfg, req: req, conn: ws}, nil -} - -func (r RelayTcpSocks5e) Read(data []byte) (n int, err error) { - if r.conn != nil { - _, p, err := r.conn.ReadMessage() - if err != nil { - slog.Error("failed to read ws", "err", err.Error()) - } - return copy(data, p), err - } - return 0, nil -} - -func (r RelayTcpSocks5e) Write(data []byte) (n int, err error) { - if r.conn != nil { - err = r.conn.WriteMessage(websocket.BinaryMessage, data) - if err != nil { - slog.Error("failed to write ws", "err", err.Error()) - } - return len(data), err - } - return 0, nil -} - -func (r RelayTcpSocks5e) Close() error { - if r.conn != nil { - err := r.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Millisecond*20)) - if err != nil { - slog.Error("failed to close ws", "err", err.Error()) - } - return r.conn.Close() - } - return nil -} - -var _ RelayTcp = (*RelayTcpSocks5e)(nil) diff --git a/client/relay_udp_direct.go b/client/relay_udp_direct.go deleted file mode 100644 index e0ae5f4..0000000 --- a/client/relay_udp_direct.go +++ /dev/null @@ -1,283 +0,0 @@ -package client - -import ( - "errors" - "fmt" - "io" - "log" - "log/slog" - "net" - "sort" - "sync" - "time" -) - -type RelayUdpDirect struct { - s5 net.Conn - relayUdp *net.UDPConn - - // Reassembly queue for fragmented UDP packets. - mu sync.Mutex - fragments map[string][]*udpPacket // Map of DstAddr to fragments - highestFrag map[string]byte // Track highest FRAG value for each DstAddr - timers map[string]*time.Timer // Map of DstAddr to reassembly timer - timerDuration time.Duration // Timer duration -} - -func (ud *RelayUdpDirect) addFragment(clientAddr *net.UDPAddr, frag *udpPacket) { - ud.mu.Lock() - defer ud.mu.Unlock() - clientDstAddr := ud.clientDstAddrAsID(clientAddr, frag.dstAddr()) - // Initialize fragment queue and timer if not already present. - if _, exists := ud.fragments[clientDstAddr]; !exists { - ud.fragments[clientDstAddr] = []*udpPacket{} - ud.highestFrag[clientDstAddr] = socks5UdpFragNotSupported - ud.startTimer(clientDstAddr) - } - - // Update highest FRAG value. - if frag.Frag > ud.highestFrag[clientDstAddr] { - ud.highestFrag[clientDstAddr] = frag.Frag - } - - // Add fragment to the queue. - ud.fragments[clientDstAddr] = append(ud.fragments[clientDstAddr], frag) - - // Check if this is the final fragment (end-of-fragment sequence). - if frag.Frag == socks5UdpFragEnd || frag.Frag == socks5UdpFragNotSupported { // High-order bit indicates end of sequence. - ud.assembleThenPipeUdp(clientAddr, frag.dstAddr()) - } -} - -func (ud *RelayUdpDirect) startTimer(ClientDstAddr string) { - if timer, exists := ud.timers[ClientDstAddr]; exists { - timer.Stop() - } - ud.timers[ClientDstAddr] = time.AfterFunc(ud.timerDuration, func() { - ud.mu.Lock() - defer ud.mu.Unlock() - delete(ud.fragments, ClientDstAddr) - delete(ud.highestFrag, ClientDstAddr) - delete(ud.timers, ClientDstAddr) - }) -} -func (ud *RelayUdpDirect) clientDstAddrAsID(clientAddr *net.UDPAddr, dstAddr string) string { - return fmt.Sprintf("%s/%s", clientAddr, dstAddr) -} -func (ud *RelayUdpDirect) assembleThenPipeUdp(clientAddr *net.UDPAddr, dstAddr string) { - var data []byte - clientDstAddr := ud.clientDstAddrAsID(clientAddr, dstAddr) - fragments := ud.fragments[clientDstAddr] - if len(fragments) == 0 { - return // No fragments to process - } - - // Sort fragments by FRAG value. - sort.Slice(fragments, func(i, j int) bool { - return fragments[i].Frag < fragments[j].Frag - }) - for _, frag := range fragments { - data = append(data, frag.Data...) - } - comboPacket := fragments[0] - comboPacket.Data = data - - // Clean up after successful reassembly - ensure timer is stopped - if timer, exists := ud.timers[clientDstAddr]; exists { - timer.Stop() - delete(ud.timers, clientDstAddr) - } - delete(ud.fragments, clientDstAddr) - delete(ud.highestFrag, clientDstAddr) - - ud.segmentPipe(comboPacket, clientAddr) -} - -func (ud *RelayUdpDirect) PipeUdp() { - buf := make([]byte, bufferSize) - for { - n, clientAddr, err := ud.relayUdp.ReadFromUDP(buf) - if errors.Is(err, io.EOF) { - return - } - //I will not verify the `clientAddr` because this SOCKS5 proxy is intended for local relay to bypass the GFW. - if err != nil { - slog.Error("Error reading UDP data", "err", err.Error()) - continue - } - packet, err := parseUDPData(buf[:n]) - if err != nil { - log.Println("Error parsing UDP data", err) - continue - } - ud.addFragment(clientAddr, packet) - } -} - -func (ud *RelayUdpDirect) segmentPipe(comboPacket *udpPacket, clientAddr *net.UDPAddr) { - resp, err := forwardUDPData(comboPacket) - if err != nil { - slog.Error("Error forwarding UDP data", "err", err.Error()) - return - } - header := comboPacket.ResponseData(resp) - _, err = ud.relayUdp.WriteToUDP(header, clientAddr) - if err != nil { - slog.Error("Error sending UDP response", "err", err.Error()) - } -} - -func NewRelayUdpDirect(s5 net.Conn) (*RelayUdpDirect, error) { - udpAddr := &net.UDPAddr{IP: net.IPv4zero, Port: 0} - udpConn, err := net.ListenUDP("udp", udpAddr) - if err != nil { - return nil, fmt.Errorf("failed to bind UDP socket: %w", err) - } - ud := &RelayUdpDirect{ - s5: s5, - relayUdp: udpConn, - mu: sync.Mutex{}, - fragments: make(map[string][]*udpPacket), - highestFrag: make(map[string]byte), - timers: make(map[string]*time.Timer), - timerDuration: time.Second * 60, - } - - boundAddr := udpConn.LocalAddr().(*net.UDPAddr) - response := []byte{ - socks5Version, socks5ReplyOkay, socks5ReplyReserved, socks5AtypeIPv4, - boundAddr.IP[0], boundAddr.IP[1], boundAddr.IP[2], boundAddr.IP[3], - byte(boundAddr.Port >> 8), byte(boundAddr.Port & 0xFF), - } - _, err = ud.s5.Write(response) - if err != nil { - return nil, fmt.Errorf("failed to send response to client: %w", err) - } - - return ud, nil -} - -func forwardUDPData(udpPacket *udpPacket) ([]byte, error) { - conn, err := net.DialUDP("udp", nil, udpPacket.addr()) - if err != nil { - return nil, err - } - defer conn.Close() - - _, err = conn.Write(udpPacket.Data) - if err != nil { - return nil, err - } - - buf := make([]byte, bufferSize) - n, _, err := conn.ReadFromUDP(buf) - if err != nil { - return nil, err - } - return buf[:n], nil -} - -type udpPacket struct { - RSV [2]byte // reserved - Frag byte // fragment - AType byte // dst address type - Addr []byte // dst address - Port []byte // dst port - Data []byte // payload -} - -func (p udpPacket) ResponseData(payload []byte) []byte { - header := []byte{p.RSV[0], p.RSV[1], 0, p.AType} - header = append(header, p.Addr...) - header = append(header, p.Port...) - return append(header, payload...) -} - -func parseUDPData(data []byte) (*udpPacket, error) { - if len(data) < 4 { - return nil, fmt.Errorf("invalid UDP packet") - } - // parse header - var packet = udpPacket{ - RSV: [2]byte{data[0], data[1]}, - Frag: data[2], - AType: data[3], - } - switch packet.AType { - case socks5AtypeIPv4: - if len(data) < 10 { - return nil, fmt.Errorf("invalid IPv4 UDP packet") - } - packet.Addr = data[4 : 4+net.IPv4len] - packet.Port = data[4+net.IPv4len : 4+net.IPv4len+2] - packet.Data = data[4+net.IPv4len+2:] - case socks5AtypeIPv6: - if len(data) < 22 { - return nil, fmt.Errorf("invalid IPv6 UDP packet") - } - packet.Addr = data[4 : 4+net.IPv6len] - packet.Port = data[4+net.IPv6len : 4+net.IPv6len+2] - packet.Data = data[4+net.IPv6len+2:] - case socks5AtypeDomain: - if len(data) < 7 { - return nil, fmt.Errorf("invalid domain UDP packet") - } - addrLen := int(data[4]) - packet.Addr = data[5 : 5+addrLen] - packet.Port = data[5+addrLen : 5+addrLen+2] - packet.Data = data[5+addrLen+2:] - default: - return nil, fmt.Errorf("unsupported address type: %d", packet.AType) - } - return &packet, nil -} - -func (p udpPacket) ip() net.IP { - switch p.AType { - case socks5AtypeIPv4, socks5AtypeIPv6: - return p.Addr - case socks5AtypeDomain: - ips, err := net.LookupIP(string(p.Addr)) - if err != nil { - slog.Error("failed to resolve domain", "err", err.Error()) - return net.IPv4zero - } - if len(ips) == 0 { - return net.IPv4zero - } - return ips[0] - default: - return net.IPv4zero - } -} - -func (p udpPacket) port() int { - return int(p.Port[0])<<8 + int(p.Port[1]) -} -func (p udpPacket) addr() *net.UDPAddr { - return &net.UDPAddr{IP: p.ip(), Port: p.port()} -} -func (p udpPacket) dstAddr() string { - return fmt.Sprintf("%s:%d", p.ip(), p.port()) -} - -func (ud *RelayUdpDirect) Close() { - // Clean up all timers before closing - ud.mu.Lock() - for clientDstAddr, timer := range ud.timers { - timer.Stop() - delete(ud.timers, clientDstAddr) - } - // Clear all maps to prevent memory leaks - clear(ud.fragments) - clear(ud.highestFrag) - ud.mu.Unlock() - - //s5 has already been closed in outside - if ud.relayUdp != nil { - err := ud.relayUdp.Close() - if err != nil { - log.Println("close udp conn failed: ", err) - } - } -} diff --git a/client/socks5_bind.go b/client/socks5_bind.go deleted file mode 100644 index 1bd5209..0000000 --- a/client/socks5_bind.go +++ /dev/null @@ -1,66 +0,0 @@ -package client - -import ( - "io" - "log/slog" - "net" - "sync" -) - -func relayBind(s5 net.Conn, _ *Socks5Request) { - bindListener, err := net.Listen("tcp4", ":0") - if err != nil { - slog.Error("bind tcp failed", "err", err) - socks5Response(s5, net.IPv4zero, 0, socks5ReplyFail) - return - } - defer bindListener.Close() - //first reply - localAddr := bindListener.Addr().(*net.TCPAddr) - socks5Response(s5, localAddr.IP, localAddr.Port, socks5ReplyOkay) - - targetConn, err := bindListener.Accept() - if err != nil { - slog.Error("bind tcp failed", "err", err) - return - } - defer targetConn.Close() - //sec reply - targetAddr := targetConn.RemoteAddr().(*net.TCPAddr) - socks5Response(s5, targetAddr.IP, targetAddr.Port, socks5ReplyOkay) - - var wg sync.WaitGroup - // Use a done channel to ensure both goroutines exit when one finishes - done := make(chan struct{}) - - wg.Add(2) - go func() { - defer wg.Done() - defer func() { - close(done) // Signal the other goroutine to exit - targetConn.Close() // Force close to interrupt the other goroutine - s5.Close() - }() - _, err := io.Copy(targetConn, s5) - if err != nil { - slog.Error("bind tcp failed", "err", err) - } - }() - go func() { - defer wg.Done() - defer func() { - targetConn.Close() // Force close to interrupt the other goroutine - s5.Close() - }() - select { - case <-done: - return // Other goroutine finished, exit - default: - } - _, err := io.Copy(s5, targetConn) - if err != nil { - slog.Error("bind tcp failed", "err", err) - } - }() - wg.Wait() -} diff --git a/client/socks5_req.go b/client/socks5_req.go deleted file mode 100644 index c949af4..0000000 --- a/client/socks5_req.go +++ /dev/null @@ -1,134 +0,0 @@ -package client - -import ( - "fmt" - "github.com/google/uuid" - "log/slog" - "net" -) - -type Socks5Request struct { - id string - socks5Cmd byte - socks5Atyp byte - dstAddr []byte - dstPort []byte - CountryCode string //iso country code -} - -func parseSocks5Request(data []byte) (*Socks5Request, error) { - id := uuid.NewString() - info := &Socks5Request{id: id} - - if data[0] != socks5Version { - return nil, fmt.Errorf("unsupported SOCKS version: %d", data[0]) - } - if data[1] == socks5CmdConnect { - info.socks5Cmd = socks5CmdConnect - } else if data[1] == socks5CmdUdpAssoc { - info.socks5Cmd = socks5CmdUdpAssoc - } else { - //BIND is not supported - return nil, fmt.Errorf("unsupported command: %d", data[1]) - } - if data[2] != socks5ReplyReserved { - return nil, fmt.Errorf("RSV must be 0x00") - } - if data[3] == socks5AtypeIPv4 { - if len(data) < 10 { - return nil, fmt.Errorf("request too short for atyp IPv4") - } - info.socks5Atyp = socks5AtypeIPv4 - info.dstAddr = data[4:8] - info.dstPort = data[8:10] - } else if data[3] == socks5AtypeDomain { - if len(data) < 5 { - return nil, fmt.Errorf("request too short for atyp Domain") - } - addrLen := int(data[4]) - info.socks5Atyp = socks5AtypeDomain - info.dstAddr = data[5 : 5+addrLen] - info.dstPort = data[5+addrLen : 5+addrLen+2] - } else if data[3] == socks5AtypeIPv6 { - if len(data) < 22 { - return nil, fmt.Errorf("request too short for atyp IPv6") - } - info.socks5Atyp = socks5AtypeIPv6 - info.dstAddr = data[4:20] - info.dstPort = data[20:22] - } else { - return nil, fmt.Errorf("unsupported address type: %d", data[3]) - } - //only get country code for connect command - if info.socks5Cmd == socks5CmdConnect { - code, err := Country(info.host()) - if err != nil { - info.Logger().Error("failed to get country code", "err", err.Error()) - } else { - info.CountryCode = code - } - } - return info, nil -} - -func (s Socks5Request) host() string { - addr := "" - if s.socks5Atyp == socks5AtypeIPv4 || s.socks5Atyp == socks5AtypeIPv6 { - addr = net.IP(s.dstAddr).String() - } else if s.socks5Atyp == socks5AtypeDomain { - addr = string(s.dstAddr) - } else { - addr = string(s.dstAddr) - } - return addr -} - -func (s Socks5Request) addr() string { - return fmt.Sprintf("%s:%s", s.host(), s.port()) -} -func (s Socks5Request) cmd() string { - cmd := "unknown" - if s.socks5Cmd == socks5CmdConnect { - cmd = "connect" - } else if s.socks5Cmd == socks5CmdUdpAssoc { - cmd = "udp" - } else if s.socks5Cmd == socks5CmdBind { - cmd = "bind" - } - return cmd -} - -func (s Socks5Request) Network() string { - cmd := "unknown" - if s.socks5Cmd == socks5CmdConnect { - cmd = "tcp" - } else if s.socks5Cmd == socks5CmdUdpAssoc { - cmd = "udp" - } else if s.socks5Cmd == socks5CmdBind { - cmd = "bind" - } - return cmd -} - -func (s Socks5Request) aType() string { - return fmt.Sprintf("%v", s.socks5Atyp) -} - -func (s Socks5Request) port() string { - port := int(s.dstPort[0])<<8 + int(s.dstPort[1]) - return fmt.Sprintf("%v", port) -} - -func (s Socks5Request) Logger() *slog.Logger { - return slog.With("reqId", s.id, "cmd", s.cmd(), "atyp", s.aType(), "ip", s.host(), "port", s.port(), "country", s.CountryCode) -} -func (s Socks5Request) String() string { - return fmt.Sprintf("socks5Cmd: %v, socks5Atyp: %v, dstAddr: %v, dstPort: %v, country: %s", s.cmd(), s.aType(), s.host(), s.port(), s.CountryCode) -} - -func (s Socks5Request) addressBytes() []byte { - if s.socks5Atyp == socks5AtypeDomain { - return append([]byte{byte(len(s.dstAddr))}, s.dstAddr...) - } - return s.dstAddr -} diff --git a/client/websocket.go b/client/websocket.go deleted file mode 100644 index 7268d10..0000000 --- a/client/websocket.go +++ /dev/null @@ -1,40 +0,0 @@ -package client - -import ( - "context" - "crypto/tls" - "fmt" - "github.com/gorilla/websocket" - "log/slog" - "net/http" -) - -const ( - browserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" -) - -func webSocketConn(ctx context.Context, proxy *Proxy, req *Socks5Request) (*websocket.Conn, error) { - wsDialer := websocket.DefaultDialer - - headers := http.Header{} - headers.Set("Authorization", proxy.UserID) - headers.Set("User-Agent", browserAgent) - if proxy.Sni != "" { - headers.Set("Host", proxy.Sni) - wsDialer.TLSClientConfig = &tls.Config{ - ServerName: proxy.Sni, // Set the SNI to the hostname of the server - } - } - headers.Set("x-req-id", req.id) - - url := proxy.RelayURL() - slog.Debug("connecting to remote proxy server", "url", url) - ws, resp, err := websocket.DefaultDialer.DialContext(ctx, url, headers) - if err != nil { - return nil, fmt.Errorf("failed to connect to remote proxy server: %s ,error:%v", proxy.RelayURL(), err) - } - if resp.StatusCode != http.StatusSwitchingProtocols { - return nil, fmt.Errorf("failed to connect to remote proxy server: %s ,error:%v", proxy.RelayURL(), err) - } - return ws, nil -} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..1f004fb --- /dev/null +++ b/config.toml @@ -0,0 +1,14 @@ +#toml 格式的文件,建议字符串使用单引号,大小敏感 +# + +SubAddresses = '127.0.0.1:8013'# 可以被广域网访问的域名端口,可以是域名也可以是ip,多个地址用逗号分隔 +AppPort = '8013' # 服务的端口,可以是80,443,在大陆其他的端口不能被访问 +RegisterUrl = '' #主控服务器地址,主要作用是控制用户授权和流量计费,可以为空则为个人模式 +RegisterToken = 'unchain.people.from.censorship.and.surveillance'# 主控服务器的token +AllowUsers = '6fe57e3f-e618-4873-ba96-a76adec22ccd' # UUID 可以访问的用户UUID,多个则用逗号分隔.个人模式这里不能为空 在线UUID生成器 https://1024tools.com/uuid +LogFile = '' # 日志文件名,可以为空则不记录日志 +DebugLevel = 'debug' # 日志基本debug, info, warn, error +IntervalSecond = '7200' #主控服务器推送流量数据的间隔,个人模式不关心 +EnableDataUsageMetering = 'false' + + diff --git a/deployment_guide_v0.0.4_zh.md b/deployment_guide_v0.0.4_zh.md deleted file mode 100644 index 38186df..0000000 --- a/deployment_guide_v0.0.4_zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Unchain 翻墙服务器搭建和使用教程 - -## 服务器要求 -建议使用linux ubuntu 服务器. 服务器配配置没有内存和CPU限制,任意配置都可以. 最便宜的服务器都可以. - -服务求区域最好选择日本或者美国(访问OpenAI,Claude,Google Gemini友好). - - -## 安装部署 - -从 [https://github.com/unchainese/unchain/releases/tag/v0.0.4](https://github.com/unchainese/unchain/releases/tag/v0.0.4) -现在对应服务器架构的二进制文件,解压到任意目录,然后运行即可. - -```bash -wget https://github.com/unchainese/unchain/releases/download/v0.0.4/unchain-linux-amd64.unchain.tar.gz -tar -zxvf unchain-linux-amd64.unchain.tar.gz -``` - -在上一步解压之后的可执行文件相同的目录创建 `config.toml`配置文件. -创建文件命令 `vim config.toml`. -文件内容详见 [https://github.com/unchainese/unchain/blob/v0.0.4/config.example.standalone.toml](https://github.com/unchainese/unchain/blob/v0.0.4/config.example.standalone.toml) - -使用下面命令,来测试配置文件是否正确. -```bash -# cd 到 unchain 和 config.toml 所在目录 -chmod +x unchain -./unchain -``` -如果没有报错,说明配置文件正确. - - -## 使用systemctl 管理服务 - -在 `/etc/systemd/system/` 目录下创建 `unchain.service` 文件, -使用命令 `vim /etc/systemd/system/unchain.service` 创建文件. -文件内容如下: -[https://github.com/unchainese/unchain/blob/v0.0.4/unchain.service](https://github.com/unchainese/unchain/blob/v0.0.4/unchain.service) - - - `systemctl daemon-reload` 重新加载服务 - - `systemctl start unchain` 启动服务 - - `systemctl stop unchain` 停止服务 - - `systemctl restart unchain` 重启服务 - - `systemctl status unchain` 查看服务状态 - - `journalctl -u unchain` 查看服务日志 - -执行 `systemctl daemon-reload` 加载刚才的配置 -执行 `systemctl start unchain` 启动服务,如果没有报错,说明服务启动成功. - -## 使用V2ray/Clash/ShadowRocket 客户端连接 - -复制日志中的 VLESS链接,在V2ray/Clash/ShadowRocket 客户端中添加VLESS链接. \ No newline at end of file diff --git a/go.mod b/go.mod index 40048c7..34f8fd0 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,4 @@ require ( github.com/gorilla/websocket v1.5.3 ) -require ( - github.com/BurntSushi/toml v1.4.0 - github.com/oschwald/geoip2-golang v1.11.0 -) - -require ( - github.com/oschwald/maxminddb-golang v1.13.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - golang.org/x/sys v0.20.0 // indirect -) +require github.com/BurntSushi/toml v1.4.0 diff --git a/go.sum b/go.sum index 08084f8..284ae24 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,6 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/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= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= -github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= -github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= -github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= -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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 2fd91bf..c8174bf 100644 --- a/main.go +++ b/main.go @@ -13,12 +13,17 @@ import ( var configFilePath, installMode, action string func main() { - flag.StringVar(&action, "action", "run", "动作参数,可选值: run, install,uninstall,info,run") + flag.StringVar(&action, "action", "run", "动作参数,可选值: run, install,uninstall,info,client") flag.StringVar(&configFilePath, "config", "config.toml", "配置文件路径") flag.StringVar(&installMode, "mode", "single", "安装命令的模式参数") flag.Parse() //todo:: switch case command + if action == "client" { + server.StartSocks5Server() + return + } + c := global.Cfg(configFilePath) //using default config.toml file fd := global.SetupLogger(c) defer fd.Close() @@ -27,7 +32,7 @@ func main() { stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt) - app := node.NewApp(c, stop) + app := server.NewApp(c, stop) app.PushNode() //register node info to the manager server app.PrintVLESSConnectionURLS() //for standalone node go app.Run() diff --git a/schema/trojan.go b/schema/trojan.go index 16f09a9..e0dfbc1 100644 --- a/schema/trojan.go +++ b/schema/trojan.go @@ -2,6 +2,7 @@ package schema import ( "bytes" + "crypto/sha256" "encoding/binary" "errors" "fmt" @@ -25,6 +26,13 @@ const ( byteLF = '\n' ) +func (p ProtoTrojan) AuthUser(password string) (isOk bool) { + sha224Hash := sha256.New224() + sha224Hash.Write([]byte(password)) + sha224Sum := sha224Hash.Sum(nil) //28 bytes + hexSha224Bytes := []byte(fmt.Sprintf("%x", sha224Sum)) + return bytes.Equal(p.sha224password, hexSha224Bytes) +} func parseTrojanHeader(buffer []byte) (*ProtoTrojan, error) { if len(buffer) < 56 { return nil, errors.New("invalid data") diff --git a/schema/vless.go b/schema/vless.go index bd5af5f..9c13ee2 100644 --- a/schema/vless.go +++ b/schema/vless.go @@ -1,8 +1,6 @@ package schema import ( - "bytes" - "crypto/sha256" "encoding/binary" "errors" "fmt" @@ -13,7 +11,7 @@ import ( ) type ProtoVLESS struct { - userID uuid.UUID + UserID uuid.UUID DstProtocol string //tcp or udp dstHost string dstHostType string //ipv6 or ipv4,domain @@ -22,16 +20,55 @@ type ProtoVLESS struct { payload []byte } -func (p ProtoTrojan) AuthUser(password string) (isOk bool) { - sha224Hash := sha256.New224() - sha224Hash.Write([]byte(password)) - sha224Sum := sha224Hash.Sum(nil) //28 bytes - hexSha224Bytes := []byte(fmt.Sprintf("%x", sha224Sum)) - return bytes.Equal(p.sha224password, hexSha224Bytes) +const VLESS_VERSION = 0 + +func MakeVless(userID string, dstHost string, dstPort uint16, tcpOrUdp string, payload []byte) *ProtoVLESS { + if tcpOrUdp != "tcp" && tcpOrUdp != "udp" { + panic("tcpOrUdp must be tcp or udp") + } + return &ProtoVLESS{ + UserID: uuid.MustParse(userID), + DstProtocol: tcpOrUdp, + dstHost: dstHost, + dstPort: dstPort, + Version: VLESS_VERSION, + payload: payload, + } } func (h ProtoVLESS) UUID() string { - return h.userID.String() + return h.UserID.String() +} +func (h ProtoVLESS) DataHeader() []byte { + header := make([]byte, 0) + header = append(header, h.Version) + header = append(header, h.UserID[:]...) + header = append(header, 0) // no extra info length 0 + switch h.DstProtocol { + case "tcp": + header = append(header, 1) + case "udp": + header = append(header, 2) + default: + panic("unsupported protocol") + } + //two bytes of port + header = append(header, byte(h.dstPort>>8), byte(h.dstPort&0xff)) + //address type + thisIP := net.ParseIP(h.dstHost) + if thisIP != nil && thisIP.To4() != nil { + header = append(header, 1) // IPv4 + header = append(header, thisIP.To4()...) + } else if thisIP != nil && thisIP.To16() != nil { + header = append(header, 3) // IPv6 + header = append(header, thisIP.To16()...) + } else { + header = append(header, 2) // domain + header = append(header, byte(len(h.dstHost))) + header = append(header, []byte(h.dstHost)...) + } + header = append(header, h.payload...) + return header } func (h ProtoVLESS) DataUdp() []byte { @@ -54,6 +91,27 @@ func (h ProtoVLESS) DataUdp() []byte { } return allData } + +func (h ProtoVLESS) DataUdpWrong() []byte { + allData := make([]byte, 0) + chunk := h.payload + for index := 0; index < len(chunk); { + if index+2 > len(chunk) { + fmt.Println("Incomplete length buffer") + return nil + } + lengthBuffer := chunk[index : index+2] + udpPacketLength := binary.BigEndian.Uint16(lengthBuffer) + if index+2+int(udpPacketLength) > len(chunk) { + fmt.Println("Incomplete UDP packet") + return nil + } + udpData := chunk[index+2 : index+2+int(udpPacketLength)] + index = index + 2 + int(udpPacketLength) + allData = append(allData, udpData...) + } + return allData +} func (h ProtoVLESS) DataTcp() []byte { return h.payload } @@ -81,13 +139,13 @@ func (h ProtoVLESS) HostPort() string { return net.JoinHostPort(h.dstHost, fmt.Sprintf("%d", h.dstPort)) } func (h ProtoVLESS) Logger() *slog.Logger { - return slog.With("userID", h.userID.String(), "network", h.DstProtocol, "addr", h.HostPort()) + return slog.With("userID", h.UserID.String(), "network", h.DstProtocol, "addr", h.HostPort()) } // VLESSParse https://xtls.github.io/development/protocols/vless.html func VLESSParse(buf []byte) (*ProtoVLESS, error) { payload := &ProtoVLESS{ - userID: uuid.Nil, + UserID: uuid.Nil, DstProtocol: "", dstHost: "", dstPort: 0, @@ -100,7 +158,7 @@ func VLESSParse(buf []byte) (*ProtoVLESS, error) { } payload.Version = buf[0] - payload.userID = uuid.Must(uuid.FromBytes(buf[1:17])) + payload.UserID = uuid.Must(uuid.FromBytes(buf[1:17])) extraInfoProtoBufLen := buf[17] command := buf[18+extraInfoProtoBufLen] diff --git a/server/app.go b/server/app.go index 6ce93a6..988ac77 100644 --- a/server/app.go +++ b/server/app.go @@ -1,4 +1,4 @@ -package node +package server import ( "bytes" @@ -66,8 +66,8 @@ func (app *App) Run() { func (app *App) PrintVLESSConnectionURLS() { listenPort := app.cfg.ListenPort() - fmt.Printf("\n\n\nvist to get VLESS connection info: http://127.0.0.1:%d/sub/ \n", listenPort) - fmt.Printf("vist to get VLESS connection info: http://:%d/sub/\n", listenPort) + fmt.Printf("\n\n visit to get VLESS connection info: http://127.0.0.1:%d/sub/ \n", listenPort) + fmt.Printf("visit to get VLESS connection info: http://:%d/sub/\n", listenPort) app.userUsedTrafficKb.Range(func(id, _ interface{}) bool { userID := id.(string) diff --git a/server/app_ping.go b/server/app_ping.go index 2cc7dcb..680060b 100644 --- a/server/app_ping.go +++ b/server/app_ping.go @@ -1,4 +1,4 @@ -package node +package server import ( "fmt" diff --git a/server/app_sub.go b/server/app_sub.go index 0be96bf..edcb27c 100644 --- a/server/app_sub.go +++ b/server/app_sub.go @@ -1,4 +1,4 @@ -package node +package server import ( "fmt" @@ -20,7 +20,7 @@ import ( // vless://6fe57e3f-e618-4873-ba96-a76ad1112ccd@aws.xxx.cn:80?encryption=none&security=none&sni=s5cf.xxx.cn&allowInsecure=1 // &type=ws -// &hostSni=aws.xxx.cn&path=%2Fws-vless%3Fed%3D2560#locaol-clone +// &hostSni=aws.xxx.cn&path=%2Fws-vless%3Fed%3D2560#local-clone type vlessSub struct { remark string addrWithPort string //eg node.cloudflare.cn:443 or node.cloudflare.cn:80 diff --git a/server/app_ws_vless.go b/server/app_ws_vless.go index f6f6a3b..cef62e6 100644 --- a/server/app_ws_vless.go +++ b/server/app_ws_vless.go @@ -1,4 +1,4 @@ -package node +package server import ( "context" @@ -46,6 +46,7 @@ func startDstConnection(vd *schema.ProtoVLESS, timeout time.Duration) (net.Conn, } func (app *App) WsVLESS(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") //check can upgrade websocket if r.Header.Get(upgradeHeader) != websocketProtocol { @@ -194,6 +195,7 @@ func vlessTCP(ctx context.Context, sv *schema.ProtoVLESS, ws *websocket.Conn) in return trafficMeter.Load() } +// vlessUDP handles UDP traffic over VLESS protocol via WebSocket is tested ok func vlessUDP(_ context.Context, sv *schema.ProtoVLESS, ws *websocket.Conn) (trafficMeter int64) { logger := sv.Logger() conn, headerVLESS, err := startDstConnection(sv, time.Millisecond*1000) @@ -202,18 +204,17 @@ func vlessUDP(_ context.Context, sv *schema.ProtoVLESS, ws *websocket.Conn) (tra return } defer conn.Close() - trafficMeter += int64(len(sv.DataUdp())) - //write early data - _, err = conn.Write(sv.DataUdp()) + udpData := sv.DataUdp() + _, err = conn.Write(udpData) if err != nil { - logger.Error("Error writing early data to TCP connection:", "err", err) + logger.Error("Error writing early data to UDP connection:", "err", err) return } buf := make([]byte, buffSize) n, err := conn.Read(buf) if err != nil { - logger.Error("Error reading from TCP connection:", "err", err) + logger.Error("Error reading from UDP connection:", "err", err) return } udpDataLen1 := (n >> 8) & 0xff @@ -221,11 +222,11 @@ func vlessUDP(_ context.Context, sv *schema.ProtoVLESS, ws *websocket.Conn) (tra headerVLESS = append(headerVLESS, byte(udpDataLen1), byte(udpDataLen2)) headerVLESS = append(headerVLESS, buf[:n]...) - trafficMeter += int64(len(headerVLESS)) + //send back the first udp packet with vless header err = ws.WriteMessage(websocket.BinaryMessage, headerVLESS) if err != nil { logger.Error("Error writing to websocket:", "err", err) return } - return trafficMeter + return int64(len(headerVLESS)) + int64(len(udpData)) } diff --git a/server/socks5.go b/server/socks5.go new file mode 100644 index 0000000..14323e6 --- /dev/null +++ b/server/socks5.go @@ -0,0 +1,488 @@ +package server + +import ( + "encoding/binary" + "fmt" + "io" + "log/slog" + "net" + "os" + "strconv" + + "github.com/gorilla/websocket" + "github.com/unchainese/unchain/schema" +) + +// SOCKS5 protocol and server constants +const ( + socksVersion5 = 0x05 + authMethodNoAuth = 0x00 + reservedField = 0x00 + + cmdConnect = 0x01 + cmdUDPAssociate = 0x03 + + addrTypeIPv4 = 0x01 + addrTypeDomain = 0x03 + addrTypeIPv6 = 0x04 + + replySucceeded = 0x00 + replyHostUnreachable = 0x04 + + responseFixedLen = 10 + udpHeaderLen = 10 + maxUDPPacketSize = 65536 + + networkTCP = "tcp" + networkUDP = "udp" + localhostAnyPort = "127.0.0.1:0" +) + +var ( + socks5Host string = "127.0.0.1" + socks5Port int = 1088 + vlessUUID = "6fe57e3f-e618-4873-ba96-a76adec22ccd" + wsURL = "ws://57.180.17.29/wsv/v1?ed=2560" +) + +func StartSocks5Server() { + addr := fmt.Sprintf("%s:%d", socks5Host, socks5Port) + listener, err := net.Listen("tcp", addr) + if err != nil { + slog.Error(fmt.Sprintf("Failed to start SOCKS5 server: %v", err)) + os.Exit(1) + } + defer listener.Close() + + slog.Info(fmt.Sprintf("SOCKS5 server started on %s", addr)) + slog.Info("Press Ctrl+C to stop the server") + + for { + conn, err := listener.Accept() + if err != nil { + slog.Error(fmt.Sprintf("Failed to accept connection: %v", err)) + continue + } + go handleSocks5Connection(conn) + } +} + +func handleSocks5Connection(client net.Conn) { + defer client.Close() + slog.Debug(fmt.Sprintf("New connection from %s", client.RemoteAddr())) + + // Step 1: Version and authentication methods + if err := handleHandshake(client); err != nil { + slog.Error(fmt.Sprintf("Handshake failed: %v", err)) + return + } + + // Step 2: Request details + request, err := handleRequest(client) + if err != nil { + slog.Error(fmt.Sprintf("Request handling failed: %v", err)) + return + } + + // Step 3: Connect to target and relay data + if err := handleRelay(client, request); err != nil { + slog.Error(fmt.Sprintf("Relay failed: %v", err)) + return + } +} + +func handleHandshake(client net.Conn) error { + // Read version and number of authentication methods + buf := make([]byte, 2) + if _, err := io.ReadFull(client, buf); err != nil { + return fmt.Errorf("failed to read handshake: %v", err) + } + + version := buf[0] + if version != socksVersion5 { + return fmt.Errorf("unsupported SOCKS version: %d", version) + } + + numMethods := buf[1] + methods := make([]byte, numMethods) + if _, err := io.ReadFull(client, methods); err != nil { + return fmt.Errorf("failed to read authentication methods: %v", err) + } + + // For simplicity, we'll accept any authentication method (including no authentication) + // In production, you might want to implement proper authentication + response := []byte{socksVersion5, authMethodNoAuth} // Version 5, No authentication required + if _, err := client.Write(response); err != nil { + return fmt.Errorf("failed to write handshake response: %v", err) + } + + return nil +} + +type socks5Request struct { + command byte + address string + port uint16 +} + +func handleRequest(client net.Conn) (*socks5Request, error) { + // Read request header + header := make([]byte, 4) + if _, err := io.ReadFull(client, header); err != nil { + return nil, fmt.Errorf("failed to read request header: %v", err) + } + + version := header[0] + if version != socksVersion5 { + return nil, fmt.Errorf("unsupported SOCKS version: %d", version) + } + + command := header[1] + if command != cmdConnect && command != cmdUDPAssociate { // CONNECT (0x01) or UDP ASSOCIATE (0x03) + return nil, fmt.Errorf("unsupported command: %d", command) + } + + // Reserved field should be 0 + if header[2] != reservedField { + return nil, fmt.Errorf("invalid reserved field: %d", header[2]) + } + + addressType := header[3] + var address string + var port uint16 + + switch addressType { + case addrTypeIPv4: // IPv4 + addr := make([]byte, 4) + if _, err := io.ReadFull(client, addr); err != nil { + return nil, fmt.Errorf("failed to read IPv4 address: %v", err) + } + address = net.IP(addr).String() + + case addrTypeDomain: // Domain name + domainLen := make([]byte, 1) + if _, err := io.ReadFull(client, domainLen); err != nil { + return nil, fmt.Errorf("failed to read domain length: %v", err) + } + domain := make([]byte, domainLen[0]) + if _, err := io.ReadFull(client, domain); err != nil { + return nil, fmt.Errorf("failed to read domain: %v", err) + } + address = string(domain) + + case addrTypeIPv6: // IPv6 + addr := make([]byte, 16) + if _, err := io.ReadFull(client, addr); err != nil { + return nil, fmt.Errorf("failed to read IPv6 address: %v", err) + } + address = net.IP(addr).String() + + default: + return nil, fmt.Errorf("unsupported address type: %d", addressType) + } + + // Read port + portBytes := make([]byte, 2) + if _, err := io.ReadFull(client, portBytes); err != nil { + return nil, fmt.Errorf("failed to read port: %v", err) + } + port = binary.BigEndian.Uint16(portBytes) + + slog.Info(fmt.Sprintf("SOCKS5 request: %s:%d", address, port)) + + return &socks5Request{ + command: command, + address: address, + port: port, + }, nil +} + +func handleRelay(client net.Conn, request *socks5Request) error { + switch request.command { + case cmdConnect: // CONNECT command + return handleTCPRelay(client, request) + case cmdUDPAssociate: // UDP ASSOCIATE command + return handleUDPRelay(client, request) + default: + return fmt.Errorf("unsupported command: %d", request.command) + } +} + +type targetWs struct { + conn *websocket.Conn + isFirstRead bool +} + +func makeTargetWs(addr, uid string, req *socks5Request) (*targetWs, error) { + target, _, err := websocket.DefaultDialer.Dial(addr, nil) + if err != nil { + return nil, fmt.Errorf("failed to connect to target %s: %v", addr, err) + } + udpOrTcp := "tcp" + if req.command == cmdUDPAssociate { + udpOrTcp = "udp" + } + vlessHeadData := schema.MakeVless(uid, req.address, req.port, udpOrTcp, nil).DataHeader() + err = target.WriteMessage(websocket.BinaryMessage, vlessHeadData) + if err != nil { + return nil, fmt.Errorf("failed to send VLESS header: %w", err) + } + return &targetWs{conn: target, isFirstRead: true}, nil +} + +func (t *targetWs) Read(p []byte) (n int, err error) { + _, bytesRead, err := t.conn.ReadMessage() + if err != nil { + return 0, err + } + if len(bytesRead) > 2 && t.isFirstRead { + // Handle the first read case + t.isFirstRead = false + bytesRead = bytesRead[2:] // Skip the first two bytes + } + return copy(p, bytesRead), nil +} + +func (t *targetWs) Write(p []byte) (n int, err error) { + err = t.conn.WriteMessage(websocket.BinaryMessage, p) + if err != nil { + return 0, err + } + return len(p), nil +} + +func (t *targetWs) Close() error { + return t.conn.Close() +} + +func handleTCPRelay(client net.Conn, request *socks5Request) error { + // Connect to target + target, err := makeTargetWs(wsURL, vlessUUID, request) + if err != nil { + // Send failure response + response := []byte{socksVersion5, replyHostUnreachable, reservedField, addrTypeIPv4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + client.Write(response) + return fmt.Errorf("failed to connect to target %s:%d %v", request.address, request.port, err) + } + defer target.Close() + + // Send success response + // For CONNECT requests, we should return the address and port that was used + // Since we're connecting to the target, we'll return the target's address + response := make([]byte, responseFixedLen) + response[0] = socksVersion5 // Version + response[1] = replySucceeded // Success + response[2] = reservedField // Reserved + response[3] = addrTypeIPv4 // IPv4 address type + + // The request.address contains the host, and request.port contains the port + // For domain names, we'll use 0.0.0.0 + copy(response[4:8], net.IPv4zero) + slog.Debug(fmt.Sprintf("Using fallback IPv4 address for domain: %s", request.address)) + // Set the port + binary.BigEndian.PutUint16(response[8:10], request.port) + slog.Debug(fmt.Sprintf("Response port: %d", request.port)) + + if _, err := client.Write(response); err != nil { + return fmt.Errorf("failed to write success response: %v", err) + } + + slog.Info(fmt.Sprintf("TCP relay established between %s and %s", client.RemoteAddr(), request.address)) + + // Start bidirectional relay + errChan := make(chan error, 2) + + // Client -> Target + go func() { + _, err := io.Copy(target, client) + errChan <- err + }() + + // Target -> Client + go func() { + _, err := io.Copy(client, target) + errChan <- err + }() + + // Wait for either direction to finish + err = <-errChan + if err != nil && err != io.EOF { + slog.Debug(fmt.Sprintf("TCP relay error: %v", err)) + } + + return nil +} + +func handleUDPRelay(client net.Conn, request *socks5Request) error { + // For UDP ASSOCIATE, we need to create a UDP listener + // The client will send UDP packets to this listener + udpAddr, err := net.ResolveUDPAddr(networkUDP, localhostAnyPort) + if err != nil { + response := []byte{socksVersion5, replyHostUnreachable, reservedField, addrTypeIPv4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + client.Write(response) + return fmt.Errorf("failed to resolve UDP address: %v", err) + } + + udpListener, err := net.ListenUDP(networkUDP, udpAddr) + if err != nil { + response := []byte{socksVersion5, replyHostUnreachable, reservedField, addrTypeIPv4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + client.Write(response) + return fmt.Errorf("failed to create UDP listener: %v", err) + } + defer udpListener.Close() + + // Get the actual address and port that was bound + actualAddr := udpListener.LocalAddr().(*net.UDPAddr) + + // Send success response with the UDP listener address + // For UDP ASSOCIATE, we send back the address where the client should send UDP packets + response := make([]byte, responseFixedLen) + response[0] = socksVersion5 // Version + response[1] = replySucceeded // Success + response[2] = reservedField // Reserved + response[3] = addrTypeIPv4 // IPv4 address type + + // Copy the IP address (4 bytes) + copy(response[4:8], actualAddr.IP.To4()) + + // Copy the port (2 bytes, big endian) + binary.BigEndian.PutUint16(response[8:10], uint16(actualAddr.Port)) + + if _, err := client.Write(response); err != nil { + return fmt.Errorf("failed to write UDP response: %v", err) + } + + slog.Info(fmt.Sprintf("UDP association established on %s for client %s", actualAddr.String(), client.RemoteAddr())) + + // Start UDP relay + return handleUDPDataRelay(udpListener, client) +} + +func handleUDPDataRelay(udpListener *net.UDPConn, client net.Conn) error { + buffer := make([]byte, maxUDPPacketSize) // Max UDP packet size + + for { + // Read UDP packet from client + n, clientAddr, err := udpListener.ReadFromUDP(buffer) + if err != nil { + if err == io.EOF { + return nil + } + slog.Debug(fmt.Sprintf("UDP read error: %v", err)) + continue + } + + // Parse SOCKS5 UDP request header + if n < udpHeaderLen { // Minimum header size + continue + } + + // Debug: log the first few bytes of the UDP packet + if n > 20 { + slog.Debug(fmt.Sprintf("UDP packet bytes: %v", buffer[:20])) + } else { + slog.Debug(fmt.Sprintf("UDP packet bytes: %v", buffer[:n])) + } + + // Extract target address from UDP packet + targetAddr, err := parseUDPRequestHeader(buffer[:n]) + if err != nil { + slog.Debug(fmt.Sprintf("Failed to parse UDP header: %v", err)) + continue + } + + slog.Debug(fmt.Sprintf("Parsed UDP target address: %s", targetAddr)) + + // Forward the UDP packet to the target + go func(data []byte, target string) { + if err := forwardUDPPacket(udpListener, clientAddr, target, data); err != nil { + slog.Debug(fmt.Sprintf("UDP forward error: %v", err)) + } + }(buffer[:n], targetAddr) + } +} + +func parseUDPRequestHeader(data []byte) (string, error) { + if len(data) < udpHeaderLen { + return "", fmt.Errorf("insufficient data for UDP header") + } + + // Skip RSV and FRAG fields + addrType := data[3] + var address string + var port uint16 + + switch addrType { + case addrTypeIPv4: // IPv4 + if len(data) < udpHeaderLen { + return "", fmt.Errorf("insufficient data for IPv4 address") + } + address = net.IP(data[4:8]).String() + port = binary.BigEndian.Uint16(data[8:10]) + + slog.Debug(fmt.Sprintf("IPv4 parsing: data[3:7]=%v, data[7:9]=%v, address=%s, port=%d", data[3:7], data[7:9], address, port)) + + case addrTypeDomain: // Domain name + domainLen := int(data[4]) + if len(data) < 7+domainLen { + return "", fmt.Errorf("insufficient data for domain name") + } + address = string(data[5 : 5+domainLen]) + port = binary.BigEndian.Uint16(data[5+domainLen : 7+domainLen]) + + case addrTypeIPv6: // IPv6 + if len(data) < 22 { + return "", fmt.Errorf("insufficient data for IPv6 address") + } + address = net.IP(data[4:20]).String() + port = binary.BigEndian.Uint16(data[20:22]) + + default: + return "", fmt.Errorf("unsupported address type: %d", addrType) + } + + return net.JoinHostPort(address, strconv.Itoa(int(port))), nil +} + +func forwardUDPPacket(udpListener *net.UDPConn, clientAddr *net.UDPAddr, targetAddr string, data []byte) error { + slog.Debug(fmt.Sprintf("Forwarding UDP packet to target %s", targetAddr)) + // Resolve target address + addr, err := net.ResolveUDPAddr(networkUDP, targetAddr) + if err != nil { + return fmt.Errorf("failed to resolve target address: %v", err) + } + req := &socks5Request{command: cmdUDPAssociate, address: addr.AddrPort().Addr().String(), port: addr.AddrPort().Port()} + ws, err := makeTargetWs(wsURL, vlessUUID, req) + if err != nil { + return fmt.Errorf("failed to connect to target %v websocket: %w", addr, err) + } + defer ws.Close() + if _, err := ws.Write(data[udpHeaderLen:]); err != nil { + return fmt.Errorf("failed to write to target websocket: %w", err) + } + + // Read response from target + responseBuffer := make([]byte, maxUDPPacketSize) + n, err := ws.Read(responseBuffer) + if err != nil { + return fmt.Errorf("failed to read from target: %v", err) + } + + // Create response packet with SOCKS5 header + responsePacket := createUDPResponsePacket(data[:udpHeaderLen], responseBuffer[:n]) + + // Send response back to client + _, err = udpListener.WriteToUDP(responsePacket, clientAddr) + if err != nil { + return fmt.Errorf("failed to send response to client: %v", err) + } + + return nil +} + +func createUDPResponsePacket(header []byte, data []byte) []byte { + response := make([]byte, len(header)+len(data)) + copy(response, header) + copy(response[len(header):], data) + return response +} diff --git a/testkit/README.md b/testkit/README.md new file mode 100644 index 0000000..5a3bdb3 --- /dev/null +++ b/testkit/README.md @@ -0,0 +1,59 @@ +# Testing UDP over SOCKS5 with VLESS Using V2Ray Client + +This guide outlines the steps to test UDP functionality over SOCKS5 with VLESS using the V2Ray client. + +## Prerequisites +- Ensure you have the necessary files: `udp_echo_svr.go`, `main.go`, `config.json`, and `udpcheck.py` in the `testkit` directory. +- Access to the server `s3.mojotv.cn` for deploying the UDP echo server. + +## Steps + +1. **Deploy UDP Echo Server**: + - Copy `testkit/udp_echo_svr.go` to the server `s3.mojotv.cn` using SCP. + - On the server, run the following command to start the UDP echo server in the background: + ``` + go run udp_echo_svr.go & + ``` + +2. **Start VLESS Proxy Server**: + + +config.toml +```toml +#toml 格式的文件,建议字符串使用单引号,大小敏感 +# + +SubAddresses = 'n-us1.libragen.cn:80'# 可以被广域网访问的域名端口,可以是域名也可以是ip,多个地址用逗号分隔 +AppPort = '8013' # import same as config.json +RegisterUrl = '' #主控服务器地址,主要作用是控制用户授权和流量计费,可以为空则为个人模式 +RegisterToken = 'unchain.people.from.censorship.and.surveillance'# 主控服务器的token +AllowUsers = '6fe57e3f-e618-4873-ba96-a76adec22ccd' # important! same as config.json uuid +LogFile = '' # +DebugLevel = 'debug' # 日志基本debug, info, warn, error +EnableDataUsageMetering = 'false' +``` + + - In your local environment, navigate to the project root and run: + ``` + go run main.go + ``` + This starts the VLESS over WebSocket proxy server. + +3. **Run V2Ray Client**: + - Follow the installation guide for V2Ray: https://www.v2fly.org/guide/install.html + - Change to the `testkit` directory and start the V2Ray client using the provided configuration: + ``` + cd testkit && v2ray run + ``` + The client will use `testkit/config.json` for configuration. + +4. **Perform UDP Check**: + - From the `testkit` directory, run the UDP check script: + ``` + cd testkit && python3 udpcheck.py + ``` + This will test UDP connectivity through the SOCKS5 proxy. + +## Notes +- Ensure all services are running and accessible before proceeding to the next step. +- Monitor the terminal output for any errors or confirmations during the process. \ No newline at end of file diff --git a/testkit/__pycache__/socks5_udp_send.cpython-313.pyc b/testkit/__pycache__/socks5_udp_send.cpython-313.pyc new file mode 100644 index 0000000..cd85578 Binary files /dev/null and b/testkit/__pycache__/socks5_udp_send.cpython-313.pyc differ diff --git a/testkit/config.json b/testkit/config.json new file mode 100644 index 0000000..21f83a0 --- /dev/null +++ b/testkit/config.json @@ -0,0 +1,38 @@ +{ + "inbounds": [ + { + "port": 1088, + "listen": "127.0.0.1", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 8013, + "users": [ + { + "id": "6fe57e3f-e618-4873-ba96-a76adec22ccd", + "encryption": "none" + } + ] + } + ] + }, + "streamSettings": { + "network": "ws", + "wsSettings": { + "path": "/wsv/v1?ed=2560" + } } + } + ] +} diff --git a/testkit/udp_echo_svr.go b/testkit/udp_echo_svr.go new file mode 100644 index 0000000..45cf919 --- /dev/null +++ b/testkit/udp_echo_svr.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "net" + "os" +) + +// running in s3.mojotv.cn +func main() { + addr, err := net.ResolveUDPAddr("udp", ":8080") + if err != nil { + fmt.Println("Error resolving address:", err) + os.Exit(1) + } + + conn, err := net.ListenUDP("udp", addr) + if err != nil { + fmt.Println("Error listening:", err) + os.Exit(1) + } + defer conn.Close() + + fmt.Println("UDP echo server listening on :8080") + + buffer := make([]byte, 1024) + for { + n, clientAddr, err := conn.ReadFromUDP(buffer) + if err != nil { + fmt.Println("Error reading:", err) + continue + } + + fmt.Printf("Received %d bytes from %s: %s\n", n, clientAddr, string(buffer[:n])) + + _, err = conn.WriteToUDP(buffer[:n], clientAddr) + if err != nil { + fmt.Println("Error writing:", err) + } + } +} diff --git a/testkit/udpcheck.py b/testkit/udpcheck.py new file mode 100644 index 0000000..4848332 --- /dev/null +++ b/testkit/udpcheck.py @@ -0,0 +1,40 @@ + + +import socket +import socks # pip3 install pysocks + +UDP_ECHO_TEST_SERVER_HOST = 's3.mojotv.cn' +UDP_ECHO_TEST_SERVER_PORT = 8080 + +V2RAY_SOCKS5_HOST = '127.0.0.1' +V2RAY_SOCKS5_PORT = 1080 + + +if __name__ == "__main__": + s = socks.socksocket( + socket.AF_INET, socket.SOCK_DGRAM + ) # Same API as socket.socket in the standard lib + try: + s.set_proxy( + socks.SOCKS5, V2RAY_SOCKS5_HOST, V2RAY_SOCKS5_PORT, False, user, pwd + ) # SOCKS4 and SOCKS5 use port 1080 by default + # Can be treated identical to a regular socket object + # Raw DNS request + req = 'abcdexxxx'.encode() + s.sendto(req, (UDP_ECHO_TEST_SERVER_HOST, UDP_ECHO_TEST_SERVER_PORT)) + (rsp, address) = s.recvfrom(4096) + # check req and rsp equality + if len(rsp) < 2: + print("Invalid response") + if rsp[0] == req[0] and rsp[1] == req[1] and len(rsp) == len(req): + print("UDP check passed") + # print req as hex string + print("req: " + " ".join("{:02x}".format(c) for c in req)) + print("res: " + " ".join("{:02x}".format(c) for c in rsp)) + + else: + print("Invalid response") + except socks.ProxyError as e: + print(e.msg) + except socket.error as e: + print(repr(e))