Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add websocket tunneling support #131

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
109 changes: 109 additions & 0 deletions client/websocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package client

import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"

onet "github.com/Jigsaw-Code/outline-ss-server/net"
ss "github.com/Jigsaw-Code/outline-ss-server/shadowsocks"
"github.com/Jigsaw-Code/outline-ss-server/websocket"
"github.com/shadowsocks/go-shadowsocks2/socks"
)

type WebsocketOptions struct {
// Addr is the address of the websocket server. It can either an IP address or a domain name.
Addr string
// Port is the destination port of the websocket connection.
Port int
// Host is the hostname to use in the Host header of HTTP request made to the websocket server.
// If empty, the header will be set to `Addr` if it is a domain name.
Host string
// SNI is the hostname to use in the server name extension of the TLS handshake. If empty, it will be set to `Host`.
SNI string
// Path is the HTTP path to use when connecting to the websocket server.
Path string
// Password is the password to use for the shadowsocks connection tunnelled inside the websocket connection.
Password string
// Ciphter is the cipher to use for the shadowsocks connection tunnelled inside the websocket connection.
Cipher string
}

// NewWebsocketClient creates a client that routes connections to a Shadowsocks proxy
// tunneled inside a websocket connection.
func NewWebsocketClient(opts WebsocketOptions) (Client, error) {
proxy := opts.Addr
if proxy == "" {
proxy = opts.Host
}
if proxy == "" {
return nil, fmt.Errorf("neither Addr or Host are defined")
}

ss, err := NewClient(proxy, opts.Port, opts.Password, opts.Cipher)
if err != nil {
return nil, err
}

if strings.HasPrefix(opts.Path, "/") {
opts.Path = opts.Path[1:]
}

addrIP := net.ParseIP(opts.Addr)
if opts.Host == "" && addrIP == nil {
opts.Host = opts.Addr
}

if opts.SNI == "" {
opts.SNI = opts.Host
}

return &wsClient{
ssClient: ss.(*ssClient),
opts: opts,
}, nil
}

type wsClient struct {
*ssClient
opts WebsocketOptions
}

func (c *wsClient) DialTCP(laddr *net.TCPAddr, raddr string) (onet.DuplexConn, error) {
socksTargetAddr := socks.ParseAddr(raddr)
if socksTargetAddr == nil {
return nil, errors.New("Failed to parse target address")
}

h := make(http.Header)
if c.opts.Host != "" {
h.Set("Host", c.opts.Host)
}
d := websocket.Dialer{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
ServerName: c.opts.SNI,
},
HandshakeTimeout: websocket.DefaultHandshakeTimeout,
}
proxyConn, err := d.Dial(fmt.Sprintf("wss://%s:%d/%s", c.proxyIP, c.opts.Port, c.opts.Path), h)
if err != nil {
return nil, err
}

ssw := ss.NewShadowsocksWriter(proxyConn, c.cipher)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Websocket code shouldn't know about Shadowsocks.

Instead, make the Shadowsocks client code take a server Dialer.
Then the server dialer could be a direct connection, or a Websocket connection.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that approach, we don't need to worry about the wiring here. outline-go-tun2socks will have the code that takes a config and translates that to object wiring.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will need to check how much bigger outline-go-tun2socks will be with this change, and how it will affect memory on iOS.

_, err = ssw.LazyWrite(socksTargetAddr)
if err != nil {
proxyConn.Close()
return nil, errors.New("Failed to write target address")
}
time.AfterFunc(helloWait, func() {
ssw.Flush()
})
ssr := ss.NewShadowsocksReader(proxyConn, c.cipher)
return onet.WrapConn(proxyConn, ssr, ssw), nil
}
151 changes: 151 additions & 0 deletions client/websocket_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package client

import (
"crypto/tls"
"net"
"net/http"
"testing"
"time"

onet "github.com/Jigsaw-Code/outline-ss-server/net"
ss "github.com/Jigsaw-Code/outline-ss-server/shadowsocks"
"github.com/Jigsaw-Code/outline-ss-server/websocket"
)

const (
testWSPath = "/test"
)

func TestWebsocketClient(t *testing.T) {
testCases := []struct {
name string
opts WebsocketOptions
wantHost string
wantSNI string
}{
{
name: "with_ip_host",
opts: WebsocketOptions{Addr: "127.0.0.1", Host: "example.com"},
wantHost: "example.com",
wantSNI: "example.com",
},
{
name: "with_ip_host_sni",
opts: WebsocketOptions{Addr: "127.0.0.1", Host: "example.com", SNI: "sni.com"},
wantHost: "example.com",
wantSNI: "sni.com",
},
{
name: "with_domain",
opts: WebsocketOptions{Addr: "localhost"},
wantHost: "localhost",
wantSNI: "localhost",
},
{
name: "with_domain_host",
opts: WebsocketOptions{Addr: "localhost", Host: "example.com"},
wantHost: "example.com",
wantSNI: "example.com",
},
{
name: "with_domain_host_sni",
opts: WebsocketOptions{Addr: "localhost", Host: "example.com", SNI: "sni.com"},
wantHost: "example.com",
wantSNI: "sni.com",
},
}

proxy, hostCh, sniCh := startWebsocketShadowsocksEchoProxy(t)
defer close(hostCh)
defer close(sniCh)
defer proxy.Close()
_, proxyPort, err := splitHostPortNumber(proxy.Addr().String())
if err != nil {
t.Fatalf("Failed to parse proxy address: %v", err)
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.opts.Password = testPassword
tc.opts.Cipher = ss.TestCipher
tc.opts.Port = proxyPort
tc.opts.Path = testWSPath

d, err := NewWebsocketClient(tc.opts)
if err != nil {
t.Fatalf("Failed to create WebsocketClient: %v", err)
}
conn, err := d.DialTCP(nil, testTargetAddr)
if err != nil {
t.Fatalf("WebsocketClient.DialTCP failed: %v", err)
}

select {
case sni := <-sniCh:
if sni != tc.wantSNI {
t.Fatalf("Wrong server name in TLS handshake server. got='%v' want='%v'", sni, tc.wantSNI)
}
case <-time.After(50 * time.Millisecond):
t.Fatal("TLS connection state not recevied")
}
select {
case host := <-hostCh:
if host != tc.wantHost {
t.Fatalf("Wrong host header. got='%v' want='%v'", host, tc.wantHost)
}
case <-time.After(50 * time.Millisecond):
t.Fatal("HTTP request not recevied")
}

conn.SetReadDeadline(time.Now().Add(time.Second * 5))
expectEchoPayload(conn, ss.MakeTestPayload(1024), make([]byte, 1024), t)
conn.Close()
})
}
}

func startWebsocketShadowsocksEchoProxy(t *testing.T) (net.Listener, chan string, chan string) {
proxy, _ := startShadowsocksTCPEchoProxy(testTargetAddr, t)

hostCh := make(chan string, 1)
sniCh := make(chan string, 1)

handler := func(w http.ResponseWriter, r *http.Request) {
u := websocket.Upgrader{HandshakeTimeout: 50 * time.Millisecond}
c, err := u.Upgrade(w, r, nil)
defer c.Close()

hostCh <- r.Host

if r.URL.Path != testWSPath {
t.Logf("Wrong Path received on request. got='%v' want='%v'", testWSPath, r.URL.Path)
return
}

targetC, err := net.Dial("tcp", proxy.Addr().String())
if err != nil {
t.Logf("Failed to connect to TCP echo server: %v", err)
return
}

onet.Relay(c, targetC.(*net.TCPConn))
}

l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
if err != nil {
t.Fatalf("Starting websocket listener failed: %v", err)
}

go func() {
srv := &http.Server{Handler: http.HandlerFunc(handler)}
srv.TLSConfig = &tls.Config{
VerifyConnection: func(cs tls.ConnectionState) error {
sniCh <- cs.ServerName
return nil
},
}
srv.ServeTLS(l, websocket.TestCert, websocket.TestKey)
}()

return l, hostCh, sniCh
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module github.com/Jigsaw-Code/outline-ss-server

require (
github.com/goreleaser/goreleaser v1.12.3
github.com/gorilla/websocket v1.5.0
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/oschwald/geoip2-golang v1.8.0
github.com/prometheus/client_golang v1.13.0
Expand Down Expand Up @@ -104,7 +105,6 @@ require (
github.com/goreleaser/chglog v0.2.2 // indirect
github.com/goreleaser/fileglob v1.3.0 // indirect
github.com/goreleaser/nfpm/v2 v2.20.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
github.com/hashicorp/go-version v1.2.1 // indirect
Expand Down
30 changes: 22 additions & 8 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,19 +212,25 @@ func readConfig(filename string) (*Config, error) {

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert the service.

func main() {
var flags struct {
ConfigFile string
MetricsAddr string
IPCountryDB string
natTimeout time.Duration
replayHistory int
Verbose bool
Version bool
ConfigFile string
MetricsAddr string
IPCountryDB string
natTimeout time.Duration
replayHistory int
Verbose bool
Version bool
WebsocketServer bool
TLSCert string
TLSKey string
}
flag.StringVar(&flags.ConfigFile, "config", "", "Configuration filename")
flag.StringVar(&flags.MetricsAddr, "metrics", "", "Address for the Prometheus metrics")
flag.StringVar(&flags.IPCountryDB, "ip_country_db", "", "Path to the ip-to-country mmdb file")
flag.DurationVar(&flags.natTimeout, "udptimeout", defaultNatTimeout, "UDP tunnel timeout")
flag.IntVar(&flags.replayHistory, "replay_history", 0, "Replay buffer size (# of handshakes)")
flag.BoolVar(&flags.WebsocketServer, "websocket", false, "Enables the websocket serve")
flag.StringVar(&flags.TLSCert, "tls-cert", "ssl.crt", "Path to tls certificate to use for the websocket server")
flag.StringVar(&flags.TLSKey, "tls-key", "ssl.key", "Path to tls key to use for the websocket server")
flag.BoolVar(&flags.Verbose, "verbose", false, "Enables verbose logging output")
flag.BoolVar(&flags.Version, "version", false, "The version of the server")

Expand Down Expand Up @@ -266,11 +272,19 @@ func main() {
}
m := metrics.NewPrometheusShadowsocksMetrics(ipCountryDB, prometheus.DefaultRegisterer)
m.SetBuildInfo(version)
_, err = RunSSServer(flags.ConfigFile, flags.natTimeout, m, flags.replayHistory)
ssServer, err := RunSSServer(flags.ConfigFile, flags.natTimeout, m, flags.replayHistory)
if err != nil {
logger.Fatal(err)
}

if flags.WebsocketServer {
if flags.TLSCert == "" || flags.TLSKey == "" {
log.Fatalln("TLS cert and key not specified")
flag.Usage()
}
RunWebsocketServer(ssServer, 443, flags.TLSCert, flags.TLSKey)
}

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
Expand Down
10 changes: 7 additions & 3 deletions service/tcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ type TCPService interface {
Stop() error
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert

// GracefulStop calls Stop(), and then blocks until all resources have been cleaned up.
GracefulStop() error
// HandleConnection takes a shadowsocks client connection and starts a relay to the destination address.
HandleConnection(listenerPort int, clientTCPConn onet.DuplexConn)
}

func (s *tcpService) SetTargetIPValidator(targetIPValidator onet.TargetIPValidator) {
Expand Down Expand Up @@ -207,12 +209,12 @@ func (s *tcpService) Serve(listener *net.TCPListener) error {
logger.Errorf("Panic in TCP handler: %v", r)
}
}()
s.handleConnection(listener.Addr().(*net.TCPAddr).Port, clientTCPConn)
s.HandleConnection(listener.Addr().(*net.TCPAddr).Port, clientTCPConn)
}()
}
}

func (s *tcpService) handleConnection(listenerPort int, clientTCPConn *net.TCPConn) {
func (s *tcpService) HandleConnection(listenerPort int, clientTCPConn onet.DuplexConn) {
clientLocation, err := s.m.GetLocation(clientTCPConn.RemoteAddr())
if err != nil {
logger.Warningf("Failed location lookup: %v", err)
Expand All @@ -221,7 +223,9 @@ func (s *tcpService) handleConnection(listenerPort int, clientTCPConn *net.TCPCo
s.m.AddOpenTCPConnection(clientLocation)

connStart := time.Now()
clientTCPConn.SetKeepAlive(true)
if tcp, ok := clientTCPConn.(*net.TCPConn); ok {
tcp.SetKeepAlive(true)
}
// Set a deadline to receive the address to the target.
clientTCPConn.SetReadDeadline(connStart.Add(s.readTimeout))
var proxyMetrics metrics.ProxyMetrics
Expand Down
27 changes: 27 additions & 0 deletions ssl.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this file? Just a test example? If so, give a clearer name. Also, move it to the websocket folder.

MIIElDCCAnwCCQDlCwAerg6zUzANBgkqhkiG9w0BAQsFADAMMQowCAYDVQQDDAEq
MB4XDTIyMTAzMTAyMDAxMloXDTI1MDcyNzAyMDAxMlowDDEKMAgGA1UEAwwBKjCC
AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAOV0rN2wcoWuwHtvYyjKO3NL
WQSCB1u/bDrJYXEFCjCaQFuEVWjW44vHwBnTanWPt2TcEN8ZZ7Nd3fku8ZN7I7i9
3CPRUDatBD/IE6s90JY0dR8i4ZavOorTD8D8Xg5ioh9KdyEnEmgZpDjzENBEJQZa
jUMytEwM5BJYO1QY6TInRbC0XCigouPBdhHR88kc5e/+tAqY7UC5x48CkLIcDAxQ
IzPBdGWheSPjLYLn6XM0QkatZK6/7q40f03BMt29AVvwN3XgU9xBwr4Iij5nC3AV
RfwBbySx+d+5rIxfsm6L5xOP0Zm5IueWNyCa8JDYrxdOf35bcm9VdPvay0IDp4Dr
gTqWRvLWzBCmsXKdOIDo3O0W3UcSfzjyiue+VKNJFOFEpS02RfqaQZ4k+YpS1QIz
3CyzDb7GVq4gNTO7P4hcMXvWxJPqoA6QYAeboHMQr695ucdjF4hebbVMNajXndhf
BPmvAGKZivUn/PZND1vTj331mqWUMQTJOyvUVv+ZpEobhr3GQvScbFw5zLOaR5ud
LAxgmtcyspCC4MQhu7bNPtnP5jhBXmdiNf7bbUQFgnaGDrNKELMIUeY3/TLAs6cv
U+ZbrbofL3eGMZtCeH/7izZxX2cS1OxcHNesqOU7+ZLznbu7AXGrDo6lH/4VbC44
YaKbn4oxfplUpQcmWWvJAgMBAAEwDQYJKoZIhvcNAQELBQADggIBABdLOenVrN4/
D2NZJiD027LVcxnWTEjCCMgoaZ1eeQdaHlpQueL1hpZDTnFCZEsbnp77GFqXhNt4
lnUgF4n8JmFoqR39MCu76k7VlndLTG2aMPOrc6zfe2JUeaC4Q6/BwothMu1xuz6P
kbWResrXVIbdVH+NPqN3SFzye8MQzBkNcgiNRY9syzaPXUD3OfOYT6xnUU6orqsX
LWnCuakRK9YlaK9X6BdPen12wyAbKg+M4eUMTnC/VTRFjHz8H8CnYjk/bzoxUQZG
QV9XK+2dw0A0MLNXiWtoUmemS/ty+tSMnvEMdfXyskJcP3qVbywp4G4E1cUiHw9z
e+0xYZQnaX0I6ztZWliK8ELHEucS+M20VODVUEryKl/zpqq7Rpx16T4VRrnqtYj2
MqGLWhjUvDRFsuDaVCTbP8oZS3CrR6p0pfHqatYXcRY8E1YeMaGBMz5DKpgEhbjo
2x6md9E7LWVRa4FQu0N/V5xlTUSGUzzgLAXIXhLiKT0B+FutG914zN8z6AOQ7DMY
IhhosVG535/mL8AnPAoDorOSz5Vk1OxPWYCLFiUZK8rqcP3+z0xe4S/bsoGaz7Oc
J/LzGxaanEKi53lS3zg/N4QYWmWe++l59fZVPa0HiAPsMCoHnOGPuhnULzGeGuPJ
+bLSo9WYo8HwVFk6RHeKkJcDoYRQIyO+
-----END CERTIFICATE-----
Loading