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