Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
v2ray-plugin*
/bin/
/.idea/
/.gocache/
/.gomodcache/
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,68 @@ On your client
ss-local -c config.json -p 443 --plugin v2ray-plugin --plugin-opts "mode=quic;host=mydomain.me"
```

### SIP003U UDP forwarding

SIP003U support is split between shadowsocks-libev and this plugin:

* shadowsocks-libev `--plugin-mode` decides whether UDP relay traffic is routed through the plugin port.
* v2ray-plugin `udpMode` decides whether this plugin starts its native UDP relay and which UDP transport it uses.

The TCP transport still follows `mode`. Enabling UDP forwarding does not change an existing TCP deployment unless both `mode=websocket` and `udpMode=websocket` are used on the server, where the plugin owns the public WebSocket listener and routes TCP and UDP by path.

#### UDP over QUIC Datagram

On your server

```sh
ss-server -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "server;tls;host=mydomain.me;udpMode=quic"
```

On your client

```sh
ss-local -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "tls;host=mydomain.me;udpMode=quic"
```

To keep TCP on WebSocket while sending UDP through QUIC Datagram, leave `mode` unset or set it to `websocket`:

```sh
ss-server -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "server;tls;host=mydomain.me;mode=websocket;udpMode=quic"
ss-local -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "tls;host=mydomain.me;mode=websocket;udpMode=quic"
```

#### UDP over WebSocket

Use `udpMode=websocket` when UDP traffic also needs to pass through an HTTP/WebSocket proxy path, for example a regular Cloudflare proxied hostname. The TCP WebSocket path remains controlled by `path`; the UDP WebSocket path is controlled by `udpPath` and defaults to `/ray-udp`.

On your server

```sh
ss-server -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "server;tls;host=mydomain.me;mode=websocket;path=/ray;udpMode=websocket;udpPath=/ray-udp"
```

On your client

```sh
ss-local -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "tls;host=mydomain.me;mode=websocket;path=/ray;udpMode=websocket;udpPath=/ray-udp"
```

In this mode the server-side plugin listens on the public TCP port, accepts `/ray-udp` itself, and reverse-proxies the normal TCP WebSocket path to an internal loopback v2ray-core listener. `path` and `udpPath` must both start with `/`, must not contain `?` or `#`, and must be different.

`udpMode=websocket` can also be combined with `mode=quic`. In that layout, TCP relay traffic uses v2ray-core QUIC on UDP while UDP relay traffic uses the plugin's WebSocket listener on TCP. Because TCP and UDP sockets are separate, the same numeric port can be reused without the public WebSocket reverse-proxy layer.

Each encrypted Shadowsocks UDP packet is sent as one WebSocket binary message. The plugin preserves packet boundaries and keeps Shadowsocks UDP payloads opaque; it does not parse, decrypt, modify, coalesce, or fragment UDP payloads.

`udpTimeout` controls the plugin's own UDP flow table and defaults to 30 seconds:

```sh
ss-local -c config.json -p 443 -u --plugin v2ray-plugin --plugin-mode tcp_and_udp --plugin-opts "tls;host=mydomain.me;udpMode=quic;udpTimeout=60"
```

This timeout is separate from shadowsocks-libev's internal UDP relay timeout. The implementation does not add separate UDP local or remote port options and does not fragment oversized UDP datagrams; oversized packets are dropped and logged. Certificate options are shared with the TCP TLS path, so certificate mismatch errors usually mean `host`, `cert`, `certRaw`, or `key` differs between client and server. If TCP works but UDP bypasses the plugin, check that shadowsocks-libev was started with `--plugin-mode tcp_and_udp` or another UDP-capable plugin mode.

`udpMode=quic` uses QUIC Datagram and needs end-to-end UDP reachability to the plugin. It will not work through a regular Cloudflare orange-cloud HTTP proxy because Cloudflare terminates QUIC/HTTP3 at the edge and speaks HTTP to the origin. `udpMode=websocket` is Cloudflare-compatible, but it carries UDP packets over a reliable WebSocket/TCP stream, so packet loss can cause head-of-line blocking.

### Issue a cert for TLS and QUIC

`v2ray-plugin` will look for TLS certificates signed by [acme.sh](https://github.com/acmesh-official/acme.sh) by default.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ toolchain go1.23.2

require (
github.com/golang/protobuf v1.5.4
github.com/quic-go/quic-go v0.48.1
github.com/v2fly/v2ray-core/v5 v5.22.0
google.golang.org/protobuf v1.35.1
)
Expand All @@ -19,7 +20,6 @@ require (
github.com/miekg/dns v1.1.62 // indirect
github.com/onsi/ginkgo/v2 v2.21.0 // indirect
github.com/pires/go-proxyproto v0.8.0 // indirect
github.com/quic-go/quic-go v0.48.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
Expand Down
188 changes: 168 additions & 20 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ var (
remoteAddr = flag.String("remoteAddr", "127.0.0.1", "remote address to forward.")
remotePort = flag.String("remotePort", "1080", "remote port to forward.")
path = flag.String("path", "/", "URL path for websocket.")
udpPath = flag.String("udpPath", "/ray-udp", "URL path for UDP over websocket.")
host = flag.String("host", "cloudfront.com", "Hostname for server.")
tlsEnabled = flag.Bool("tls", false, "Enable TLS.")
cert = flag.String("cert", "", "Path to TLS certificate file. Overrides certRaw. Default: ~/.acme.sh/{host}/fullchain.cer")
certRaw = flag.String("certRaw", "", "Raw TLS certificate content. Intended only for Android.")
key = flag.String("key", "", "(server) Path to TLS key file. Default: ~/.acme.sh/{host}/{host}.key")
mode = flag.String("mode", "websocket", "Transport mode: websocket, quic (enforced tls).")
udpMode = flag.String("udpMode", "", "UDP transport mode: quic, websocket.")
udpTimeout = flag.Int("udpTimeout", 30, "UDP relay timeout in seconds.")
mux = flag.Int("mux", 1, "Concurrent multiplexed connections (websocket client mode only).")
server = flag.Bool("server", false, "Run in server mode")
logLevel = flag.String("loglevel", "", "loglevel for v2ray: debug, info, warning (default), error, none.")
Expand Down Expand Up @@ -104,24 +107,56 @@ func parseLocalAddr(localAddr string) []string {
return strings.Split(localAddr, "|")
}

func generateConfig() (*core.Config, error) {
lport, err := net.PortFromString(*localPort)
if err != nil {
return nil, newError("invalid localPort:", *localPort).Base(err)
func applyUDPOptions(opts Args) error {
if c, b := opts.Get("udpMode"); b {
*udpMode = c
}
rport, err := strconv.ParseUint(*remotePort, 10, 32)
if err != nil {
return nil, newError("invalid remotePort:", *remotePort).Base(err)
if c, b := opts.Get("udpPath"); b {
*udpPath = c
}
outboundProxy := serial.ToTypedMessage(&freedom.Config{
DestinationOverride: &freedom.DestinationOverride{
Server: &protocol.ServerEndpoint{
Address: net.NewIPOrDomain(net.ParseAddress(*remoteAddr)),
Port: uint32(rport),
},
},
})
if c, b := opts.Get("udpTimeout"); b {
i, err := strconv.Atoi(c)
if err != nil {
return newError("invalid udpTimeout:", c).Base(err)
}
*udpTimeout = i
}
return validateUDPOptions()
}

func validateUDPOptions() error {
switch *udpMode {
case "", "quic", "websocket":
default:
return newError("unsupported udpMode:", *udpMode)
}
if *udpTimeout <= 0 {
return newError("invalid udpTimeout:", *udpTimeout)
}
if *udpMode == "websocket" {
switch *mode {
case "websocket", "quic":
default:
return newError("udpMode=websocket requires mode=websocket or mode=quic")
}
if *mode == "websocket" && !isValidWebSocketPath(*path) {
return newError("invalid websocket path:", *path)
}
if !isValidWebSocketPath(*udpPath) {
return newError("invalid udp websocket path:", *udpPath)
}
if *mode == "websocket" && *udpPath == *path {
return newError("udpPath must differ from path:", *udpPath)
}
}
return nil
}

func isValidWebSocketPath(p string) bool {
return strings.HasPrefix(p, "/") && !strings.ContainsAny(p, "?#")
}

func generateTCPStreamConfig() (internet.StreamConfig, bool, error) {
var transportSettings proto.Message
var connectionReuse bool
switch *mode {
Expand All @@ -141,7 +176,7 @@ func generateConfig() (*core.Config, error) {
}
*tlsEnabled = true
default:
return nil, newError("unsupported mode:", *mode)
return internet.StreamConfig{}, false, newError("unsupported mode:", *mode)
}

streamConfig := internet.StreamConfig{
Expand Down Expand Up @@ -170,31 +205,59 @@ func generateConfig() (*core.Config, error) {
*cert = fmt.Sprintf("%s/.acme.sh/%s/fullchain.cer", homeDir(), *host)
logWarn("No TLS cert specified, trying", *cert)
}
var err error
certificate.Certificate, err = readCertificate()
if err != nil {
return nil, newError("failed to read cert").Base(err)
return internet.StreamConfig{}, false, newError("failed to read cert").Base(err)
}
if *key == "" {
*key = fmt.Sprintf("%[1]s/.acme.sh/%[2]s/%[2]s.key", homeDir(), *host)
logWarn("No TLS key specified, trying", *key)
}
certificate.Key, err = filesystem.ReadFile(*key)
if err != nil {
return nil, newError("failed to read key file").Base(err)
return internet.StreamConfig{}, false, newError("failed to read key file").Base(err)
}
tlsConfig.Certificate = []*tls.Certificate{&certificate}
} else if *cert != "" || *certRaw != "" {
certificate := tls.Certificate{Usage: tls.Certificate_AUTHORITY_VERIFY}
var err error
certificate.Certificate, err = readCertificate()
if err != nil {
return nil, newError("failed to read cert").Base(err)
return internet.StreamConfig{}, false, newError("failed to read cert").Base(err)
}
tlsConfig.Certificate = []*tls.Certificate{&certificate}
}
streamConfig.SecurityType = serial.GetMessageType(&tlsConfig)
streamConfig.SecuritySettings = []*anypb.Any{serial.ToTypedMessage(&tlsConfig)}
}

return streamConfig, connectionReuse, nil
}

func generateConfig() (*core.Config, error) {
lport, err := net.PortFromString(*localPort)
if err != nil {
return nil, newError("invalid localPort:", *localPort).Base(err)
}
rport, err := strconv.ParseUint(*remotePort, 10, 32)
if err != nil {
return nil, newError("invalid remotePort:", *remotePort).Base(err)
}
outboundProxy := serial.ToTypedMessage(&freedom.Config{
DestinationOverride: &freedom.DestinationOverride{
Server: &protocol.ServerEndpoint{
Address: net.NewIPOrDomain(net.ParseAddress(*remoteAddr)),
Port: uint32(rport),
},
},
})

streamConfig, connectionReuse, err := generateTCPStreamConfig()
if err != nil {
return nil, err
}

apps := []*anypb.Any{
serial.ToTypedMessage(&dispatcher.Config{}),
serial.ToTypedMessage(&proxyman.InboundConfig{}),
Expand Down Expand Up @@ -258,6 +321,77 @@ func generateConfig() (*core.Config, error) {
}
}

type pluginServer struct {
tcp core.Server
udp *udpRelay
}

func (s *pluginServer) Start() error {
if err := s.tcp.Start(); err != nil {
return err
}
if err := s.udp.Start(); err != nil {
if closeErr := s.tcp.Close(); closeErr != nil {
logWarn(closeErr.Error())
}
return err
}
return nil
}

type combinedWebSocketServer struct {
tcp core.Server
udp *udpRelay
router *webSocketRouter
}

func (s *combinedWebSocketServer) Start() error {
if err := s.tcp.Start(); err != nil {
return err
}
if err := s.udp.Start(); err != nil {
if closeErr := s.tcp.Close(); closeErr != nil {
logWarn(closeErr.Error())
}
return err
}
if err := s.router.Start(); err != nil {
if closeErr := s.udp.Close(); closeErr != nil {
logWarn(closeErr.Error())
}
if closeErr := s.tcp.Close(); closeErr != nil {
logWarn(closeErr.Error())
}
return err
}
return nil
}

func (s *combinedWebSocketServer) Close() error {
var closeErr error
if err := s.router.Close(); err != nil {
closeErr = err
}
if err := s.udp.Close(); err != nil && closeErr == nil {
closeErr = err
}
if err := s.tcp.Close(); err != nil && closeErr == nil {
closeErr = err
}
return closeErr
}

func (s *pluginServer) Close() error {
var closeErr error
if err := s.udp.Close(); err != nil {
closeErr = err
}
if err := s.tcp.Close(); err != nil && closeErr == nil {
closeErr = err
}
return closeErr
}

func startV2Ray() (core.Server, error) {

opts, err := parseEnv()
Expand Down Expand Up @@ -342,9 +476,19 @@ func startV2Ray() (core.Server, error) {
}
}

if err := applyUDPOptions(opts); err != nil {
return nil, err
}

if *vpn {
registerControlFunc()
}
} else if err := validateUDPOptions(); err != nil {
return nil, err
}

if *server && *mode == "websocket" && *udpMode == "websocket" {
return startCombinedWebSocketServer()
}

config, err := generateConfig()
Expand All @@ -355,7 +499,11 @@ func startV2Ray() (core.Server, error) {
if err != nil {
return nil, newError("failed to create v2ray instance").Base(err)
}
return instance, nil
udpRelay, err := newUDPRelayFromOptions()
if err != nil {
return nil, err
}
return &pluginServer{tcp: instance, udp: udpRelay}, nil
}

func printCoreVersion() {
Expand Down
Loading