Skip to content

Commit 1e61084

Browse files
authored
Add compatibility with only websocket-capable clients (#2132)
* handle control protocol through websocket The necessary behaviour is already in place, but the wasm build only issued GETs, and the handler was not invoked. * get DERP-over-websocket working for wasm clients * Prepare for testing builtin websocket-over-DERP Still needs some way to assert that clients are connected through websockets, rather than the TCP hijacking version of DERP. * integration tests: properly differentiate between DERP transports * do not touch unrelated code * linter fixes * integration testing: unexport common implementation of derp server scenario * fixup! integration testing: unexport common implementation of derp server scenario * dockertestutil/logs: remove unhelpful comment * update changelog --------- Co-authored-by: Csaba Sarkadi <[email protected]>
1 parent 10a72e8 commit 1e61084

File tree

14 files changed

+277
-34
lines changed

14 files changed

+277
-34
lines changed

.github/workflows/test-integration.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ jobs:
4141
- TestResolveMagicDNS
4242
- TestValidateResolvConf
4343
- TestDERPServerScenario
44+
- TestDERPServerWebsocketScenario
4445
- TestPingAllByIP
4546
- TestPingAllByIPPublicDERP
4647
- TestAuthKeyLogoutAndRelogin

CHANGELOG.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# CHANGELOG
22

3-
## 0.23.0 (2023-09-18)
3+
## Next
4+
5+
- Improved compatibilty of built-in DERP server with clients connecting over WebSocket.
6+
7+
## 0.23.0 (2024-09-18)
48

59
This release was intended to be mainly a code reorganisation and refactoring, significantly improving the maintainability of the codebase. This should allow us to improve further and make it easier for the maintainers to keep on top of the project.
610
However, as you all have noticed, it turned out to become a much larger, much longer release cycle than anticipated. It has ended up to be a release with a lot of rewrites and changes to the code base and functionality of Headscale, cleaning up a lot of technical debt and introducing a lot of improvements. This does come with some breaking changes,

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.23.0
44

55
require (
66
github.com/AlecAivazis/survey/v2 v2.3.7
7+
github.com/coder/websocket v1.8.12
78
github.com/coreos/go-oidc/v3 v3.11.0
89
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
910
github.com/deckarep/golang-set/v2 v2.6.0
@@ -79,7 +80,6 @@ require (
7980
github.com/bits-and-blooms/bitset v1.13.0 // indirect
8081
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
8182
github.com/cespare/xxhash/v2 v2.3.0 // indirect
82-
github.com/coder/websocket v1.8.12 // indirect
8383
github.com/containerd/console v1.0.4 // indirect
8484
github.com/containerd/continuity v0.4.3 // indirect
8585
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect

hscontrol/app.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
425425
router := mux.NewRouter()
426426
router.Use(prometheusMiddleware)
427427

428-
router.HandleFunc(ts2021UpgradePath, h.NoiseUpgradeHandler).Methods(http.MethodPost)
428+
router.HandleFunc(ts2021UpgradePath, h.NoiseUpgradeHandler).Methods(http.MethodPost, http.MethodGet)
429429

430430
router.HandleFunc("/health", h.HealthHandler).Methods(http.MethodGet)
431431
router.HandleFunc("/key", h.KeyHandler).Methods(http.MethodGet)

hscontrol/derp/server/derp_server.go

+53
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package server
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -12,11 +13,13 @@ import (
1213
"strings"
1314
"time"
1415

16+
"github.com/coder/websocket"
1517
"github.com/juanfont/headscale/hscontrol/types"
1618
"github.com/juanfont/headscale/hscontrol/util"
1719
"github.com/rs/zerolog/log"
1820
"tailscale.com/derp"
1921
"tailscale.com/net/stun"
22+
"tailscale.com/net/wsconn"
2023
"tailscale.com/tailcfg"
2124
"tailscale.com/types/key"
2225
)
@@ -132,6 +135,56 @@ func (d *DERPServer) DERPHandler(
132135
return
133136
}
134137

138+
if strings.Contains(req.Header.Get("Sec-Websocket-Protocol"), "derp") {
139+
d.serveWebsocket(writer, req)
140+
} else {
141+
d.servePlain(writer, req)
142+
}
143+
}
144+
145+
func (d *DERPServer) serveWebsocket(writer http.ResponseWriter, req *http.Request) {
146+
websocketConn, err := websocket.Accept(writer, req, &websocket.AcceptOptions{
147+
Subprotocols: []string{"derp"},
148+
OriginPatterns: []string{"*"},
149+
// Disable compression because DERP transmits WireGuard messages that
150+
// are not compressible.
151+
// Additionally, Safari has a broken implementation of compression
152+
// (see https://github.com/nhooyr/websocket/issues/218) that makes
153+
// enabling it actively harmful.
154+
CompressionMode: websocket.CompressionDisabled,
155+
})
156+
if err != nil {
157+
log.Error().
158+
Caller().
159+
Err(err).
160+
Msg("Failed to upgrade websocket request")
161+
162+
writer.Header().Set("Content-Type", "text/plain")
163+
writer.WriteHeader(http.StatusInternalServerError)
164+
165+
_, err = writer.Write([]byte("Failed to upgrade websocket request"))
166+
if err != nil {
167+
log.Error().
168+
Caller().
169+
Err(err).
170+
Msg("Failed to write response")
171+
}
172+
173+
return
174+
}
175+
defer websocketConn.Close(websocket.StatusInternalError, "closing")
176+
if websocketConn.Subprotocol() != "derp" {
177+
websocketConn.Close(websocket.StatusPolicyViolation, "client must speak the derp subprotocol")
178+
179+
return
180+
}
181+
182+
wc := wsconn.NetConn(req.Context(), websocketConn, websocket.MessageBinary, req.RemoteAddr)
183+
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
184+
d.tailscaleDERP.Accept(req.Context(), wc, brw, req.RemoteAddr)
185+
}
186+
187+
func (d *DERPServer) servePlain(writer http.ResponseWriter, req *http.Request) {
135188
fastStart := req.Header.Get(fastStartHeader) == "1"
136189

137190
hijacker, ok := writer.(http.Hijacker)

hscontrol/types/users.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type User struct {
1919
Name string `gorm:"unique"`
2020
}
2121

22-
// TODO(kradalby): See if we can fill in Gravatar here
22+
// TODO(kradalby): See if we can fill in Gravatar here.
2323
func (u *User) profilePicURL() string {
2424
return ""
2525
}

hscontrol/util/net.go

-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ func GrpcSocketDialer(ctx context.Context, addr string) (net.Conn, error) {
1313
return d.DialContext(ctx, "unix", addr)
1414
}
1515

16-
1716
// TODO(kradalby): Remove after go 1.24, will be in stdlib.
1817
// Compare returns an integer comparing two prefixes.
1918
// The result will be 0 if p == p2, -1 if p < p2, and +1 if p > p2.

integration/dns_test.go

-1
Original file line numberDiff line numberDiff line change
@@ -242,5 +242,4 @@ func TestValidateResolvConf(t *testing.T) {
242242
}
243243
})
244244
}
245-
246245
}

integration/dockertestutil/logs.go

+22-14
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dockertestutil
33
import (
44
"bytes"
55
"context"
6+
"io"
67
"log"
78
"os"
89
"path"
@@ -13,25 +14,18 @@ import (
1314

1415
const filePerm = 0o644
1516

16-
func SaveLog(
17+
func WriteLog(
1718
pool *dockertest.Pool,
1819
resource *dockertest.Resource,
19-
basePath string,
20-
) (string, string, error) {
21-
err := os.MkdirAll(basePath, os.ModePerm)
22-
if err != nil {
23-
return "", "", err
24-
}
25-
26-
var stdout bytes.Buffer
27-
var stderr bytes.Buffer
28-
29-
err = pool.Client.Logs(
20+
stdout io.Writer,
21+
stderr io.Writer,
22+
) error {
23+
return pool.Client.Logs(
3024
docker.LogsOptions{
3125
Context: context.TODO(),
3226
Container: resource.Container.ID,
33-
OutputStream: &stdout,
34-
ErrorStream: &stderr,
27+
OutputStream: stdout,
28+
ErrorStream: stderr,
3529
Tail: "all",
3630
RawTerminal: false,
3731
Stdout: true,
@@ -40,6 +34,20 @@ func SaveLog(
4034
Timestamps: false,
4135
},
4236
)
37+
}
38+
39+
func SaveLog(
40+
pool *dockertest.Pool,
41+
resource *dockertest.Resource,
42+
basePath string,
43+
) (string, string, error) {
44+
err := os.MkdirAll(basePath, os.ModePerm)
45+
if err != nil {
46+
return "", "", err
47+
}
48+
49+
var stdout, stderr bytes.Buffer
50+
err = WriteLog(pool, resource, &stdout, &stderr)
4351
if err != nil {
4452
return "", "", err
4553
}

integration/embedded_derp_test.go

+105-13
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,77 @@ import (
1515
"github.com/ory/dockertest/v3"
1616
)
1717

18+
type ClientsSpec struct {
19+
Plain int
20+
WebsocketDERP int
21+
}
22+
1823
type EmbeddedDERPServerScenario struct {
1924
*Scenario
2025

2126
tsicNetworks map[string]*dockertest.Network
2227
}
2328

2429
func TestDERPServerScenario(t *testing.T) {
30+
spec := map[string]ClientsSpec{
31+
"user1": {
32+
Plain: len(MustTestVersions),
33+
WebsocketDERP: 0,
34+
},
35+
}
36+
37+
derpServerScenario(t, spec, func(scenario *EmbeddedDERPServerScenario) {
38+
allClients, err := scenario.ListTailscaleClients()
39+
assertNoErrListClients(t, err)
40+
t.Logf("checking %d clients for websocket connections", len(allClients))
41+
42+
for _, client := range allClients {
43+
if didClientUseWebsocketForDERP(t, client) {
44+
t.Logf(
45+
"client %q used websocket a connection, but was not expected to",
46+
client.Hostname(),
47+
)
48+
t.Fail()
49+
}
50+
}
51+
})
52+
}
53+
54+
func TestDERPServerWebsocketScenario(t *testing.T) {
55+
spec := map[string]ClientsSpec{
56+
"user1": {
57+
Plain: 0,
58+
WebsocketDERP: len(MustTestVersions),
59+
},
60+
}
61+
62+
derpServerScenario(t, spec, func(scenario *EmbeddedDERPServerScenario) {
63+
allClients, err := scenario.ListTailscaleClients()
64+
assertNoErrListClients(t, err)
65+
t.Logf("checking %d clients for websocket connections", len(allClients))
66+
67+
for _, client := range allClients {
68+
if !didClientUseWebsocketForDERP(t, client) {
69+
t.Logf(
70+
"client %q does not seem to have used a websocket connection, even though it was expected to do so",
71+
client.Hostname(),
72+
)
73+
t.Fail()
74+
}
75+
}
76+
})
77+
}
78+
79+
// This function implements the common parts of a DERP scenario,
80+
// we *want* it to show up in stacktraces,
81+
// so marking it as a test helper would be counterproductive.
82+
//
83+
//nolint:thelper
84+
func derpServerScenario(
85+
t *testing.T,
86+
spec map[string]ClientsSpec,
87+
furtherAssertions ...func(*EmbeddedDERPServerScenario),
88+
) {
2589
IntegrationSkip(t)
2690
// t.Parallel()
2791

@@ -34,20 +98,18 @@ func TestDERPServerScenario(t *testing.T) {
3498
}
3599
defer scenario.ShutdownAssertNoPanics(t)
36100

37-
spec := map[string]int{
38-
"user1": len(MustTestVersions),
39-
}
40-
41101
err = scenario.CreateHeadscaleEnv(
42102
spec,
43103
hsic.WithTestName("derpserver"),
44104
hsic.WithExtraPorts([]string{"3478/udp"}),
45105
hsic.WithEmbeddedDERPServerOnly(),
106+
hsic.WithPort(443),
46107
hsic.WithTLS(),
47108
hsic.WithHostnameAsServerURL(),
48109
hsic.WithConfigEnv(map[string]string{
49110
"HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "true",
50111
"HEADSCALE_DERP_UPDATE_FREQUENCY": "10s",
112+
"HEADSCALE_LISTEN_ADDR": "0.0.0.0:443",
51113
}),
52114
)
53115
assertNoErrHeadscaleEnv(t, err)
@@ -76,6 +138,11 @@ func TestDERPServerScenario(t *testing.T) {
76138
}
77139

78140
success := pingDerpAllHelper(t, allClients, allHostnames)
141+
if len(allHostnames)*len(allClients) > success {
142+
t.FailNow()
143+
144+
return
145+
}
79146

80147
for _, client := range allClients {
81148
status, err := client.Status()
@@ -98,6 +165,9 @@ func TestDERPServerScenario(t *testing.T) {
98165
time.Sleep(30 * time.Second)
99166

100167
success = pingDerpAllHelper(t, allClients, allHostnames)
168+
if len(allHostnames)*len(allClients) > success {
169+
t.Fail()
170+
}
101171

102172
for _, client := range allClients {
103173
status, err := client.Status()
@@ -114,10 +184,14 @@ func TestDERPServerScenario(t *testing.T) {
114184
}
115185

116186
t.Logf("Run2: %d successful pings out of %d", success, len(allClients)*len(allHostnames))
187+
188+
for _, check := range furtherAssertions {
189+
check(&scenario)
190+
}
117191
}
118192

119193
func (s *EmbeddedDERPServerScenario) CreateHeadscaleEnv(
120-
users map[string]int,
194+
users map[string]ClientsSpec,
121195
opts ...hsic.Option,
122196
) error {
123197
hsServer, err := s.Headscale(opts...)
@@ -137,6 +211,7 @@ func (s *EmbeddedDERPServerScenario) CreateHeadscaleEnv(
137211
if err != nil {
138212
return err
139213
}
214+
log.Printf("headscale server ip address: %s", hsServer.GetIP())
140215

141216
hash, err := util.GenerateRandomStringDNSSafe(scenarioHashLength)
142217
if err != nil {
@@ -149,14 +224,31 @@ func (s *EmbeddedDERPServerScenario) CreateHeadscaleEnv(
149224
return err
150225
}
151226

152-
err = s.CreateTailscaleIsolatedNodesInUser(
153-
hash,
154-
userName,
155-
"all",
156-
clientCount,
157-
)
158-
if err != nil {
159-
return err
227+
if clientCount.Plain > 0 {
228+
// Containers that use default DERP config
229+
err = s.CreateTailscaleIsolatedNodesInUser(
230+
hash,
231+
userName,
232+
"all",
233+
clientCount.Plain,
234+
)
235+
if err != nil {
236+
return err
237+
}
238+
}
239+
240+
if clientCount.WebsocketDERP > 0 {
241+
// Containers that use DERP-over-WebSocket
242+
err = s.CreateTailscaleIsolatedNodesInUser(
243+
hash,
244+
userName,
245+
"all",
246+
clientCount.WebsocketDERP,
247+
tsic.WithWebsocketDERP(true),
248+
)
249+
if err != nil {
250+
return err
251+
}
160252
}
161253

162254
key, err := s.CreatePreAuthKey(userName, true, false)

0 commit comments

Comments
 (0)