Skip to content

Commit 5656629

Browse files
authored
Merge pull request #40 from nhooyr/impl
Implement handshake
2 parents 5050331 + cfba735 commit 5656629

16 files changed

+1025
-125
lines changed

.github/CONTRIBUTING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Contributing Guidelines
2+
3+
Please split up changes into several small descriptive commits.
4+
5+
Please capitalize the first word in the commit message and ensure it is
6+
descriptive.
7+
8+
The commit message should use the verb tense + phrase that completes the blank in
9+
10+
> This change modifies websocket to ___________
11+
12+
Be sure to link to an existing issue if one exists. In general, try creating an issue
13+
before making a PR to get some discussion going and to make sure you do not spend time
14+
on a PR that may be rejected.
15+
16+
Run `test.sh` to test your changes. You only need docker and bash to run tests.

.github/fmt/entrypoint.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ gen() {
88
go list ./... > /dev/null
99
go mod tidy
1010

11-
go install golang.org/x/tools/cmd/stringer
1211
go generate ./...
1312
}
1413

README.md

Lines changed: 61 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -16,84 +16,81 @@ go get nhooyr.io/websocket@master
1616

1717
## Features
1818

19+
- HTTP/2 over WebSocket's support
1920
- Full support of the WebSocket protocol
21+
- Only depends on the stdlib
2022
- Simple to use because of the minimal API
2123
- Uses the context package for cancellation
2224
- Uses net/http's Client to do WebSocket dials
2325
- Compression of text frames larger than 1024 bytes by default
24-
- Highly optimized
25-
- API will transparently work with WebSockets over HTTP/2
26+
- Highly optimized where it matters
2627
- WASM support
2728

2829
## Example
2930

3031
### Server
3132

3233
```go
33-
func main() {
34-
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35-
c, err := websocket.Accept(w, r,
36-
websocket.AcceptSubprotocols("test"),
37-
)
38-
if err != nil {
39-
log.Printf("server handshake failed: %v", err)
40-
return
41-
}
42-
defer c.Close(websocket.StatusInternalError, "")
43-
44-
ctx, cancel := context.WithTimeout(r.Context(), time.Second*10)
45-
defer cancel()
46-
47-
v := map[string]interface{}{
48-
"my_field": "foo",
49-
}
50-
err = websocket.WriteJSON(ctx, c, v)
51-
if err != nil {
52-
log.Printf("failed to write json: %v", err)
53-
return
54-
}
55-
56-
log.Printf("wrote %v", v)
57-
58-
c.Close(websocket.StatusNormalClosure, "")
59-
})
60-
err := http.ListenAndServe("localhost:8080", fn)
34+
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35+
c, err := websocket.Accept(w, r,
36+
websocket.AcceptSubprotocols("test"),
37+
)
6138
if err != nil {
62-
log.Fatalf("failed to listen and serve: %v", err)
39+
log.Printf("server handshake failed: %v", err)
40+
return
6341
}
64-
}
65-
```
66-
67-
For a production quality example that shows off the low level API, see the echo example on the [godoc](https://godoc.org/nhooyr.io/websocket#Accept).
68-
69-
### Client
42+
defer c.Close(websocket.StatusInternalError, "")
7043

71-
```go
72-
func main() {
73-
ctx := context.Background()
74-
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
44+
ctx, cancel := context.WithTimeout(r.Context(), time.Second*10)
7545
defer cancel()
7646

77-
c, _, err := websocket.Dial(ctx, "ws://localhost:8080",
78-
websocket.DialSubprotocols("test"),
79-
)
80-
if err != nil {
81-
log.Fatalf("failed to ws dial: %v", err)
47+
v := map[string]interface{}{
48+
"my_field": "foo",
8249
}
83-
defer c.Close(websocket.StatusInternalError, "")
84-
85-
var v interface{}
86-
err = websocket.ReadJSON(ctx, c, v)
50+
err = websocket.WriteJSON(ctx, c, v)
8751
if err != nil {
88-
log.Fatalf("failed to read json: %v", err)
52+
log.Printf("failed to write json: %v", err)
53+
return
8954
}
9055

91-
log.Printf("received %v", v)
56+
log.Printf("wrote %v", v)
9257

9358
c.Close(websocket.StatusNormalClosure, "")
59+
})
60+
err := http.ListenAndServe("localhost:8080", fn)
61+
if err != nil {
62+
log.Fatalf("failed to listen and serve: %v", err)
9463
}
9564
```
9665

66+
For a production quality example that shows off the low level API, see the echo example on the [godoc](https://godoc.org/nhooyr.io/websocket#Accept).
67+
68+
### Client
69+
70+
```go
71+
ctx := context.Background()
72+
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
73+
defer cancel()
74+
75+
c, _, err := websocket.Dial(ctx, "ws://localhost:8080",
76+
websocket.DialSubprotocols("test"),
77+
)
78+
if err != nil {
79+
log.Fatalf("failed to ws dial: %v", err)
80+
}
81+
defer c.Close(websocket.StatusInternalError, "")
82+
83+
var v interface{}
84+
err = websocket.ReadJSON(ctx, c, v)
85+
if err != nil {
86+
log.Fatalf("failed to read json: %v", err)
87+
}
88+
89+
log.Printf("received %v", v)
90+
91+
c.Close(websocket.StatusNormalClosure, "")
92+
```
93+
9794
See [example_test.go](example_test.go) for more examples.
9895

9996
## Design considerations
@@ -105,24 +102,28 @@ See [example_test.go](example_test.go) for more examples.
105102
- net.Conn is never exposed as WebSocket's over HTTP/2 will not have a net.Conn.
106103
- Functional options make the API very clean and easy to extend
107104
- Compression is very useful for JSON payloads
108-
- Protobuf and JSON helpers make code terse
105+
- JSON helpers make code terse
109106
- Using net/http's Client for dialing means we do not have to reinvent dialing hooks
110107
and configurations. Just pass in a custom net/http client if you want custom dialing.
111108

112109
## Comparison
113110

111+
While I believe nhooyr/websocket has a better API than existing libraries,
112+
both gorilla/websocket and gobwas/ws were extremely useful in implementing the
113+
WebSocket protocol correctly so big thanks to the authors of both.
114+
114115
### gorilla/websocket
115116

116117
https://github.com/gorilla/websocket
117118

118-
This package is the community standard but it is very old and over timennn
119+
This package is the community standard but it is very old and over time
119120
has accumulated cruft. There are many ways to do the same thing and the API
120-
overall is just not very clear. Just compare the godoc of
121+
is not clear. Just compare the godoc of
121122
[nhooyr/websocket](godoc.org/github.com/nhooyr/websocket) side by side with
122123
[gorilla/websocket](godoc.org/github.com/gorilla/websocket).
123124

124125
The API for nhooyr/websocket has been designed such that there is only one way to do things
125-
and with HTTP/2 in mind which makes using it correctly and safely much easier.
126+
which makes using it correctly and safely much easier.
126127

127128
### x/net/websocket
128129

@@ -137,12 +138,12 @@ See https://github.com/golang/go/issues/18152
137138
https://github.com/gobwas/ws
138139

139140
This library has an extremely flexible API but that comes at the cost of usability
140-
and clarity. Its just not clear how to do things in a safe manner.
141+
and clarity. Its not clear what the best way to do anything is.
141142

142-
This library is fantastic in terms of performance though. The author put in significant
143-
effort to ensure its speed and I have tried to apply as many of its teachings as
143+
This library is fantastic in terms of performance. The author put in significant
144+
effort to ensure its speed and I have applied as many of its optimizations as
144145
I could into nhooyr/websocket.
145146

146147
If you want a library that gives you absolute control over everything, this is the library,
147148
but for most users, the API provided by nhooyr/websocket will definitely fit better as it will
148-
be just as performant but much easier to use.
149+
be just as performant but much easier to use correctly.

accept.go

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,48 @@
11
package websocket
22

33
import (
4-
"fmt"
4+
"crypto/sha1"
5+
"encoding/base64"
56
"net/http"
7+
"net/url"
8+
"strings"
9+
10+
"golang.org/x/net/http/httpguts"
11+
"golang.org/x/xerrors"
612
)
713

814
// AcceptOption is an option that can be passed to Accept.
15+
// The implementations of this interface are printable.
916
type AcceptOption interface {
1017
acceptOption()
11-
fmt.Stringer
1218
}
1319

20+
type acceptSubprotocols []string
21+
22+
func (o acceptSubprotocols) acceptOption() {}
23+
1424
// AcceptSubprotocols list the subprotocols that Accept will negotiate with a client.
1525
// The first protocol that a client supports will be negotiated.
16-
// Pass "" as a subprotocol if you would like to allow the default protocol.
26+
// Pass "" as a subprotocol if you would like to allow the default protocol along with
27+
// specific subprotocols.
1728
func AcceptSubprotocols(subprotocols ...string) AcceptOption {
18-
panic("TODO")
29+
return acceptSubprotocols(subprotocols)
1930
}
2031

32+
type acceptOrigins []string
33+
34+
func (o acceptOrigins) acceptOption() {}
35+
2136
// AcceptOrigins lists the origins that Accept will accept.
2237
// Accept will always accept r.Host as the origin so you do not need to
2338
// specify that with this option.
2439
//
2540
// Use this option with caution to avoid exposing your WebSocket
2641
// server to a CSRF attack.
2742
// See https://stackoverflow.com/a/37837709/4283659
28-
// You can use a * to specify wildcards.
43+
// You can use a * for wildcards.
2944
func AcceptOrigins(origins ...string) AcceptOption {
30-
panic("TODO")
45+
return AcceptOrigins(origins...)
3146
}
3247

3348
// Accept accepts a WebSocket handshake from a client and upgrades the
@@ -36,5 +51,126 @@ func AcceptOrigins(origins ...string) AcceptOption {
3651
// InsecureAcceptOrigin is passed.
3752
// Accept uses w to write the handshake response so the timeouts on the http.Server apply.
3853
func Accept(w http.ResponseWriter, r *http.Request, opts ...AcceptOption) (*Conn, error) {
39-
panic("TODO")
54+
var subprotocols []string
55+
origins := []string{r.Host}
56+
for _, opt := range opts {
57+
switch opt := opt.(type) {
58+
case acceptOrigins:
59+
origins = []string(opt)
60+
case acceptSubprotocols:
61+
subprotocols = []string(opt)
62+
}
63+
}
64+
65+
if !httpguts.HeaderValuesContainsToken(r.Header["Connection"], "Upgrade") {
66+
err := xerrors.Errorf("websocket: protocol violation: Connection header does not contain Upgrade: %q", r.Header.Get("Connection"))
67+
http.Error(w, err.Error(), http.StatusBadRequest)
68+
return nil, err
69+
}
70+
71+
if !httpguts.HeaderValuesContainsToken(r.Header["Upgrade"], "websocket") {
72+
err := xerrors.Errorf("websocket: protocol violation: Upgrade header does not contain websocket: %q", r.Header.Get("Upgrade"))
73+
http.Error(w, err.Error(), http.StatusBadRequest)
74+
return nil, err
75+
}
76+
77+
if r.Method != "GET" {
78+
err := xerrors.Errorf("websocket: protocol violation: handshake request method is not GET: %q", r.Method)
79+
http.Error(w, err.Error(), http.StatusBadRequest)
80+
return nil, err
81+
}
82+
83+
if r.Header.Get("Sec-WebSocket-Version") != "13" {
84+
err := xerrors.Errorf("websocket: unsupported protocol version: %q", r.Header.Get("Sec-WebSocket-Version"))
85+
http.Error(w, err.Error(), http.StatusBadRequest)
86+
return nil, err
87+
}
88+
89+
if r.Header.Get("Sec-WebSocket-Key") == "" {
90+
err := xerrors.New("websocket: protocol violation: missing Sec-WebSocket-Key")
91+
http.Error(w, err.Error(), http.StatusBadRequest)
92+
return nil, err
93+
}
94+
95+
origins = append(origins, r.Host)
96+
97+
err := authenticateOrigin(r, origins)
98+
if err != nil {
99+
http.Error(w, err.Error(), http.StatusForbidden)
100+
return nil, err
101+
}
102+
103+
hj, ok := w.(http.Hijacker)
104+
if !ok {
105+
err = xerrors.New("websocket: response writer does not implement http.Hijacker")
106+
http.Error(w, err.Error(), http.StatusInternalServerError)
107+
return nil, err
108+
}
109+
110+
w.Header().Set("Upgrade", "websocket")
111+
w.Header().Set("Connection", "Upgrade")
112+
113+
handleKey(w, r)
114+
115+
selectSubprotocol(w, r, subprotocols)
116+
117+
w.WriteHeader(http.StatusSwitchingProtocols)
118+
119+
netConn, brw, err := hj.Hijack()
120+
if err != nil {
121+
err = xerrors.Errorf("websocket: failed to hijack connection: %v", err)
122+
http.Error(w, err.Error(), http.StatusInternalServerError)
123+
return nil, err
124+
}
125+
126+
c := &Conn{
127+
subprotocol: w.Header().Get("Sec-WebSocket-Protocol"),
128+
br: brw.Reader,
129+
bw: brw.Writer,
130+
closer: netConn,
131+
}
132+
c.init()
133+
134+
return c, nil
135+
}
136+
137+
func selectSubprotocol(w http.ResponseWriter, r *http.Request, subprotocols []string) {
138+
clientSubprotocols := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), "\n")
139+
for _, sp := range subprotocols {
140+
for _, cp := range clientSubprotocols {
141+
if sp == strings.TrimSpace(cp) {
142+
w.Header().Set("Sec-WebSocket-Protocol", sp)
143+
return
144+
}
145+
}
146+
}
147+
}
148+
149+
var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
150+
151+
func handleKey(w http.ResponseWriter, r *http.Request) {
152+
key := r.Header.Get("Sec-WebSocket-Key")
153+
h := sha1.New()
154+
h.Write([]byte(key))
155+
h.Write(keyGUID)
156+
157+
responseKey := base64.StdEncoding.EncodeToString(h.Sum(nil))
158+
w.Header().Set("Sec-WebSocket-Accept", responseKey)
159+
}
160+
161+
func authenticateOrigin(r *http.Request, origins []string) error {
162+
origin := r.Header.Get("Origin")
163+
if origin == "" {
164+
return nil
165+
}
166+
u, err := url.Parse(origin)
167+
if err != nil {
168+
return xerrors.Errorf("failed to parse Origin header %q: %v", origin, err)
169+
}
170+
for _, o := range origins {
171+
if u.Host == o {
172+
return nil
173+
}
174+
}
175+
return xerrors.New("request origin is not authorized")
40176
}

0 commit comments

Comments
 (0)