/
alpn_conn_upgrade.go
307 lines (270 loc) · 10.5 KB
/
alpn_conn_upgrade.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
/*
Copyright 2022 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package client
import (
"bufio"
"context"
"crypto/tls"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/pingconn"
"github.com/gravitational/teleport/api/utils/tlsutils"
)
// IsALPNConnUpgradeRequired returns true if a tunnel is required through a HTTP
// connection upgrade for ALPN connections.
//
// The function makes a test connection to the Proxy Service and checks if the
// ALPN is supported. If not, the Proxy Service is likely behind an AWS ALB or
// some custom proxy services that strip out ALPN and SNI information on the
// way to our Proxy Service.
//
// In those cases, the Teleport client should make a HTTP "upgrade" call to the
// Proxy Service to establish a tunnel for the originally planned traffic to
// preserve the ALPN and SNI information.
func IsALPNConnUpgradeRequired(ctx context.Context, addr string, insecure bool, opts ...DialOption) bool {
if result, ok := OverwriteALPNConnUpgradeRequirementByEnv(addr); ok {
return result
}
// Use NewDialer which takes care of ProxyURL, and use a shorter I/O
// timeout to avoid blocking caller.
baseDialer := NewDialer(
ctx,
defaults.DefaultIdleTimeout,
5*time.Second,
append(opts,
WithInsecureSkipVerify(insecure),
WithALPNConnUpgrade(false),
)...,
)
tlsConfig := &tls.Config{
NextProtos: []string{string(constants.ALPNSNIProtocolReverseTunnel)},
InsecureSkipVerify: insecure,
}
testConn, err := tlsutils.TLSDial(ctx, baseDialer, "tcp", addr, tlsConfig)
logger := slog.With("address", addr)
if err != nil {
if isRemoteNoALPNError(err) {
logger.DebugContext(ctx, "No ALPN protocol is negotiated by the server.", "upgrade_required", true)
return true
}
if isUnadvertisedALPNError(err) {
logger.DebugContext(ctx, "ALPN connection upgrade received an unadvertised ALPN protocol.", "error", err)
return true
}
// If dialing TLS fails for any other reason, we assume connection
// upgrade is not required so it will fallback to original connection
// method.
logger.InfoContext(ctx, "ALPN connection upgrade test failed.", "error", err)
return false
}
defer testConn.Close()
// Upgrade required when ALPN is not supported on the remote side so
// NegotiatedProtocol comes back as empty.
result := testConn.ConnectionState().NegotiatedProtocol == ""
logger.DebugContext(ctx, "ALPN connection upgrade test complete", "upgrade_required", result)
return result
}
func isRemoteNoALPNError(err error) bool {
var opErr *net.OpError
return errors.As(err, &opErr) && opErr.Op == "remote error" && strings.Contains(opErr.Err.Error(), "tls: no application protocol")
}
// isUnadvertisedALPNError returns true if the error indicates that the server
// returns an ALPN value that the client does not expect during TLS handshake.
//
// Reference:
// https://github.com/golang/go/blob/2639a17f146cc7df0778298c6039156d7ca68202/src/crypto/tls/handshake_client.go#L838
func isUnadvertisedALPNError(err error) bool {
return strings.Contains(err.Error(), "tls: server selected unadvertised ALPN protocol")
}
// OverwriteALPNConnUpgradeRequirementByEnv overwrites ALPN connection upgrade
// requirement by environment variable.
//
// TODO(greedy52) DELETE in ??. Note that this toggle was planned to be deleted
// in 15.0 when the feature exits preview. However, many users still rely on
// this manual toggle as IsALPNConnUpgradeRequired cannot detect many
// situations where connection upgrade is required. This can be deleted once
// IsALPNConnUpgradeRequired is improved.
func OverwriteALPNConnUpgradeRequirementByEnv(addr string) (bool, bool) {
envValue := os.Getenv(defaults.TLSRoutingConnUpgradeEnvVar)
if envValue == "" {
return false, false
}
result := isALPNConnUpgradeRequiredByEnv(addr, envValue)
slog.DebugContext(context.TODO(), "Determining if ALPN connection upgrade is explicitly forced due to environment variables.", defaults.TLSRoutingConnUpgradeEnvVar, envValue, "address", addr, "upgrade_required", result)
return result, true
}
// isALPNConnUpgradeRequiredByEnv checks if ALPN connection upgrade is required
// based on provided env value.
//
// The env value should contain a list of conditions separated by either ';' or
// ','. A condition is in format of either '<addr>=<bool>' or '<bool>'. The
// former specifies the upgrade requirement for a specific address and the
// later specifies the upgrade requirement for all other addresses. By default,
// upgrade is not required if target is not specified in the env value.
//
// Sample values:
// true
// <some.cluster.com>=yes,<another.cluster.com>=no
// 0,<some.cluster.com>=1
func isALPNConnUpgradeRequiredByEnv(addr, envValue string) bool {
tokens := strings.FieldsFunc(envValue, func(r rune) bool {
return r == ';' || r == ','
})
var upgradeRequiredForAll bool
for _, token := range tokens {
switch {
case strings.ContainsRune(token, '='):
if _, boolText, ok := strings.Cut(token, addr+"="); ok {
upgradeRequiredForAddr, err := utils.ParseBool(boolText)
if err != nil {
slog.DebugContext(context.TODO(), "Failed to parse ALPN connection upgrade environment variable", "value", envValue, "error", err)
}
return upgradeRequiredForAddr
}
default:
if boolValue, err := utils.ParseBool(token); err != nil {
slog.DebugContext(context.TODO(), "Failed to parse ALPN connection upgrade environment variable", "value", envValue, "error", err)
} else {
upgradeRequiredForAll = boolValue
}
}
}
return upgradeRequiredForAll
}
// alpnConnUpgradeDialer makes an "HTTP" upgrade call to the Proxy Service then
// tunnels the connection with this connection upgrade.
type alpnConnUpgradeDialer struct {
dialer ContextDialer
tlsConfig *tls.Config
withPing bool
}
// newALPNConnUpgradeDialer creates a new alpnConnUpgradeDialer.
func newALPNConnUpgradeDialer(dialer ContextDialer, tlsConfig *tls.Config, withPing bool) ContextDialer {
return &alpnConnUpgradeDialer{
dialer: dialer,
tlsConfig: tlsConfig,
withPing: withPing,
}
}
// DialContext implements ContextDialer
func (d *alpnConnUpgradeDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
tlsConn, err := tlsutils.TLSDial(ctx, d.dialer, network, addr, d.tlsConfig.Clone())
if err != nil {
return nil, trace.Wrap(err)
}
upgradeURL := url.URL{
Host: addr,
Scheme: "https",
Path: constants.WebAPIConnUpgrade,
}
conn, err := upgradeConnThroughWebAPI(tlsConn, upgradeURL, d.upgradeType())
if err != nil {
return nil, trace.NewAggregate(tlsConn.Close(), err)
}
return conn, nil
}
func (d *alpnConnUpgradeDialer) upgradeType() string {
if d.withPing {
return constants.WebAPIConnUpgradeTypeALPNPing
}
return constants.WebAPIConnUpgradeTypeALPN
}
func upgradeConnThroughWebAPI(conn net.Conn, api url.URL, alpnUpgradeType string) (net.Conn, error) {
req, err := http.NewRequest(http.MethodGet, api.String(), nil)
if err != nil {
return nil, trace.Wrap(err)
}
challengeKey, err := generateWebSocketChallengeKey()
if err != nil {
return nil, trace.Wrap(err)
}
// Prefer "websocket".
if useConnUpgradeMode.useWebSocket() {
applyWebSocketUpgradeHeaders(req, alpnUpgradeType, challengeKey)
}
// Append "legacy" custom upgrade type.
// TODO(greedy52) DELETE in 17.0
if useConnUpgradeMode.useLegacy() {
req.Header.Add(constants.WebAPIConnUpgradeHeader, alpnUpgradeType)
req.Header.Add(constants.WebAPIConnUpgradeTeleportHeader, alpnUpgradeType)
}
// Set "Connection" header to meet RFC spec:
// https://datatracker.ietf.org/doc/html/rfc2616#section-14.42
// Quote: "the upgrade keyword MUST be supplied within a Connection header
// field (section 14.10) whenever Upgrade is present in an HTTP/1.1
// message."
//
// Some L7 load balancers/reverse proxies like "ngrok" and "tailscale"
// require this header to be set to complete the upgrade flow. The header
// must be set on both the upgrade request here and the 101 Switching
// Protocols response from the server.
req.Header.Set(constants.WebAPIConnUpgradeConnectionHeader, constants.WebAPIConnUpgradeConnectionType)
// Send the request and check if upgrade is successful.
if err = req.Write(conn); err != nil {
return nil, trace.Wrap(err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
if err != nil {
return nil, trace.Wrap(err)
}
defer resp.Body.Close()
if http.StatusSwitchingProtocols != resp.StatusCode {
if http.StatusNotFound == resp.StatusCode {
return nil, trace.NotImplemented(
"connection upgrade call to %q with upgrade type %v failed with status code %v. Please upgrade the server and try again.",
constants.WebAPIConnUpgrade,
alpnUpgradeType,
resp.StatusCode,
)
}
return nil, trace.BadParameter("failed to switch Protocols %v", resp.StatusCode)
}
// Handle WebSocket.
logger := slog.With("hostname", api.Host)
if resp.Header.Get(constants.WebAPIConnUpgradeHeader) == constants.WebAPIConnUpgradeTypeWebSocket {
if err := checkWebSocketUpgradeResponse(resp, alpnUpgradeType, challengeKey); err != nil {
return nil, trace.Wrap(err)
}
logger.DebugContext(req.Context(), "Performing ALPN WebSocket connection upgrade.")
return newWebSocketALPNClientConn(conn), nil
}
// Handle "legacy".
// TODO(greedy52) DELETE in 17.0.
logger.DebugContext(req.Context(), "Performing ALPN legacy connection upgrade.")
if alpnUpgradeType == constants.WebAPIConnUpgradeTypeALPNPing {
return pingconn.New(conn), nil
}
return conn, nil
}
type connUpgradeMode string
func (m connUpgradeMode) useWebSocket() bool {
// Use WebSocket as long as it's not legacy only.
return strings.ToLower(string(m)) != "legacy"
}
func (m connUpgradeMode) useLegacy() bool {
// Use legacy as long as it's not WebSocket only.
return strings.ToLower(string(m)) != "websocket"
}
var (
useConnUpgradeMode connUpgradeMode = connUpgradeMode(os.Getenv(defaults.TLSRoutingConnUpgradeModeEnvVar))
)