20647: Fix CORS preflight request handling.
[arvados.git] / lib / controller / localdb / container_gateway.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package localdb
6
7 import (
8         "bufio"
9         "bytes"
10         "context"
11         "crypto/hmac"
12         "crypto/sha256"
13         "crypto/subtle"
14         "crypto/tls"
15         "crypto/x509"
16         "errors"
17         "fmt"
18         "io"
19         "io/ioutil"
20         "net"
21         "net/http"
22         "net/http/httputil"
23         "net/url"
24         "os"
25         "strings"
26
27         "git.arvados.org/arvados.git/lib/controller/rpc"
28         "git.arvados.org/arvados.git/lib/service"
29         "git.arvados.org/arvados.git/lib/webdavfs"
30         "git.arvados.org/arvados.git/sdk/go/arvados"
31         "git.arvados.org/arvados.git/sdk/go/auth"
32         "git.arvados.org/arvados.git/sdk/go/ctxlog"
33         "git.arvados.org/arvados.git/sdk/go/httpserver"
34         keepweb "git.arvados.org/arvados.git/services/keep-web"
35         "github.com/hashicorp/yamux"
36         "golang.org/x/net/webdav"
37 )
38
39 var (
40         forceProxyForTest       = false
41         forceInternalURLForTest *arvados.URL
42 )
43
44 // ContainerRequestLog returns a WebDAV handler that reads logs from
45 // the indicated container request. It works by proxying the incoming
46 // HTTP request to
47 //
48 //   - the container gateway, if there is an associated container that
49 //     is running
50 //
51 //   - a different controller process, if there is a running container
52 //     whose gateway is accessible through a tunnel to a different
53 //     controller process
54 //
55 //   - keep-web, if saved logs exist and there is no gateway (or the
56 //     associated container is finished)
57 //
58 //   - an empty-collection stub, if there is no gateway and no saved
59 //     log
60 //
61 // For an incoming request
62 //
63 //      GET /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}{/c_log_path}
64 //
65 // The upstream request may be to {c_uuid}'s container gateway
66 //
67 //      GET /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}{/c_log_path}
68 //      X-Webdav-Prefix: /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}
69 //      X-Webdav-Source: /log
70 //
71 // ...or the upstream request may be to keep-web (where {cr_log_uuid}
72 // is the container request log collection UUID)
73 //
74 //      GET /arvados/v1/container_requests/{cr_uuid}/log/{c_uuid}{/c_log_path}
75 //      Host: {cr_log_uuid}.internal
76 //      X-Webdav-Prefix: /arvados/v1/container_requests/{cr_uuid}/log
77 //      X-Arvados-Container-Uuid: {c_uuid}
78 //
79 // ...or the request may be handled locally using an empty-collection
80 // stub.
81 func (conn *Conn) ContainerRequestLog(ctx context.Context, opts arvados.ContainerLogOptions) (http.Handler, error) {
82         if opts.Method == "OPTIONS" && opts.Header.Get("Access-Control-Request-Method") != "" {
83                 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84                         if !keepweb.ServeCORSPreflight(w, opts.Header) {
85                                 // Inconceivable.  We already checked
86                                 // for the only condition where
87                                 // ServeCORSPreflight returns false.
88                                 httpserver.Error(w, "unhandled CORS preflight request", http.StatusInternalServerError)
89                         }
90                 }), nil
91         }
92         cr, err := conn.railsProxy.ContainerRequestGet(ctx, arvados.GetOptions{UUID: opts.UUID, Select: []string{"uuid", "container_uuid", "log_uuid"}})
93         if err != nil {
94                 if se := httpserver.HTTPStatusError(nil); errors.As(err, &se) && se.HTTPStatus() == http.StatusUnauthorized {
95                         // Hint to WebDAV client that we accept HTTP basic auth.
96                         return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97                                 w.Header().Set("Www-Authenticate", "Basic realm=\"collections\"")
98                                 w.WriteHeader(http.StatusUnauthorized)
99                         }), nil
100                 }
101                 return nil, err
102         }
103         ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{UUID: cr.ContainerUUID, Select: []string{"uuid", "state", "gateway_address"}})
104         if err != nil {
105                 return nil, err
106         }
107         // .../log/{ctr.UUID} is a directory where the currently
108         // assigned container's log data [will] appear (as opposed to
109         // previous attempts in .../log/{previous_ctr_uuid}). Requests
110         // that are outside that directory, and requests on a
111         // non-running container, are proxied to keep-web instead of
112         // going through the container gateway system.
113         //
114         // Side note: a depth>1 directory tree listing starting at
115         // .../{cr_uuid}/log will only include subdirectories for
116         // finished containers, i.e., will not include a subdirectory
117         // with log data for a current (unfinished) container UUID.
118         // In order to access live logs, a client must look up the
119         // container_uuid field of the container request record, and
120         // explicitly request a path under .../{cr_uuid}/log/{c_uuid}.
121         if ctr.GatewayAddress == "" ||
122                 (ctr.State != arvados.ContainerStateLocked && ctr.State != arvados.ContainerStateRunning) ||
123                 !(opts.Path == "/"+ctr.UUID || strings.HasPrefix(opts.Path, "/"+ctr.UUID+"/")) {
124                 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125                         conn.serveContainerRequestLogViaKeepWeb(opts, cr, w, r)
126                 }), nil
127         }
128         dial, arpc, err := conn.findGateway(ctx, ctr, opts.NoForward)
129         if err != nil {
130                 return nil, err
131         }
132         if arpc != nil {
133                 opts.NoForward = true
134                 return arpc.ContainerRequestLog(ctx, opts)
135         }
136         return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137                 r = r.WithContext(ctx)
138                 var proxyReq *http.Request
139                 var proxyErr error
140                 var expectRespondAuth string
141                 proxy := &httputil.ReverseProxy{
142                         // Our custom Transport:
143                         //
144                         // - Uses a custom dialer to connect to the
145                         // gateway (either directly or through a
146                         // tunnel set up though ContainerTunnel)
147                         //
148                         // - Verifies the gateway's TLS certificate
149                         // using X-Arvados-Authorization headers.
150                         //
151                         // This involves modifying the outgoing
152                         // request header in DialTLSContext.
153                         // (ReverseProxy certainly doesn't expect us
154                         // to do this, but it works.)
155                         Transport: &http.Transport{
156                                 DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
157                                         tlsconn, requestAuth, respondAuth, err := dial()
158                                         if err != nil {
159                                                 return nil, err
160                                         }
161                                         proxyReq.Header.Set("X-Arvados-Authorization", requestAuth)
162                                         expectRespondAuth = respondAuth
163                                         return tlsconn, nil
164                                 },
165                         },
166                         Director: func(r *http.Request) {
167                                 // Scheme/host of incoming r.URL are
168                                 // irrelevant now, and may even be
169                                 // missing. Host is ignored by our
170                                 // DialTLSContext, but we need a
171                                 // generic syntactically correct URL
172                                 // for net/http to work with.
173                                 r.URL.Scheme = "https"
174                                 r.URL.Host = "0.0.0.0:0"
175                                 r.Header.Set("X-Arvados-Container-Gateway-Uuid", ctr.UUID)
176                                 r.Header.Set("X-Webdav-Prefix", "/arvados/v1/container_requests/"+cr.UUID+"/log/"+ctr.UUID)
177                                 r.Header.Set("X-Webdav-Source", "/log")
178                                 proxyReq = r
179                         },
180                         ModifyResponse: func(resp *http.Response) error {
181                                 if resp.Header.Get("X-Arvados-Authorization-Response") != expectRespondAuth {
182                                         // Note this is how we detect
183                                         // an attacker-in-the-middle.
184                                         return httpserver.ErrorWithStatus(errors.New("bad X-Arvados-Authorization-Response header"), http.StatusBadGateway)
185                                 }
186                                 return nil
187                         },
188                         ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
189                                 proxyErr = err
190                         },
191                 }
192                 proxy.ServeHTTP(w, r)
193                 if proxyErr == nil {
194                         // proxy succeeded
195                         return
196                 }
197                 // If proxying to the container gateway fails, it
198                 // might be caused by a race where crunch-run exited
199                 // after we decided (above) the log was not final.
200                 // In that case we should proxy to keep-web.
201                 ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{
202                         UUID:   ctr.UUID,
203                         Select: []string{"uuid", "state", "gateway_address", "log"},
204                 })
205                 if err != nil {
206                         // Lost access to the container record?
207                         httpserver.Error(w, "error re-fetching container record: "+err.Error(), http.StatusServiceUnavailable)
208                 } else if ctr.State == arvados.ContainerStateLocked || ctr.State == arvados.ContainerStateRunning {
209                         // No race, proxyErr was the best we can do
210                         httpserver.Error(w, "proxy error: "+proxyErr.Error(), http.StatusServiceUnavailable)
211                 } else {
212                         conn.serveContainerRequestLogViaKeepWeb(opts, cr, w, r)
213                 }
214         }), nil
215 }
216
217 // serveContainerLogViaKeepWeb handles a request for saved container
218 // log content by proxying to one of the configured keep-web servers.
219 //
220 // It tries to choose a keep-web server that is running on this host.
221 func (conn *Conn) serveContainerRequestLogViaKeepWeb(opts arvados.ContainerLogOptions, cr arvados.ContainerRequest, w http.ResponseWriter, r *http.Request) {
222         if cr.LogUUID == "" {
223                 // Special case: if no log data exists yet, we serve
224                 // an empty collection by ourselves instead of
225                 // proxying to keep-web.
226                 conn.serveEmptyDir("/arvados/v1/container_requests/"+cr.UUID+"/log", w, r)
227                 return
228         }
229         myURL, _ := service.URLFromContext(r.Context())
230         u := url.URL(myURL)
231         myHostname := u.Hostname()
232         var webdavBase arvados.URL
233         var ok bool
234         for webdavBase = range conn.cluster.Services.WebDAV.InternalURLs {
235                 ok = true
236                 u := url.URL(webdavBase)
237                 if h := u.Hostname(); h == "127.0.0.1" || h == "0.0.0.0" || h == "::1" || h == myHostname {
238                         // Prefer a keep-web service running on the
239                         // same host as us. (If we don't find one, we
240                         // pick one arbitrarily.)
241                         break
242                 }
243         }
244         if !ok {
245                 httpserver.Error(w, "no internalURLs configured for WebDAV service", http.StatusInternalServerError)
246                 return
247         }
248         proxy := &httputil.ReverseProxy{
249                 Director: func(r *http.Request) {
250                         r.URL.Scheme = webdavBase.Scheme
251                         r.URL.Host = webdavBase.Host
252                         // Outgoing Host header specifies the
253                         // collection ID.
254                         r.Host = cr.LogUUID + ".internal"
255                         // We already checked permission on the
256                         // container, so we can use a root token here
257                         // instead of counting on the "access to log
258                         // via container request and container"
259                         // permission check, which can be racy when a
260                         // request gets retried with a new container.
261                         r.Header.Set("Authorization", "Bearer "+conn.cluster.SystemRootToken)
262                         // We can't change r.URL.Path without
263                         // confusing WebDAV (request body and response
264                         // headers refer to the same paths) so we tell
265                         // keep-web to map the log collection onto the
266                         // containers/X/log/ namespace.
267                         r.Header.Set("X-Webdav-Prefix", "/arvados/v1/container_requests/"+cr.UUID+"/log")
268                         if len(opts.Path) >= 28 && opts.Path[6:13] == "-dz642-" {
269                                 // "/arvados/v1/container_requests/{crUUID}/log/{cUUID}..."
270                                 // proxies to
271                                 // "/log for container {cUUID}..."
272                                 r.Header.Set("X-Webdav-Prefix", "/arvados/v1/container_requests/"+cr.UUID+"/log/"+opts.Path[1:28])
273                                 r.Header.Set("X-Webdav-Source", "/log for container "+opts.Path[1:28]+"/")
274                         }
275                 },
276         }
277         if conn.cluster.TLS.Insecure {
278                 proxy.Transport = &http.Transport{
279                         TLSClientConfig: &tls.Config{
280                                 InsecureSkipVerify: conn.cluster.TLS.Insecure,
281                         },
282                 }
283         }
284         proxy.ServeHTTP(w, r)
285 }
286
287 // serveEmptyDir handles read-only webdav requests as if there was an
288 // empty collection rooted at the given path. It's equivalent to
289 // proxying to an empty collection in keep-web, but avoids the extra
290 // hop.
291 func (conn *Conn) serveEmptyDir(path string, w http.ResponseWriter, r *http.Request) {
292         wh := webdav.Handler{
293                 Prefix:     path,
294                 FileSystem: webdav.NewMemFS(),
295                 LockSystem: webdavfs.NoLockSystem,
296                 Logger: func(r *http.Request, err error) {
297                         if err != nil && !os.IsNotExist(err) {
298                                 ctxlog.FromContext(r.Context()).WithError(err).Info("webdav error on empty collection fs")
299                         }
300                 },
301         }
302         wh.ServeHTTP(w, r)
303 }
304
305 // ContainerSSH returns a connection to the SSH server in the
306 // appropriate crunch-run process on the worker node where the
307 // specified container is running.
308 //
309 // If the returned error is nil, the caller is responsible for closing
310 // sshconn.Conn.
311 func (conn *Conn) ContainerSSH(ctx context.Context, opts arvados.ContainerSSHOptions) (sshconn arvados.ConnectionResponse, err error) {
312         user, err := conn.railsProxy.UserGetCurrent(ctx, arvados.GetOptions{})
313         if err != nil {
314                 return sshconn, err
315         }
316         ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{UUID: opts.UUID, Select: []string{"uuid", "state", "gateway_address", "interactive_session_started"}})
317         if err != nil {
318                 return sshconn, err
319         }
320         ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{conn.cluster.SystemRootToken}})
321         if !user.IsAdmin || !conn.cluster.Containers.ShellAccess.Admin {
322                 if !conn.cluster.Containers.ShellAccess.User {
323                         return sshconn, httpserver.ErrorWithStatus(errors.New("shell access is disabled in config"), http.StatusServiceUnavailable)
324                 }
325                 crs, err := conn.railsProxy.ContainerRequestList(ctxRoot, arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"container_uuid", "=", opts.UUID}}})
326                 if err != nil {
327                         return sshconn, err
328                 }
329                 for _, cr := range crs.Items {
330                         if cr.ModifiedByUserUUID != user.UUID {
331                                 return sshconn, httpserver.ErrorWithStatus(errors.New("permission denied: container is associated with requests submitted by other users"), http.StatusForbidden)
332                         }
333                 }
334                 if crs.ItemsAvailable != len(crs.Items) {
335                         return sshconn, httpserver.ErrorWithStatus(errors.New("incomplete response while checking permission"), http.StatusInternalServerError)
336                 }
337         }
338
339         if ctr.State == arvados.ContainerStateQueued || ctr.State == arvados.ContainerStateLocked {
340                 return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("container is not running yet (state is %q)", ctr.State), http.StatusServiceUnavailable)
341         } else if ctr.State != arvados.ContainerStateRunning {
342                 return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("container has ended (state is %q)", ctr.State), http.StatusGone)
343         }
344
345         dial, arpc, err := conn.findGateway(ctx, ctr, opts.NoForward)
346         if err != nil {
347                 return sshconn, err
348         }
349         if arpc != nil {
350                 opts.NoForward = true
351                 return arpc.ContainerSSH(ctx, opts)
352         }
353
354         tlsconn, requestAuth, respondAuth, err := dial()
355         if err != nil {
356                 return sshconn, err
357         }
358         bufr := bufio.NewReader(tlsconn)
359         bufw := bufio.NewWriter(tlsconn)
360
361         u := url.URL{
362                 Scheme: "http",
363                 Host:   tlsconn.RemoteAddr().String(),
364                 Path:   "/ssh",
365         }
366         postform := url.Values{
367                 // uuid is only needed for older crunch-run versions
368                 // (current version uses X-Arvados-* header below)
369                 "uuid":           {opts.UUID},
370                 "detach_keys":    {opts.DetachKeys},
371                 "login_username": {opts.LoginUsername},
372                 "no_forward":     {fmt.Sprintf("%v", opts.NoForward)},
373         }
374         postdata := postform.Encode()
375         bufw.WriteString("POST " + u.String() + " HTTP/1.1\r\n")
376         bufw.WriteString("Host: " + u.Host + "\r\n")
377         bufw.WriteString("Upgrade: ssh\r\n")
378         bufw.WriteString("X-Arvados-Container-Gateway-Uuid: " + opts.UUID + "\r\n")
379         bufw.WriteString("X-Arvados-Authorization: " + requestAuth + "\r\n")
380         bufw.WriteString("Content-Type: application/x-www-form-urlencoded\r\n")
381         fmt.Fprintf(bufw, "Content-Length: %d\r\n", len(postdata))
382         bufw.WriteString("\r\n")
383         bufw.WriteString(postdata)
384         bufw.Flush()
385         resp, err := http.ReadResponse(bufr, &http.Request{Method: "POST"})
386         if err != nil {
387                 tlsconn.Close()
388                 return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("error reading http response from gateway: %w", err), http.StatusBadGateway)
389         }
390         defer resp.Body.Close()
391         if resp.StatusCode != http.StatusSwitchingProtocols {
392                 body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1000))
393                 tlsconn.Close()
394                 return sshconn, httpserver.ErrorWithStatus(fmt.Errorf("unexpected status %s %q", resp.Status, body), http.StatusBadGateway)
395         }
396         if strings.ToLower(resp.Header.Get("Upgrade")) != "ssh" ||
397                 strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
398                 tlsconn.Close()
399                 return sshconn, httpserver.ErrorWithStatus(errors.New("bad upgrade"), http.StatusBadGateway)
400         }
401         if resp.Header.Get("X-Arvados-Authorization-Response") != respondAuth {
402                 tlsconn.Close()
403                 return sshconn, httpserver.ErrorWithStatus(errors.New("bad X-Arvados-Authorization-Response header"), http.StatusBadGateway)
404         }
405
406         if !ctr.InteractiveSessionStarted {
407                 _, err = conn.railsProxy.ContainerUpdate(ctxRoot, arvados.UpdateOptions{
408                         UUID: opts.UUID,
409                         Attrs: map[string]interface{}{
410                                 "interactive_session_started": true,
411                         },
412                 })
413                 if err != nil {
414                         tlsconn.Close()
415                         return sshconn, httpserver.ErrorWithStatus(err, http.StatusInternalServerError)
416                 }
417         }
418
419         sshconn.Conn = tlsconn
420         sshconn.Bufrw = &bufio.ReadWriter{Reader: bufr, Writer: bufw}
421         sshconn.Logger = ctxlog.FromContext(ctx)
422         sshconn.Header = http.Header{"Upgrade": {"ssh"}}
423         return sshconn, nil
424 }
425
426 // ContainerGatewayTunnel sets up a tunnel enabling us (controller) to
427 // connect to the caller's (crunch-run's) gateway server.
428 func (conn *Conn) ContainerGatewayTunnel(ctx context.Context, opts arvados.ContainerGatewayTunnelOptions) (resp arvados.ConnectionResponse, err error) {
429         h := hmac.New(sha256.New, []byte(conn.cluster.SystemRootToken))
430         fmt.Fprint(h, opts.UUID)
431         authSecret := fmt.Sprintf("%x", h.Sum(nil))
432         if subtle.ConstantTimeCompare([]byte(authSecret), []byte(opts.AuthSecret)) != 1 {
433                 ctxlog.FromContext(ctx).Info("received incorrect auth_secret")
434                 return resp, httpserver.ErrorWithStatus(errors.New("authentication error"), http.StatusUnauthorized)
435         }
436
437         muxconn, clientconn := net.Pipe()
438         tunnel, err := yamux.Server(muxconn, nil)
439         if err != nil {
440                 clientconn.Close()
441                 return resp, httpserver.ErrorWithStatus(err, http.StatusInternalServerError)
442         }
443
444         conn.gwTunnelsLock.Lock()
445         if conn.gwTunnels == nil {
446                 conn.gwTunnels = map[string]*yamux.Session{opts.UUID: tunnel}
447         } else {
448                 conn.gwTunnels[opts.UUID] = tunnel
449         }
450         conn.gwTunnelsLock.Unlock()
451
452         go func() {
453                 <-tunnel.CloseChan()
454                 conn.gwTunnelsLock.Lock()
455                 if conn.gwTunnels[opts.UUID] == tunnel {
456                         delete(conn.gwTunnels, opts.UUID)
457                 }
458                 conn.gwTunnelsLock.Unlock()
459         }()
460
461         // Assuming we're acting as the backend of an http server,
462         // lib/controller/router will call resp's ServeHTTP handler,
463         // which upgrades the incoming http connection to a raw socket
464         // and connects it to our yamux.Server through our net.Pipe().
465         resp.Conn = clientconn
466         resp.Bufrw = &bufio.ReadWriter{Reader: bufio.NewReader(&bytes.Buffer{}), Writer: bufio.NewWriter(&bytes.Buffer{})}
467         resp.Logger = ctxlog.FromContext(ctx)
468         resp.Header = http.Header{"Upgrade": {"tunnel"}}
469         if u, ok := service.URLFromContext(ctx); ok {
470                 resp.Header.Set("X-Arvados-Internal-Url", u.String())
471         } else if forceInternalURLForTest != nil {
472                 resp.Header.Set("X-Arvados-Internal-Url", forceInternalURLForTest.String())
473         }
474         return
475 }
476
477 type gatewayDialer func() (conn net.Conn, requestAuth, respondAuth string, err error)
478
479 // findGateway figures out how to connect to ctr's gateway.
480 //
481 // If the gateway can be contacted directly or through a tunnel on
482 // this instance, the first return value is a non-nil dialer.
483 //
484 // If the gateway is only accessible through a tunnel through a
485 // different controller process, the second return value is a non-nil
486 // *rpc.Conn for that controller.
487 func (conn *Conn) findGateway(ctx context.Context, ctr arvados.Container, noForward bool) (gatewayDialer, *rpc.Conn, error) {
488         conn.gwTunnelsLock.Lock()
489         tunnel := conn.gwTunnels[ctr.UUID]
490         conn.gwTunnelsLock.Unlock()
491
492         myURL, _ := service.URLFromContext(ctx)
493
494         if host, _, splitErr := net.SplitHostPort(ctr.GatewayAddress); splitErr == nil && host != "" && host != "127.0.0.1" {
495                 // If crunch-run provided a GatewayAddress like
496                 // "ipaddr:port", that means "ipaddr" is one of the
497                 // external interfaces where the gateway is
498                 // listening. In that case, it's the most
499                 // reliable/direct option, so we use it even if a
500                 // tunnel might also be available.
501                 return func() (net.Conn, string, string, error) {
502                         rawconn, err := (&net.Dialer{}).DialContext(ctx, "tcp", ctr.GatewayAddress)
503                         if err != nil {
504                                 return nil, "", "", httpserver.ErrorWithStatus(err, http.StatusServiceUnavailable)
505                         }
506                         return conn.dialGatewayTLS(ctx, ctr, rawconn)
507                 }, nil, nil
508         }
509         if tunnel != nil && !(forceProxyForTest && !noForward) {
510                 // If we can't connect directly, and the gateway has
511                 // established a yamux tunnel with us, connect through
512                 // the tunnel.
513                 //
514                 // ...except: forceProxyForTest means we are emulating
515                 // a situation where the gateway has established a
516                 // yamux tunnel with controller B, and the
517                 // ContainerSSH request arrives at controller A. If
518                 // noForward==false then we are acting as A, so
519                 // we pretend not to have a tunnel, and fall through
520                 // to the "tunurl" case below. If noForward==true
521                 // then the client is A and we are acting as B, so we
522                 // connect to our tunnel.
523                 return func() (net.Conn, string, string, error) {
524                         rawconn, err := tunnel.Open()
525                         if err != nil {
526                                 return nil, "", "", httpserver.ErrorWithStatus(err, http.StatusServiceUnavailable)
527                         }
528                         return conn.dialGatewayTLS(ctx, ctr, rawconn)
529                 }, nil, nil
530         }
531         if tunurl := strings.TrimPrefix(ctr.GatewayAddress, "tunnel "); tunurl != ctr.GatewayAddress &&
532                 tunurl != "" &&
533                 tunurl != myURL.String() &&
534                 !noForward {
535                 // If crunch-run provided a GatewayAddress like
536                 // "tunnel https://10.0.0.10:1010/", that means the
537                 // gateway has established a yamux tunnel with the
538                 // controller process at the indicated InternalURL
539                 // (which isn't us, otherwise we would have had
540                 // "tunnel != nil" above). We need to proxy through to
541                 // the other controller process in order to use the
542                 // tunnel.
543                 for u := range conn.cluster.Services.Controller.InternalURLs {
544                         if u.String() == tunurl {
545                                 ctxlog.FromContext(ctx).Debugf("connecting to container gateway through other controller at %s", u)
546                                 u := url.URL(u)
547                                 return nil, rpc.NewConn(conn.cluster.ClusterID, &u, conn.cluster.TLS.Insecure, rpc.PassthroughTokenProvider), nil
548                         }
549                 }
550                 ctxlog.FromContext(ctx).Warnf("container gateway provided a tunnel endpoint %s that is not one of Services.Controller.InternalURLs", tunurl)
551                 return nil, nil, httpserver.ErrorWithStatus(errors.New("container gateway is running but tunnel endpoint is invalid"), http.StatusServiceUnavailable)
552         }
553         if ctr.GatewayAddress == "" {
554                 return nil, nil, httpserver.ErrorWithStatus(errors.New("container is running but gateway is not available"), http.StatusServiceUnavailable)
555         } else {
556                 return nil, nil, httpserver.ErrorWithStatus(errors.New("container is running but tunnel is down"), http.StatusServiceUnavailable)
557         }
558 }
559
560 // dialGatewayTLS negotiates a TLS connection to a container gateway
561 // over the given raw connection.
562 func (conn *Conn) dialGatewayTLS(ctx context.Context, ctr arvados.Container, rawconn net.Conn) (*tls.Conn, string, string, error) {
563         // crunch-run uses a self-signed / unverifiable TLS
564         // certificate, so we use the following scheme to ensure we're
565         // not talking to an attacker-in-the-middle.
566         //
567         // 1. Compute ctrKey = HMAC-SHA256(sysRootToken,ctrUUID) --
568         // this will be the same ctrKey that a-d-c supplied to
569         // crunch-run in the GatewayAuthSecret env var.
570         //
571         // 2. Compute requestAuth = HMAC-SHA256(ctrKey,serverCert) and
572         // send it to crunch-run as the X-Arvados-Authorization
573         // header, proving that we know ctrKey. (Note a MITM cannot
574         // replay the proof to a real crunch-run server, because the
575         // real crunch-run server would have a different cert.)
576         //
577         // 3. Compute respondAuth = HMAC-SHA256(ctrKey,requestAuth)
578         // and ensure the server returns it in the
579         // X-Arvados-Authorization-Response header, proving that the
580         // server knows ctrKey.
581         var requestAuth, respondAuth string
582         tlsconn := tls.Client(rawconn, &tls.Config{
583                 InsecureSkipVerify: true,
584                 VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
585                         if len(rawCerts) == 0 {
586                                 return errors.New("no certificate received, cannot compute authorization header")
587                         }
588                         h := hmac.New(sha256.New, []byte(conn.cluster.SystemRootToken))
589                         fmt.Fprint(h, ctr.UUID)
590                         authKey := fmt.Sprintf("%x", h.Sum(nil))
591                         h = hmac.New(sha256.New, []byte(authKey))
592                         h.Write(rawCerts[0])
593                         requestAuth = fmt.Sprintf("%x", h.Sum(nil))
594                         h.Reset()
595                         h.Write([]byte(requestAuth))
596                         respondAuth = fmt.Sprintf("%x", h.Sum(nil))
597                         return nil
598                 },
599         })
600         err := tlsconn.HandshakeContext(ctx)
601         if err != nil {
602                 return nil, "", "", httpserver.ErrorWithStatus(fmt.Errorf("TLS handshake failed: %w", err), http.StatusBadGateway)
603         }
604         if respondAuth == "" {
605                 tlsconn.Close()
606                 return nil, "", "", httpserver.ErrorWithStatus(errors.New("BUG: no respondAuth"), http.StatusInternalServerError)
607         }
608         return tlsconn, requestAuth, respondAuth, nil
609 }