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