17170: Merge branch 'master'
authorTom Clegg <tom@curii.com>
Wed, 3 Feb 2021 16:46:43 +0000 (11:46 -0500)
committerTom Clegg <tom@curii.com>
Wed, 3 Feb 2021 16:46:43 +0000 (11:46 -0500)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

35 files changed:
cmd/arvados-client/cmd.go
cmd/arvados-client/container_gateway.go [new file with mode: 0644]
cmd/arvados-client/container_gateway_test.go [new file with mode: 0644]
doc/admin/upgrading.html.textile.liquid
doc/api/methods/containers.html.textile.liquid
doc/install/install-api-server.html.textile.liquid
go.mod
go.sum
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/federation/conn.go
lib/controller/handler.go
lib/controller/localdb/container_gateway.go [new file with mode: 0644]
lib/controller/localdb/container_gateway_test.go [new file with mode: 0644]
lib/controller/router/router.go
lib/controller/rpc/conn.go
lib/crunchrun/container_gateway.go [new file with mode: 0644]
lib/crunchrun/crunchrun.go
lib/dispatchcloud/worker/pool.go
lib/dispatchcloud/worker/runner.go
lib/selfsigned/cert.go [new file with mode: 0644]
lib/selfsigned/cert_test.go [new file with mode: 0644]
sdk/go/arvados/api.go
sdk/go/arvados/client.go
sdk/go/arvados/config.go
sdk/go/arvados/container.go
sdk/go/arvados/container_gateway.go [new file with mode: 0644]
sdk/go/arvadostest/api.go
sdk/python/tests/run_test_server.py
services/api/app/models/container.rb
services/api/db/migrate/20210108033940_add_gateway_address_to_containers.rb [new file with mode: 0644]
services/api/db/migrate/20210126183521_add_interactive_session_started_to_containers.rb [new file with mode: 0644]
services/api/db/structure.sql
services/api/test/unit/container_test.rb

index 47fcd5ad7dc88275c5c9ce47369f3432ac861632..aefcce79a45a4eb65fbbed209801d684da6b4923 100644 (file)
@@ -57,6 +57,8 @@ var (
                "mount":                mount.Command,
                "deduplication-report": deduplicationreport.Command,
                "costanalyzer":         costanalyzer.Command,
+               "shell":                shellCommand{},
+               "connect-ssh":          connectSSHCommand{},
        })
 )
 
diff --git a/cmd/arvados-client/container_gateway.go b/cmd/arvados-client/container_gateway.go
new file mode 100644 (file)
index 0000000..6dbc241
--- /dev/null
@@ -0,0 +1,202 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+       "bytes"
+       "context"
+       "flag"
+       "fmt"
+       "io"
+       "net/url"
+       "os"
+       "os/exec"
+       "strings"
+       "syscall"
+
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+)
+
+// shellCommand connects the terminal to an interactive shell on a
+// running container.
+type shellCommand struct{}
+
+func (shellCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+       f := flag.NewFlagSet(prog, flag.ContinueOnError)
+       f.SetOutput(stderr)
+       f.Usage = func() {
+               fmt.Fprint(stderr, prog+`: open an interactive shell on a running container.
+
+Usage: `+prog+` [options] [username@]container-uuid [ssh-options] [remote-command [args...]]
+
+Options:
+`)
+               f.PrintDefaults()
+       }
+       detachKeys := f.String("detach-keys", "ctrl-],ctrl-]", "set detach key sequence, as in docker-attach(1)")
+       err := f.Parse(args)
+       if err != nil {
+               fmt.Fprintln(stderr, err)
+               return 2
+       }
+
+       if f.NArg() < 1 {
+               f.Usage()
+               return 2
+       }
+       target := f.Args()[0]
+       if !strings.Contains(target, "@") {
+               target = "root@" + target
+       }
+       sshargs := f.Args()[1:]
+
+       // Try setting up a tunnel, and exit right away if it
+       // fails. This tunnel won't get used -- we'll set up a new
+       // tunnel when running as SSH client's ProxyCommand child --
+       // but in most cases where the real tunnel setup would fail,
+       // we catch the problem earlier here. This makes it less
+       // likely that an error message about tunnel setup will get
+       // hidden behind noisy errors from SSH client like this:
+       //
+       // [useful tunnel setup error message here]
+       // kex_exchange_identification: Connection closed by remote host
+       // Connection closed by UNKNOWN port 65535
+       // exit status 255
+       exitcode := connectSSHCommand{}.RunCommand(
+               "arvados-client connect-ssh",
+               []string{"-detach-keys=" + *detachKeys, "-probe-only=true", target},
+               &bytes.Buffer{}, &bytes.Buffer{}, stderr)
+       if exitcode != 0 {
+               return exitcode
+       }
+
+       selfbin, err := os.Readlink("/proc/self/exe")
+       if err != nil {
+               fmt.Fprintln(stderr, err)
+               return 2
+       }
+       sshargs = append([]string{
+               "ssh",
+               "-o", "ProxyCommand " + selfbin + " connect-ssh -detach-keys=" + shellescape(*detachKeys) + " " + shellescape(target),
+               "-o", "StrictHostKeyChecking no",
+               target},
+               sshargs...)
+       sshbin, err := exec.LookPath("ssh")
+       if err != nil {
+               fmt.Fprintln(stderr, err)
+               return 1
+       }
+       err = syscall.Exec(sshbin, sshargs, os.Environ())
+       fmt.Fprintf(stderr, "exec(%q) failed: %s\n", sshbin, err)
+       return 1
+}
+
+// connectSSHCommand connects stdin/stdout to a container's gateway
+// server (see lib/crunchrun/ssh.go).
+//
+// It is intended to be invoked with OpenSSH client's ProxyCommand
+// config.
+type connectSSHCommand struct{}
+
+func (connectSSHCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+       f := flag.NewFlagSet(prog, flag.ContinueOnError)
+       f.SetOutput(stderr)
+       f.Usage = func() {
+               fmt.Fprint(stderr, prog+`: connect to the gateway service for a running container.
+
+Usage: `+prog+` [options] [username@]container-uuid
+
+Options:
+`)
+               f.PrintDefaults()
+       }
+       probeOnly := f.Bool("probe-only", false, "do not transfer IO, just exit 0 immediately if tunnel setup succeeds")
+       detachKeys := f.String("detach-keys", "", "set detach key sequence, as in docker-attach(1)")
+       if err := f.Parse(args); err != nil {
+               fmt.Fprintln(stderr, err)
+               return 2
+       } else if f.NArg() != 1 {
+               f.Usage()
+               return 2
+       }
+       targetUUID := f.Args()[0]
+       loginUsername := "root"
+       if i := strings.Index(targetUUID, "@"); i >= 0 {
+               loginUsername = targetUUID[:i]
+               targetUUID = targetUUID[i+1:]
+       }
+       if os.Getenv("ARVADOS_API_HOST") == "" || os.Getenv("ARVADOS_API_TOKEN") == "" {
+               fmt.Fprintln(stderr, "fatal: ARVADOS_API_HOST and ARVADOS_API_TOKEN environment variables are not set")
+               return 1
+       }
+       insecure := os.Getenv("ARVADOS_API_HOST_INSECURE")
+       rpcconn := rpc.NewConn("",
+               &url.URL{
+                       Scheme: "https",
+                       Host:   os.Getenv("ARVADOS_API_HOST"),
+               },
+               insecure == "1" || insecure == "yes" || insecure == "true",
+               func(context.Context) ([]string, error) {
+                       return []string{os.Getenv("ARVADOS_API_TOKEN")}, nil
+               })
+       if strings.Contains(targetUUID, "-xvhdp-") {
+               crs, err := rpcconn.ContainerRequestList(context.TODO(), arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", targetUUID}}})
+               if err != nil {
+                       fmt.Fprintln(stderr, err)
+                       return 1
+               }
+               if len(crs.Items) < 1 {
+                       fmt.Fprintf(stderr, "container request %q not found\n", targetUUID)
+                       return 1
+               }
+               cr := crs.Items[0]
+               if cr.ContainerUUID == "" {
+                       fmt.Fprintf(stderr, "no container assigned, container request state is %s\n", strings.ToLower(string(cr.State)))
+                       return 1
+               }
+               targetUUID = cr.ContainerUUID
+               fmt.Fprintln(stderr, "connecting to container", targetUUID)
+       } else if !strings.Contains(targetUUID, "-dz642-") {
+               fmt.Fprintf(stderr, "target UUID is not a container or container request UUID: %s\n", targetUUID)
+               return 1
+       }
+       sshconn, err := rpcconn.ContainerSSH(context.TODO(), arvados.ContainerSSHOptions{
+               UUID:          targetUUID,
+               DetachKeys:    *detachKeys,
+               LoginUsername: loginUsername,
+       })
+       if err != nil {
+               fmt.Fprintln(stderr, "error setting up tunnel:", err)
+               return 1
+       }
+       defer sshconn.Conn.Close()
+
+       if *probeOnly {
+               return 0
+       }
+
+       ctx, cancel := context.WithCancel(context.Background())
+       go func() {
+               defer cancel()
+               _, err := io.Copy(stdout, sshconn.Conn)
+               if err != nil && ctx.Err() == nil {
+                       fmt.Fprintf(stderr, "receive: %v\n", err)
+               }
+       }()
+       go func() {
+               defer cancel()
+               _, err := io.Copy(sshconn.Conn, stdin)
+               if err != nil && ctx.Err() == nil {
+                       fmt.Fprintf(stderr, "send: %v\n", err)
+               }
+       }()
+       <-ctx.Done()
+       return 0
+}
+
+func shellescape(s string) string {
+       return "'" + strings.Replace(s, "'", "'\\''", -1) + "'"
+}
diff --git a/cmd/arvados-client/container_gateway_test.go b/cmd/arvados-client/container_gateway_test.go
new file mode 100644 (file)
index 0000000..39d6e91
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+       "bytes"
+       "context"
+       "crypto/hmac"
+       "crypto/sha256"
+       "fmt"
+       "net/url"
+       "os"
+       "os/exec"
+
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/lib/crunchrun"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       check "gopkg.in/check.v1"
+)
+
+func (s *ClientSuite) TestShellGatewayNotAvailable(c *check.C) {
+       var stdout, stderr bytes.Buffer
+       cmd := exec.Command("go", "run", ".", "shell", arvadostest.QueuedContainerUUID, "-o", "controlpath=none", "echo", "ok")
+       cmd.Env = append(cmd.Env, os.Environ()...)
+       cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.ActiveTokenV2)
+       cmd.Stdout = &stdout
+       cmd.Stderr = &stderr
+       c.Check(cmd.Run(), check.NotNil)
+       c.Log(stderr.String())
+       c.Check(stderr.String(), check.Matches, `(?ms).*gateway is not available, container is queued.*`)
+}
+
+func (s *ClientSuite) TestShellGateway(c *check.C) {
+       defer func() {
+               c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+       }()
+       uuid := arvadostest.QueuedContainerUUID
+       h := hmac.New(sha256.New, []byte(arvadostest.SystemRootToken))
+       fmt.Fprint(h, uuid)
+       authSecret := fmt.Sprintf("%x", h.Sum(nil))
+       dcid := "theperthcountyconspiracy"
+       gw := crunchrun.Gateway{
+               DockerContainerID: &dcid,
+               ContainerUUID:     uuid,
+               Address:           "0.0.0.0:0",
+               AuthSecret:        authSecret,
+       }
+       err := gw.Start()
+       c.Assert(err, check.IsNil)
+
+       rpcconn := rpc.NewConn("",
+               &url.URL{
+                       Scheme: "https",
+                       Host:   os.Getenv("ARVADOS_API_HOST"),
+               },
+               true,
+               func(context.Context) ([]string, error) {
+                       return []string{arvadostest.SystemRootToken}, nil
+               })
+       _, err = rpcconn.ContainerUpdate(context.TODO(), arvados.UpdateOptions{UUID: uuid, Attrs: map[string]interface{}{
+               "state": arvados.ContainerStateLocked,
+       }})
+       c.Assert(err, check.IsNil)
+       _, err = rpcconn.ContainerUpdate(context.TODO(), arvados.UpdateOptions{UUID: uuid, Attrs: map[string]interface{}{
+               "state":           arvados.ContainerStateRunning,
+               "gateway_address": gw.Address,
+       }})
+       c.Assert(err, check.IsNil)
+
+       var stdout, stderr bytes.Buffer
+       cmd := exec.Command("go", "run", ".", "shell", uuid, "-o", "controlpath=none", "-o", "userknownhostsfile="+c.MkDir()+"/known_hosts", "echo", "ok")
+       cmd.Env = append(cmd.Env, os.Environ()...)
+       cmd.Env = append(cmd.Env, "ARVADOS_API_TOKEN="+arvadostest.ActiveTokenV2)
+       cmd.Stdout = &stdout
+       cmd.Stderr = &stderr
+       c.Check(cmd.Run(), check.NotNil)
+       c.Log(stderr.String())
+       c.Check(stderr.String(), check.Matches, `(?ms).*(No such container: theperthcountyconspiracy|exec: \"docker\": executable file not found in \$PATH).*`)
+}
index 8317a744a705e614051f16f6f3c908ff85329e31..e0324f6b86d7bbdcf4b4a7525f85f9735654c2ef 100644 (file)
@@ -39,6 +39,15 @@ h2(#main). development main (as of 2020-12-10)
 
 "Upgrading from 2.1.0":#v2_1_0
 
+h3. New proxy parameters for arvados-controller
+
+In your Nginx configuration file (@/etc/nginx/conf.d/arvados-api-and-controller.conf@), add the following lines to the @location /@ block with @http://controller@ (see "Update nginx configuration":{{site.baseurl}}/install/install-api-server.html#update-nginx for an example) and reload/restart Nginx (@sudo nginx -s reload@).
+
+<pre>
+    proxy_set_header      Upgrade           $http_upgrade;
+    proxy_set_header      Connection        "upgrade";
+</pre>
+
 h3. Changes on the collection's @preserve_version@ attribute semantics
 
 The @preserve_version@ attribute on collections was originally designed to allow clients to persist a preexisting collection version. This forced clients to make 2 requests if the intention is to "make this set of changes in a new version that will be kept", so we have changed the semantics to do just that: When passing @preserve_version=true@ along with other collection updates, the current version is persisted and also the newly created one will be persisted on the next update.
index 8a7ebc36e5b33613fc6947c88f833ad093aaf351..096a1fcaa9b628e6d5907ac33e8c6625f1114fd7 100644 (file)
@@ -57,6 +57,8 @@ Generally this will contain additional keys that are not present in any correspo
 |auth_uuid|string|UUID of a token to be passed into the container itself, used to access Keep-backed mounts, etc.  Automatically assigned.|Null if state∉{"Locked","Running"} or if @runtime_token@ was provided.|
 |locked_by_uuid|string|UUID of a token, indicating which dispatch process changed state to Locked. If null, any token can be used to lock. If not null, only the indicated token can modify this container.|Null if state∉{"Locked","Running"}|
 |runtime_token|string|A v2 token to be passed into the container itself, used to access Keep-backed mounts, etc.|Not returned in API responses.  Reset to null when state is "Complete" or "Cancelled".|
+|gateway_address|string|Address (host:port) of gateway server.|Internal use only.|
+|interactive_session_started|boolean|Indicates whether @arvados-client shell@ has been used to run commands in the container, which may have altered the container's behavior and output.||
 
 h2(#container_states). Container states
 
index ca55be53e332f12196b359b17a711e5b13c495fc..7d0353c9e71283617e0bb6c5e53c1de89204663b 100644 (file)
@@ -153,11 +153,13 @@ server {
     proxy_connect_timeout 90s;
     proxy_read_timeout    300s;
 
-    proxy_set_header      X-Forwarded-Proto https;
-    proxy_set_header      Host $http_host;
+    proxy_set_header      Host              $http_host;
+    proxy_set_header      Upgrade           $http_upgrade;
+    proxy_set_header      Connection        "upgrade";
     proxy_set_header      X-External-Client $external_client;
-    proxy_set_header      X-Real-IP $remote_addr;
-    proxy_set_header      X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header      X-Forwarded-For   $proxy_add_x_forwarded_for;
+    proxy_set_header      X-Forwarded-Proto https;
+    proxy_set_header      X-Real-IP         $remote_addr;
   }
 }
 
diff --git a/go.mod b/go.mod
index 262978d9125d412b32bfee22508bcfe517de8ec6..88dcb86c72e35cafcf61905f7d67ebb554026b0d 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
        github.com/bradleypeabody/godap v0.0.0-20170216002349-c249933bc092
        github.com/coreos/go-oidc v2.1.0+incompatible
        github.com/coreos/go-systemd v0.0.0-20180108085132-cc4f39464dc7
+       github.com/creack/pty v1.1.7
        github.com/dnaeon/go-vcr v1.0.1 // indirect
        github.com/docker/distribution v2.6.0-rc.1.0.20180105232752-277ed486c948+incompatible // indirect
        github.com/docker/docker v1.4.2-0.20180109013817-94b8a116fbf1
@@ -33,6 +34,7 @@ require (
        github.com/go-asn1-ber/asn1-ber v1.4.1 // indirect
        github.com/go-ldap/ldap v3.0.3+incompatible
        github.com/gogo/protobuf v1.1.1
+       github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
        github.com/gorilla/context v1.1.1 // indirect
        github.com/gorilla/mux v1.6.1-0.20180107155708-5bbbb5b2b572
        github.com/hashicorp/golang-lru v0.5.1
diff --git a/go.sum b/go.sum
index 85d205112fb95ecf1895d96122686e0e2e2a849b..91b5689eb33424ce130f8c06400c74da002e585e 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -72,6 +72,8 @@ github.com/coreos/go-oidc v2.1.0+incompatible h1:sdJrfw8akMnCuUlaZU3tE/uYXFgfqom
 github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
 github.com/coreos/go-systemd v0.0.0-20180108085132-cc4f39464dc7 h1:e3u8KWFMR3irlDo1Z/tL8Hsz1MJmCLkSoX5AZRMKZkg=
 github.com/coreos/go-systemd v0.0.0-20180108085132-cc4f39464dc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -134,6 +136,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
index 771dc2ee799584e4853c5e59c1769bd46bf44509..f62c483f46748310ce4c97b3fc82b6399da3b465 100644 (file)
@@ -868,6 +868,26 @@ Clusters:
         # period.
         LogUpdateSize: 32MiB
 
+      ShellAccess:
+        # An admin user can use "arvados-client shell" to start an
+        # interactive shell (with any user ID) in any running
+        # container.
+        Admin: false
+
+        # Any user can use "arvados-client shell" to start an
+        # interactive shell (with any user ID) in any running
+        # container that they started, provided it isn't also
+        # associated with a different user's container request.
+        #
+        # Interactive sessions make it easy to alter the container's
+        # runtime environment in ways that aren't recorded or
+        # reproducible. Consider the implications for automatic
+        # container reuse before enabling and using this feature. In
+        # particular, note that starting an interactive session does
+        # not disqualify a container from being reused by a different
+        # user/workflow in the future.
+        User: false
+
       SLURM:
         PrioritySpread: 0
         SbatchArgumentsList: []
index e4917032ffe06ca72d5a016fe7cd747a4cc12e02..2d666e638bea225d5ec1f4e1dc974a6695481494 100644 (file)
@@ -121,6 +121,9 @@ var whitelist = map[string]bool{
        "Containers.MaxRetryAttempts":                  true,
        "Containers.MinRetryPeriod":                    true,
        "Containers.ReserveExtraRAM":                   true,
+       "Containers.ShellAccess":                       true,
+       "Containers.ShellAccess.Admin":                 true,
+       "Containers.ShellAccess.User":                  true,
        "Containers.SLURM":                             false,
        "Containers.StaleLockTimeout":                  false,
        "Containers.SupportedDockerImageFormats":       true,
index a202a540476ed54a36097244005c24822503ccf2..df4b02862210ce8a574cb3e5bbb05314e5cce386 100644 (file)
@@ -874,6 +874,26 @@ Clusters:
         # period.
         LogUpdateSize: 32MiB
 
+      ShellAccess:
+        # An admin user can use "arvados-client shell" to start an
+        # interactive shell (with any user ID) in any running
+        # container.
+        Admin: false
+
+        # Any user can use "arvados-client shell" to start an
+        # interactive shell (with any user ID) in any running
+        # container that they started, provided it isn't also
+        # associated with a different user's container request.
+        #
+        # Interactive sessions make it easy to alter the container's
+        # runtime environment in ways that aren't recorded or
+        # reproducible. Consider the implications for automatic
+        # container reuse before enabling and using this feature. In
+        # particular, note that starting an interactive session does
+        # not disqualify a container from being reused by a different
+        # user/workflow in the future.
+        User: false
+
       SLURM:
         PrioritySpread: 0
         SbatchArgumentsList: []
index 00523c7826a74331ea8c1560013c40aebca86f3d..b86266d67e6f02c170deb631d32c777ecb072781 100644 (file)
@@ -336,6 +336,10 @@ func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOption
        return conn.chooseBackend(options.UUID).ContainerUnlock(ctx, options)
 }
 
+func (conn *Conn) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (arvados.ContainerSSHConnection, error) {
+       return conn.chooseBackend(options.UUID).ContainerSSH(ctx, options)
+}
+
 func (conn *Conn) ContainerRequestList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerRequestList, error) {
        return conn.generated_ContainerRequestList(ctx, options)
 }
index b04757ac338fc1549f38daf960c07381803442dd..5f6fb192e1731a75b9052e9096c9a04dab6ddd99 100644 (file)
@@ -25,6 +25,7 @@ import (
        "git.arvados.org/arvados.git/sdk/go/health"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
        "github.com/jmoiron/sqlx"
+
        // sqlx needs lib/pq to talk to PostgreSQL
        _ "github.com/lib/pq"
 )
@@ -100,6 +101,7 @@ func (h *Handler) setup() {
                mux.Handle("/arvados/v1/collections/", rtr)
                mux.Handle("/arvados/v1/users", rtr)
                mux.Handle("/arvados/v1/users/", rtr)
+               mux.Handle("/arvados/v1/connect/", rtr)
                mux.Handle("/arvados/v1/container_requests", rtr)
                mux.Handle("/arvados/v1/container_requests/", rtr)
                mux.Handle("/login", rtr)
diff --git a/lib/controller/localdb/container_gateway.go b/lib/controller/localdb/container_gateway.go
new file mode 100644 (file)
index 0000000..e509278
--- /dev/null
@@ -0,0 +1,174 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "bufio"
+       "context"
+       "crypto/hmac"
+       "crypto/sha256"
+       "crypto/tls"
+       "crypto/x509"
+       "errors"
+       "fmt"
+       "net/http"
+       "net/url"
+       "strings"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+)
+
+// ContainerSSH returns a connection to the SSH server in the
+// appropriate crunch-run process on the worker node where the
+// specified container is running.
+//
+// If the returned error is nil, the caller is responsible for closing
+// sshconn.Conn.
+func (conn *Conn) ContainerSSH(ctx context.Context, opts arvados.ContainerSSHOptions) (sshconn arvados.ContainerSSHConnection, err error) {
+       user, err := conn.railsProxy.UserGetCurrent(ctx, arvados.GetOptions{})
+       if err != nil {
+               return
+       }
+       ctr, err := conn.railsProxy.ContainerGet(ctx, arvados.GetOptions{UUID: opts.UUID})
+       if err != nil {
+               return
+       }
+       ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{conn.cluster.SystemRootToken}})
+       if !user.IsAdmin || !conn.cluster.Containers.ShellAccess.Admin {
+               if !conn.cluster.Containers.ShellAccess.User {
+                       err = httpserver.ErrorWithStatus(errors.New("shell access is disabled in config"), http.StatusServiceUnavailable)
+                       return
+               }
+               var crs arvados.ContainerRequestList
+               crs, err = conn.railsProxy.ContainerRequestList(ctxRoot, arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"container_uuid", "=", opts.UUID}}})
+               if err != nil {
+                       return
+               }
+               for _, cr := range crs.Items {
+                       if cr.ModifiedByUserUUID != user.UUID {
+                               err = httpserver.ErrorWithStatus(errors.New("permission denied: container is associated with requests submitted by other users"), http.StatusForbidden)
+                               return
+                       }
+               }
+               if crs.ItemsAvailable != len(crs.Items) {
+                       err = httpserver.ErrorWithStatus(errors.New("incomplete response while checking permission"), http.StatusInternalServerError)
+                       return
+               }
+       }
+
+       switch ctr.State {
+       case arvados.ContainerStateQueued, arvados.ContainerStateLocked:
+               err = httpserver.ErrorWithStatus(fmt.Errorf("gateway is not available, container is %s", strings.ToLower(string(ctr.State))), http.StatusServiceUnavailable)
+               return
+       case arvados.ContainerStateRunning:
+               if ctr.GatewayAddress == "" {
+                       err = httpserver.ErrorWithStatus(errors.New("container is running but gateway is not available"), http.StatusServiceUnavailable)
+                       return
+               }
+       default:
+               err = httpserver.ErrorWithStatus(fmt.Errorf("gateway is not available, container is %s", strings.ToLower(string(ctr.State))), http.StatusGone)
+               return
+       }
+       // crunch-run uses a self-signed / unverifiable TLS
+       // certificate, so we use the following scheme to ensure we're
+       // not talking to a MITM.
+       //
+       // 1. Compute ctrKey = HMAC-SHA256(sysRootToken,ctrUUID) --
+       // this will be the same ctrKey that a-d-c supplied to
+       // crunch-run in the GatewayAuthSecret env var.
+       //
+       // 2. Compute requestAuth = HMAC-SHA256(ctrKey,serverCert) and
+       // send it to crunch-run as the X-Arvados-Authorization
+       // header, proving that we know ctrKey. (Note a MITM cannot
+       // replay the proof to a real crunch-run server, because the
+       // real crunch-run server would have a different cert.)
+       //
+       // 3. Compute respondAuth = HMAC-SHA256(ctrKey,requestAuth)
+       // and ensure the server returns it in the
+       // X-Arvados-Authorization-Response header, proving that the
+       // server knows ctrKey.
+       var requestAuth, respondAuth string
+       netconn, err := tls.Dial("tcp", ctr.GatewayAddress, &tls.Config{
+               InsecureSkipVerify: true,
+               VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+                       if len(rawCerts) == 0 {
+                               return errors.New("no certificate received, cannot compute authorization header")
+                       }
+                       h := hmac.New(sha256.New, []byte(conn.cluster.SystemRootToken))
+                       fmt.Fprint(h, opts.UUID)
+                       authKey := fmt.Sprintf("%x", h.Sum(nil))
+                       h = hmac.New(sha256.New, []byte(authKey))
+                       h.Write(rawCerts[0])
+                       requestAuth = fmt.Sprintf("%x", h.Sum(nil))
+                       h.Reset()
+                       h.Write([]byte(requestAuth))
+                       respondAuth = fmt.Sprintf("%x", h.Sum(nil))
+                       return nil
+               },
+       })
+       if err != nil {
+               err = httpserver.ErrorWithStatus(err, http.StatusBadGateway)
+               return
+       }
+       if respondAuth == "" {
+               err = httpserver.ErrorWithStatus(errors.New("BUG: no respondAuth"), http.StatusInternalServerError)
+               return
+       }
+       bufr := bufio.NewReader(netconn)
+       bufw := bufio.NewWriter(netconn)
+
+       u := url.URL{
+               Scheme: "http",
+               Host:   ctr.GatewayAddress,
+               Path:   "/ssh",
+       }
+       bufw.WriteString("GET " + u.String() + " HTTP/1.1\r\n")
+       bufw.WriteString("Host: " + u.Host + "\r\n")
+       bufw.WriteString("Upgrade: ssh\r\n")
+       bufw.WriteString("X-Arvados-Target-Uuid: " + opts.UUID + "\r\n")
+       bufw.WriteString("X-Arvados-Authorization: " + requestAuth + "\r\n")
+       bufw.WriteString("X-Arvados-Detach-Keys: " + opts.DetachKeys + "\r\n")
+       bufw.WriteString("X-Arvados-Login-Username: " + opts.LoginUsername + "\r\n")
+       bufw.WriteString("\r\n")
+       bufw.Flush()
+       resp, err := http.ReadResponse(bufr, &http.Request{Method: "GET"})
+       if err != nil {
+               err = httpserver.ErrorWithStatus(fmt.Errorf("error reading http response from gateway: %w", err), http.StatusBadGateway)
+               netconn.Close()
+               return
+       }
+       if resp.Header.Get("X-Arvados-Authorization-Response") != respondAuth {
+               err = httpserver.ErrorWithStatus(errors.New("bad X-Arvados-Authorization-Response header"), http.StatusBadGateway)
+               netconn.Close()
+               return
+       }
+       if strings.ToLower(resp.Header.Get("Upgrade")) != "ssh" ||
+               strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
+               err = httpserver.ErrorWithStatus(errors.New("bad upgrade"), http.StatusBadGateway)
+               netconn.Close()
+               return
+       }
+
+       if !ctr.InteractiveSessionStarted {
+               _, err = conn.railsProxy.ContainerUpdate(ctxRoot, arvados.UpdateOptions{
+                       UUID: opts.UUID,
+                       Attrs: map[string]interface{}{
+                               "interactive_session_started": true,
+                       },
+               })
+               if err != nil {
+                       netconn.Close()
+                       return
+               }
+       }
+
+       sshconn.Conn = netconn
+       sshconn.Bufrw = &bufio.ReadWriter{Reader: bufr, Writer: bufw}
+       sshconn.Logger = ctxlog.FromContext(ctx)
+       return
+}
diff --git a/lib/controller/localdb/container_gateway_test.go b/lib/controller/localdb/container_gateway_test.go
new file mode 100644 (file)
index 0000000..aff569b
--- /dev/null
@@ -0,0 +1,172 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+       "crypto/hmac"
+       "crypto/sha256"
+       "fmt"
+       "io"
+       "time"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/lib/crunchrun"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&ContainerGatewaySuite{})
+
+type ContainerGatewaySuite struct {
+       cluster *arvados.Cluster
+       localdb *Conn
+       ctx     context.Context
+       ctrUUID string
+       gw      *crunchrun.Gateway
+}
+
+func (s *ContainerGatewaySuite) TearDownSuite(c *check.C) {
+       // Undo any changes/additions to the user database so they
+       // don't affect subsequent tests.
+       arvadostest.ResetEnv()
+       c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+}
+
+func (s *ContainerGatewaySuite) SetUpSuite(c *check.C) {
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.IsNil)
+       s.cluster, err = cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+       s.localdb = NewConn(s.cluster)
+       s.ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.ActiveTokenV2}})
+
+       s.ctrUUID = arvadostest.QueuedContainerUUID
+
+       h := hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
+       fmt.Fprint(h, s.ctrUUID)
+       authKey := fmt.Sprintf("%x", h.Sum(nil))
+
+       s.gw = &crunchrun.Gateway{
+               DockerContainerID: new(string),
+               ContainerUUID:     s.ctrUUID,
+               AuthSecret:        authKey,
+               Address:           "localhost:0",
+               Log:               ctxlog.TestLogger(c),
+       }
+       c.Assert(s.gw.Start(), check.IsNil)
+       rootctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{s.cluster.SystemRootToken}})
+       _, err = s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
+               UUID: s.ctrUUID,
+               Attrs: map[string]interface{}{
+                       "state": arvados.ContainerStateLocked}})
+       c.Assert(err, check.IsNil)
+       _, err = s.localdb.ContainerUpdate(rootctx, arvados.UpdateOptions{
+               UUID: s.ctrUUID,
+               Attrs: map[string]interface{}{
+                       "state":           arvados.ContainerStateRunning,
+                       "gateway_address": s.gw.Address}})
+       c.Assert(err, check.IsNil)
+}
+
+func (s *ContainerGatewaySuite) SetUpTest(c *check.C) {
+       s.cluster.Containers.ShellAccess.Admin = true
+       s.cluster.Containers.ShellAccess.User = true
+       _, err := arvadostest.DB(c, s.cluster).Exec(`update containers set interactive_session_started=$1 where uuid=$2`, false, s.ctrUUID)
+       c.Check(err, check.IsNil)
+}
+
+func (s *ContainerGatewaySuite) TestConfig(c *check.C) {
+       for _, trial := range []struct {
+               configAdmin bool
+               configUser  bool
+               sendToken   string
+               errorCode   int
+       }{
+               {true, true, arvadostest.ActiveTokenV2, 0},
+               {true, false, arvadostest.ActiveTokenV2, 503},
+               {false, true, arvadostest.ActiveTokenV2, 0},
+               {false, false, arvadostest.ActiveTokenV2, 503},
+               {true, true, arvadostest.AdminToken, 0},
+               {true, false, arvadostest.AdminToken, 0},
+               {false, true, arvadostest.AdminToken, 403},
+               {false, false, arvadostest.AdminToken, 503},
+       } {
+               c.Logf("trial %#v", trial)
+               s.cluster.Containers.ShellAccess.Admin = trial.configAdmin
+               s.cluster.Containers.ShellAccess.User = trial.configUser
+               ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: []string{trial.sendToken}})
+               sshconn, err := s.localdb.ContainerSSH(ctx, arvados.ContainerSSHOptions{UUID: s.ctrUUID})
+               if trial.errorCode == 0 {
+                       if !c.Check(err, check.IsNil) {
+                               continue
+                       }
+                       if !c.Check(sshconn.Conn, check.NotNil) {
+                               continue
+                       }
+                       sshconn.Conn.Close()
+               } else {
+                       c.Check(err, check.NotNil)
+                       err, ok := err.(interface{ HTTPStatus() int })
+                       if c.Check(ok, check.Equals, true) {
+                               c.Check(err.HTTPStatus(), check.Equals, trial.errorCode)
+                       }
+               }
+       }
+}
+
+func (s *ContainerGatewaySuite) TestConnect(c *check.C) {
+       c.Logf("connecting to %s", s.gw.Address)
+       sshconn, err := s.localdb.ContainerSSH(s.ctx, arvados.ContainerSSHOptions{UUID: s.ctrUUID})
+       c.Assert(err, check.IsNil)
+       c.Assert(sshconn.Conn, check.NotNil)
+       defer sshconn.Conn.Close()
+
+       done := make(chan struct{})
+       go func() {
+               defer close(done)
+
+               // Receive text banner
+               buf := make([]byte, 12)
+               _, err := io.ReadFull(sshconn.Conn, buf)
+               c.Check(err, check.IsNil)
+               c.Check(string(buf), check.Equals, "SSH-2.0-Go\r\n")
+
+               // Send text banner
+               _, err = sshconn.Conn.Write([]byte("SSH-2.0-Fake\r\n"))
+               c.Check(err, check.IsNil)
+
+               // Receive binary
+               _, err = io.ReadFull(sshconn.Conn, buf[:4])
+               c.Check(err, check.IsNil)
+               c.Check(buf[:4], check.DeepEquals, []byte{0, 0, 1, 0xfc})
+
+               // If we can get this far into an SSH handshake...
+               c.Log("success, tunnel is working")
+       }()
+       select {
+       case <-done:
+       case <-time.After(time.Second):
+               c.Fail()
+       }
+       ctr, err := s.localdb.ContainerGet(s.ctx, arvados.GetOptions{UUID: s.ctrUUID})
+       c.Check(err, check.IsNil)
+       c.Check(ctr.InteractiveSessionStarted, check.Equals, true)
+}
+
+func (s *ContainerGatewaySuite) TestConnectFail(c *check.C) {
+       c.Log("trying with no token")
+       ctx := auth.NewContext(context.Background(), &auth.Credentials{})
+       _, err := s.localdb.ContainerSSH(ctx, arvados.ContainerSSHOptions{UUID: s.ctrUUID})
+       c.Check(err, check.ErrorMatches, `.* 401 .*`)
+
+       c.Log("trying with anonymous token")
+       ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{arvadostest.AnonymousToken}})
+       _, err = s.localdb.ContainerSSH(ctx, arvados.ContainerSSHOptions{UUID: s.ctrUUID})
+       c.Check(err, check.ErrorMatches, `.* 404 .*`)
+}
index 9fb2a0d32b49b5af5c6337cc2187c94b7d86a991..83c89d322ab3d85aa31fff177b92115efa469ed8 100644 (file)
@@ -221,6 +221,13 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
                        },
                },
+               {
+                       arvados.EndpointContainerSSH,
+                       func() interface{} { return &arvados.ContainerSSHOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.ContainerSSH(ctx, *opts.(*arvados.ContainerSSHOptions))
+                       },
+               },
                {
                        arvados.EndpointSpecimenCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
index d9d24260bbc98e617b62083f2bb3dd899d59995a..3a19f4ab5ad50d2ad5ceb5fbdf9981108bf1213f 100644 (file)
@@ -5,6 +5,7 @@
 package rpc
 
 import (
+       "bufio"
        "bytes"
        "context"
        "crypto/tls"
@@ -12,6 +13,7 @@ import (
        "errors"
        "fmt"
        "io"
+       "io/ioutil"
        "net"
        "net/http"
        "net/url"
@@ -21,6 +23,7 @@ import (
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
 )
 
 const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
@@ -302,6 +305,82 @@ func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOption
        return resp, err
 }
 
+// ContainerSSH returns a connection to the out-of-band SSH server for
+// a running container. If the returned error is nil, the caller is
+// responsible for closing sshconn.Conn.
+func (conn *Conn) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (sshconn arvados.ContainerSSHConnection, err error) {
+       addr := conn.baseURL.Host
+       if strings.Index(addr, ":") < 1 || (strings.Contains(addr, "::") && addr[0] != '[') {
+               // hostname or ::1 or 1::1
+               addr = net.JoinHostPort(addr, "https")
+       }
+       insecure := false
+       if tlsconf := conn.httpClient.Transport.(*http.Transport).TLSClientConfig; tlsconf != nil && tlsconf.InsecureSkipVerify {
+               insecure = true
+       }
+       netconn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: insecure})
+       if err != nil {
+               err = fmt.Errorf("tls.Dial: %w", err)
+               return
+       }
+       defer func() {
+               if err != nil {
+                       netconn.Close()
+               }
+       }()
+       bufr := bufio.NewReader(netconn)
+       bufw := bufio.NewWriter(netconn)
+
+       u, err := conn.baseURL.Parse("/" + strings.Replace(arvados.EndpointContainerSSH.Path, "{uuid}", options.UUID, -1))
+       if err != nil {
+               err = fmt.Errorf("tls.Dial: %w", err)
+               return
+       }
+       u.RawQuery = url.Values{
+               "detach_keys":    {options.DetachKeys},
+               "login_username": {options.LoginUsername},
+       }.Encode()
+       tokens, err := conn.tokenProvider(ctx)
+       if err != nil {
+               return
+       } else if len(tokens) < 1 {
+               err = httpserver.ErrorWithStatus(errors.New("unauthorized"), http.StatusUnauthorized)
+               return
+       }
+       bufw.WriteString("GET " + u.String() + " HTTP/1.1\r\n")
+       bufw.WriteString("Authorization: Bearer " + tokens[0] + "\r\n")
+       bufw.WriteString("Host: " + u.Host + "\r\n")
+       bufw.WriteString("Upgrade: ssh\r\n")
+       bufw.WriteString("\r\n")
+       bufw.Flush()
+       resp, err := http.ReadResponse(bufr, &http.Request{Method: "GET"})
+       if err != nil {
+               err = fmt.Errorf("http.ReadResponse: %w", err)
+               return
+       }
+       if resp.StatusCode != http.StatusSwitchingProtocols {
+               defer resp.Body.Close()
+               body, _ := ioutil.ReadAll(resp.Body)
+               var message string
+               var errDoc httpserver.ErrorResponse
+               if err := json.Unmarshal(body, &errDoc); err == nil {
+                       message = strings.Join(errDoc.Errors, "; ")
+               } else {
+                       message = fmt.Sprintf("%q", body)
+               }
+               err = fmt.Errorf("server did not provide a tunnel: %s (HTTP %d)", message, resp.StatusCode)
+               return
+       }
+       if strings.ToLower(resp.Header.Get("Upgrade")) != "ssh" ||
+               strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
+               err = fmt.Errorf("bad response from server: Upgrade %q Connection %q", resp.Header.Get("Upgrade"), resp.Header.Get("Connection"))
+               return
+       }
+       sshconn.Conn = netconn
+       sshconn.Bufrw = &bufio.ReadWriter{Reader: bufr, Writer: bufw}
+       return
+}
+
 func (conn *Conn) ContainerRequestCreate(ctx context.Context, options arvados.CreateOptions) (arvados.ContainerRequest, error) {
        ep := arvados.EndpointContainerRequestCreate
        var resp arvados.ContainerRequest
diff --git a/lib/crunchrun/container_gateway.go b/lib/crunchrun/container_gateway.go
new file mode 100644 (file)
index 0000000..1116c4b
--- /dev/null
@@ -0,0 +1,335 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package crunchrun
+
+import (
+       "crypto/hmac"
+       "crypto/rand"
+       "crypto/rsa"
+       "crypto/sha256"
+       "crypto/tls"
+       "fmt"
+       "io"
+       "net"
+       "net/http"
+       "os"
+       "os/exec"
+       "sync"
+       "syscall"
+
+       "git.arvados.org/arvados.git/lib/selfsigned"
+       "git.arvados.org/arvados.git/sdk/go/httpserver"
+       "github.com/creack/pty"
+       "github.com/google/shlex"
+       "golang.org/x/crypto/ssh"
+)
+
+type Gateway struct {
+       DockerContainerID *string
+       ContainerUUID     string
+       Address           string // listen host:port; if port=0, Start() will change it to the selected port
+       AuthSecret        string
+       Log               interface {
+               Printf(fmt string, args ...interface{})
+       }
+
+       sshConfig   ssh.ServerConfig
+       requestAuth string
+       respondAuth string
+}
+
+// startGatewayServer starts an http server that allows authenticated
+// clients to open an interactive "docker exec" session and (in
+// future) connect to tcp ports inside the docker container.
+func (gw *Gateway) Start() error {
+       gw.sshConfig = ssh.ServerConfig{
+               NoClientAuth: true,
+               PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
+                       if c.User() == "_" {
+                               return nil, nil
+                       } else {
+                               return nil, fmt.Errorf("cannot specify user %q via ssh client", c.User())
+                       }
+               },
+               PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
+                       if c.User() == "_" {
+                               return &ssh.Permissions{
+                                       Extensions: map[string]string{
+                                               "pubkey-fp": ssh.FingerprintSHA256(pubKey),
+                                       },
+                               }, nil
+                       } else {
+                               return nil, fmt.Errorf("cannot specify user %q via ssh client", c.User())
+                       }
+               },
+       }
+       pvt, err := rsa.GenerateKey(rand.Reader, 2048)
+       if err != nil {
+               return err
+       }
+       err = pvt.Validate()
+       if err != nil {
+               return err
+       }
+       signer, err := ssh.NewSignerFromKey(pvt)
+       if err != nil {
+               return err
+       }
+       gw.sshConfig.AddHostKey(signer)
+
+       // Address (typically provided by arvados-dispatch-cloud) is
+       // HOST:PORT where HOST is our IP address or hostname as seen
+       // from arvados-controller, and PORT is either the desired
+       // port where we should run our gateway server, or "0" if we
+       // should choose an available port.
+       host, port, err := net.SplitHostPort(gw.Address)
+       if err != nil {
+               return err
+       }
+       cert, err := selfsigned.CertGenerator{}.Generate()
+       if err != nil {
+               return err
+       }
+       h := hmac.New(sha256.New, []byte(gw.AuthSecret))
+       h.Write(cert.Certificate[0])
+       gw.requestAuth = fmt.Sprintf("%x", h.Sum(nil))
+       h.Reset()
+       h.Write([]byte(gw.requestAuth))
+       gw.respondAuth = fmt.Sprintf("%x", h.Sum(nil))
+
+       srv := &httpserver.Server{
+               Server: http.Server{
+                       Handler: http.HandlerFunc(gw.handleSSH),
+                       TLSConfig: &tls.Config{
+                               Certificates: []tls.Certificate{cert},
+                       },
+               },
+               Addr: ":" + port,
+       }
+       err = srv.Start()
+       if err != nil {
+               return err
+       }
+       // Get the port number we are listening on (the port might be
+       // "0" or a port name, in which case this will be different).
+       _, port, err = net.SplitHostPort(srv.Addr)
+       if err != nil {
+               return err
+       }
+       // When changing state to Running, we will set
+       // gateway_address to "HOST:PORT" where HOST is our
+       // external hostname/IP as provided by arvados-dispatch-cloud,
+       // and PORT is the port number we ended up listening on.
+       gw.Address = net.JoinHostPort(host, port)
+       return nil
+}
+
+// handleSSH connects to an SSH server that allows the caller to run
+// interactive commands as root (or any other desired user) inside the
+// container. The tunnel itself can only be created by an
+// authenticated caller, so the SSH server itself is wide open (any
+// password or key will be accepted).
+//
+// Requests must have path "/ssh" and the following headers:
+//
+// Connection: upgrade
+// Upgrade: ssh
+// X-Arvados-Target-Uuid: uuid of container
+// X-Arvados-Authorization: must match
+// hmac(AuthSecret,certfingerprint) (this prevents other containers
+// and shell nodes from connecting directly)
+//
+// Optional headers:
+//
+// X-Arvados-Detach-Keys: argument to "docker exec --detach-keys",
+// e.g., "ctrl-p,ctrl-q"
+// X-Arvados-Login-Username: argument to "docker exec --user": account
+// used to run command(s) inside the container.
+func (gw *Gateway) handleSSH(w http.ResponseWriter, req *http.Request) {
+       // In future we'll handle browser traffic too, but for now the
+       // only traffic we expect is an SSH tunnel from
+       // (*lib/controller/localdb.Conn)ContainerSSH()
+       if req.Method != "GET" || req.Header.Get("Upgrade") != "ssh" {
+               http.Error(w, "path not found", http.StatusNotFound)
+               return
+       }
+       if want := req.Header.Get("X-Arvados-Target-Uuid"); want != gw.ContainerUUID {
+               http.Error(w, fmt.Sprintf("misdirected request: meant for %q but received by crunch-run %q", want, gw.ContainerUUID), http.StatusBadGateway)
+               return
+       }
+       if req.Header.Get("X-Arvados-Authorization") != gw.requestAuth {
+               http.Error(w, "bad X-Arvados-Authorization header", http.StatusUnauthorized)
+               return
+       }
+       detachKeys := req.Header.Get("X-Arvados-Detach-Keys")
+       username := req.Header.Get("X-Arvados-Login-Username")
+       if username == "" {
+               username = "root"
+       }
+       hj, ok := w.(http.Hijacker)
+       if !ok {
+               http.Error(w, "ResponseWriter does not support connection upgrade", http.StatusInternalServerError)
+               return
+       }
+       netconn, _, err := hj.Hijack()
+       if !ok {
+               http.Error(w, err.Error(), http.StatusInternalServerError)
+               return
+       }
+       defer netconn.Close()
+       w.Header().Set("Connection", "upgrade")
+       w.Header().Set("Upgrade", "ssh")
+       w.Header().Set("X-Arvados-Authorization-Response", gw.respondAuth)
+       netconn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n"))
+       w.Header().Write(netconn)
+       netconn.Write([]byte("\r\n"))
+
+       ctx := req.Context()
+
+       conn, newchans, reqs, err := ssh.NewServerConn(netconn, &gw.sshConfig)
+       if err != nil {
+               gw.Log.Printf("ssh.NewServerConn: %s", err)
+               return
+       }
+       defer conn.Close()
+       go ssh.DiscardRequests(reqs)
+       for newch := range newchans {
+               if newch.ChannelType() != "session" {
+                       newch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unsupported channel type %q", newch.ChannelType()))
+                       continue
+               }
+               ch, reqs, err := newch.Accept()
+               if err != nil {
+                       gw.Log.Printf("accept channel: %s", err)
+                       return
+               }
+               var pty0, tty0 *os.File
+               go func() {
+                       // Where to send errors/messages for the
+                       // client to see
+                       logw := io.Writer(ch.Stderr())
+                       // How to end lines when sending
+                       // errors/messages to the client (changes to
+                       // \r\n when using a pty)
+                       eol := "\n"
+                       // Env vars to add to child process
+                       termEnv := []string(nil)
+                       for req := range reqs {
+                               ok := false
+                               switch req.Type {
+                               case "shell", "exec":
+                                       ok = true
+                                       var payload struct {
+                                               Command string
+                                       }
+                                       ssh.Unmarshal(req.Payload, &payload)
+                                       execargs, err := shlex.Split(payload.Command)
+                                       if err != nil {
+                                               fmt.Fprintf(logw, "error parsing supplied command: %s"+eol, err)
+                                               return
+                                       }
+                                       if len(execargs) == 0 {
+                                               execargs = []string{"/bin/bash", "-login"}
+                                       }
+                                       go func() {
+                                               cmd := exec.CommandContext(ctx, "docker", "exec", "-i", "--detach-keys="+detachKeys, "--user="+username)
+                                               cmd.Stdin = ch
+                                               cmd.Stdout = ch
+                                               cmd.Stderr = ch.Stderr()
+                                               if tty0 != nil {
+                                                       cmd.Args = append(cmd.Args, "-t")
+                                                       cmd.Stdin = tty0
+                                                       cmd.Stdout = tty0
+                                                       cmd.Stderr = tty0
+                                                       var wg sync.WaitGroup
+                                                       defer wg.Wait()
+                                                       wg.Add(2)
+                                                       go func() { io.Copy(ch, pty0); wg.Done() }()
+                                                       go func() { io.Copy(pty0, ch); wg.Done() }()
+                                                       // Send our own debug messages to tty as well.
+                                                       logw = tty0
+                                               }
+                                               cmd.Args = append(cmd.Args, *gw.DockerContainerID)
+                                               cmd.Args = append(cmd.Args, execargs...)
+                                               cmd.SysProcAttr = &syscall.SysProcAttr{
+                                                       Setctty: tty0 != nil,
+                                                       Setsid:  true,
+                                               }
+                                               cmd.Env = append(os.Environ(), termEnv...)
+                                               err := cmd.Run()
+                                               var resp struct {
+                                                       Status uint32
+                                               }
+                                               if exiterr, ok := err.(*exec.ExitError); ok {
+                                                       if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
+                                                               resp.Status = uint32(status.ExitStatus())
+                                                       }
+                                               } else if err != nil {
+                                                       // Propagate errors like `exec: "docker": executable file not found in $PATH`
+                                                       fmt.Fprintln(ch.Stderr(), err)
+                                               }
+                                               errClose := ch.CloseWrite()
+                                               if resp.Status == 0 && (err != nil || errClose != nil) {
+                                                       resp.Status = 1
+                                               }
+                                               ch.SendRequest("exit-status", false, ssh.Marshal(&resp))
+                                               ch.Close()
+                                       }()
+                               case "pty-req":
+                                       eol = "\r\n"
+                                       p, t, err := pty.Open()
+                                       if err != nil {
+                                               fmt.Fprintf(ch.Stderr(), "pty failed: %s"+eol, err)
+                                               break
+                                       }
+                                       defer p.Close()
+                                       defer t.Close()
+                                       pty0, tty0 = p, t
+                                       ok = true
+                                       var payload struct {
+                                               Term string
+                                               Cols uint32
+                                               Rows uint32
+                                               X    uint32
+                                               Y    uint32
+                                       }
+                                       ssh.Unmarshal(req.Payload, &payload)
+                                       termEnv = []string{"TERM=" + payload.Term, "USE_TTY=1"}
+                                       err = pty.Setsize(pty0, &pty.Winsize{Rows: uint16(payload.Rows), Cols: uint16(payload.Cols), X: uint16(payload.X), Y: uint16(payload.Y)})
+                                       if err != nil {
+                                               fmt.Fprintf(logw, "pty-req: setsize failed: %s"+eol, err)
+                                       }
+                               case "window-change":
+                                       var payload struct {
+                                               Cols uint32
+                                               Rows uint32
+                                               X    uint32
+                                               Y    uint32
+                                       }
+                                       ssh.Unmarshal(req.Payload, &payload)
+                                       err := pty.Setsize(pty0, &pty.Winsize{Rows: uint16(payload.Rows), Cols: uint16(payload.Cols), X: uint16(payload.X), Y: uint16(payload.Y)})
+                                       if err != nil {
+                                               fmt.Fprintf(logw, "window-change: setsize failed: %s"+eol, err)
+                                               break
+                                       }
+                                       ok = true
+                               case "env":
+                                       // TODO: implement "env"
+                                       // requests by setting env
+                                       // vars in the docker-exec
+                                       // command (not docker-exec's
+                                       // own environment, which
+                                       // would be a gaping security
+                                       // hole).
+                               default:
+                                       // fmt.Fprintf(logw, "declining %q req"+eol, req.Type)
+                               }
+                               if req.WantReply {
+                                       req.Reply(ok, nil)
+                               }
+                       }
+               }()
+       }
+}
index 6e7e6feba5715bdd6c3528f9f97c328826036647..7d6fb4ed47bef547f4eb3cb1728163c77021bf02 100644 (file)
@@ -178,6 +178,8 @@ type ContainerRunner struct {
        arvMountLog   *ThrottledLogger
 
        containerWatchdogInterval time.Duration
+
+       gateway Gateway
 }
 
 // setupSignals sets up signal handling to gracefully terminate the underlying
@@ -1478,7 +1480,7 @@ func (runner *ContainerRunner) UpdateContainerRunning() error {
                return ErrCancelled
        }
        return runner.DispatcherArvClient.Update("containers", runner.Container.UUID,
-               arvadosclient.Dict{"container": arvadosclient.Dict{"state": "Running"}}, nil)
+               arvadosclient.Dict{"container": arvadosclient.Dict{"state": "Running", "gateway_address": runner.gateway.Address}}, nil)
 }
 
 // ContainerToken returns the api_token the container (and any
@@ -1877,6 +1879,20 @@ func (command) RunCommand(prog string, args []string, stdin io.Reader, stdout, s
                return 1
        }
 
+       cr.gateway = Gateway{
+               Address:           os.Getenv("GatewayAddress"),
+               AuthSecret:        os.Getenv("GatewayAuthSecret"),
+               ContainerUUID:     containerID,
+               DockerContainerID: &cr.ContainerID,
+               Log:               cr.CrunchLog,
+       }
+       os.Unsetenv("GatewayAuthSecret")
+       err = cr.gateway.Start()
+       if err != nil {
+               log.Printf("error starting gateway server: %s", err)
+               return 1
+       }
+
        parentTemp, tmperr := cr.MkTempDir("", "crunch-run."+containerID+".")
        if tmperr != nil {
                log.Printf("%s: %v", containerID, tmperr)
index a25ed60150718f83829a003d6b0e8267a382a430..6a74280ca452e9b365f6a976f96e04ef03edc7e6 100644 (file)
@@ -5,8 +5,10 @@
 package worker
 
 import (
+       "crypto/hmac"
        "crypto/md5"
        "crypto/rand"
+       "crypto/sha256"
        "errors"
        "fmt"
        "io"
@@ -116,6 +118,7 @@ func NewPool(logger logrus.FieldLogger, arvClient *arvados.Client, reg *promethe
                timeoutTERM:                    duration(cluster.Containers.CloudVMs.TimeoutTERM, defaultTimeoutTERM),
                timeoutSignal:                  duration(cluster.Containers.CloudVMs.TimeoutSignal, defaultTimeoutSignal),
                timeoutStaleRunLock:            duration(cluster.Containers.CloudVMs.TimeoutStaleRunLock, defaultTimeoutStaleRunLock),
+               systemRootToken:                cluster.SystemRootToken,
                installPublicKey:               installPublicKey,
                tagKeyPrefix:                   cluster.Containers.CloudVMs.TagKeyPrefix,
                stop:                           make(chan bool),
@@ -154,6 +157,7 @@ type Pool struct {
        timeoutTERM                    time.Duration
        timeoutSignal                  time.Duration
        timeoutStaleRunLock            time.Duration
+       systemRootToken                string
        installPublicKey               ssh.PublicKey
        tagKeyPrefix                   string
 
@@ -990,6 +994,12 @@ func (wp *Pool) waitUntilLoaded() {
        }
 }
 
+func (wp *Pool) gatewayAuthSecret(uuid string) string {
+       h := hmac.New(sha256.New, []byte(wp.systemRootToken))
+       fmt.Fprint(h, uuid)
+       return fmt.Sprintf("%x", h.Sum(nil))
+}
+
 // Return a random string of n hexadecimal digits (n*4 random bits). n
 // must be even.
 func randomHex(n int) string {
index 47521213427610a531fa269df32b046b17ba72aa..0fd99aeeef136cdc1113f55466b1342b7c975cc1 100644 (file)
@@ -8,6 +8,7 @@ import (
        "bytes"
        "encoding/json"
        "fmt"
+       "net"
        "syscall"
        "time"
 
@@ -48,6 +49,8 @@ func newRemoteRunner(uuid string, wkr *worker) *remoteRunner {
                "ARVADOS_API_HOST":  wkr.wp.arvClient.APIHost,
                "ARVADOS_API_TOKEN": wkr.wp.arvClient.AuthToken,
                "InstanceType":      instJSON.String(),
+               "GatewayAddress":    net.JoinHostPort(wkr.instance.Address(), "0"),
+               "GatewayAuthSecret": wkr.wp.gatewayAuthSecret(uuid),
        }
        if wkr.wp.arvClient.Insecure {
                env["ARVADOS_API_HOST_INSECURE"] = "1"
diff --git a/lib/selfsigned/cert.go b/lib/selfsigned/cert.go
new file mode 100644 (file)
index 0000000..ae521dd
--- /dev/null
@@ -0,0 +1,76 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package selfsigned
+
+import (
+       "crypto/rand"
+       "crypto/rsa"
+       "crypto/tls"
+       "crypto/x509"
+       "crypto/x509/pkix"
+       "fmt"
+       "math/big"
+       "net"
+       "time"
+)
+
+type CertGenerator struct {
+       Bits  int
+       Hosts []string
+       IsCA  bool
+}
+
+func (gen CertGenerator) Generate() (cert tls.Certificate, err error) {
+       keyUsage := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
+       if gen.IsCA {
+               keyUsage |= x509.KeyUsageCertSign
+       }
+       notBefore := time.Now()
+       notAfter := time.Now().Add(time.Hour * 24 * 365)
+       snMax := new(big.Int).Lsh(big.NewInt(1), 128)
+       sn, err := rand.Int(rand.Reader, snMax)
+       if err != nil {
+               err = fmt.Errorf("Failed to generate serial number: %w", err)
+               return
+       }
+       template := x509.Certificate{
+               SerialNumber: sn,
+               Subject: pkix.Name{
+                       Organization: []string{"N/A"},
+               },
+               NotBefore:             notBefore,
+               NotAfter:              notAfter,
+               KeyUsage:              keyUsage,
+               ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+               BasicConstraintsValid: true,
+               IsCA:                  gen.IsCA,
+       }
+       for _, h := range gen.Hosts {
+               if ip := net.ParseIP(h); ip != nil {
+                       template.IPAddresses = append(template.IPAddresses, ip)
+               } else {
+                       template.DNSNames = append(template.DNSNames, h)
+               }
+       }
+       bits := gen.Bits
+       if bits == 0 {
+               bits = 4096
+       }
+       priv, err := rsa.GenerateKey(rand.Reader, bits)
+       if err != nil {
+               err = fmt.Errorf("error generating key: %w", err)
+               return
+       }
+       certder, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+       if err != nil {
+               err = fmt.Errorf("error creating certificate: %w", err)
+               return
+       }
+       cert = tls.Certificate{
+               Certificate: [][]byte{certder},
+               PrivateKey:  priv,
+       }
+       return
+}
diff --git a/lib/selfsigned/cert_test.go b/lib/selfsigned/cert_test.go
new file mode 100644 (file)
index 0000000..16ed8bd
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package selfsigned
+
+import (
+       "testing"
+)
+
+func TestCert(t *testing.T) {
+       cert, err := CertGenerator{Bits: 1024, Hosts: []string{"localhost"}, IsCA: false}.Generate()
+       if err != nil {
+               t.Error(err)
+       }
+       if len(cert.Certificate) < 1 {
+               t.Error("no certificate!")
+       }
+       cert, err = CertGenerator{Bits: 2048, Hosts: []string{"localhost"}, IsCA: true}.Generate()
+       if err != nil {
+               t.Error(err)
+       }
+       if len(cert.Certificate) < 1 {
+               t.Error("no certificate!")
+       }
+}
index a11872971a9dffc9a2563dedb7338a1438ca40b3..37a3e007b16108cb6a2f9c6627c29106eee52bca 100644 (file)
@@ -5,8 +5,12 @@
 package arvados
 
 import (
+       "bufio"
        "context"
        "encoding/json"
+       "net"
+
+       "github.com/sirupsen/logrus"
 )
 
 type APIEndpoint struct {
@@ -41,6 +45,7 @@ var (
        EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/{uuid}", ""}
        EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/lock", ""}
        EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/{uuid}/unlock", ""}
+       EndpointContainerSSH                  = APIEndpoint{"GET", "arvados/v1/connect/{uuid}/ssh", ""} // move to /containers after #17014 fixes routing
        EndpointContainerRequestCreate        = APIEndpoint{"POST", "arvados/v1/container_requests", "container_request"}
        EndpointContainerRequestUpdate        = APIEndpoint{"PATCH", "arvados/v1/container_requests/{uuid}", "container_request"}
        EndpointContainerRequestGet           = APIEndpoint{"GET", "arvados/v1/container_requests/{uuid}", ""}
@@ -65,6 +70,18 @@ var (
        EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
 )
 
+type ContainerSSHOptions struct {
+       UUID          string `json:"uuid"`
+       DetachKeys    string `json:"detach_keys"`
+       LoginUsername string `json:"login_username"`
+}
+
+type ContainerSSHConnection struct {
+       Conn   net.Conn           `json:"-"`
+       Bufrw  *bufio.ReadWriter  `json:"-"`
+       Logger logrus.FieldLogger `json:"-"`
+}
+
 type GetOptions struct {
        UUID         string   `json:"uuid,omitempty"`
        Select       []string `json:"select"`
@@ -180,6 +197,7 @@ type API interface {
        ContainerDelete(ctx context.Context, options DeleteOptions) (Container, error)
        ContainerLock(ctx context.Context, options GetOptions) (Container, error)
        ContainerUnlock(ctx context.Context, options GetOptions) (Container, error)
+       ContainerSSH(ctx context.Context, options ContainerSSHOptions) (ContainerSSHConnection, error)
        ContainerRequestCreate(ctx context.Context, options CreateOptions) (ContainerRequest, error)
        ContainerRequestUpdate(ctx context.Context, options UpdateOptions) (ContainerRequest, error)
        ContainerRequestGet(ctx context.Context, options GetOptions) (ContainerRequest, error)
index 52c75d5113c2a9399267e90fb8c18c8a5aeeaad7..ea3cb6899e7bd5ac9f92cc0d3127e6aa3a485f13 100644 (file)
@@ -9,6 +9,7 @@ import (
        "context"
        "crypto/tls"
        "encoding/json"
+       "errors"
        "fmt"
        "io"
        "io/ioutil"
@@ -67,6 +68,10 @@ type Client struct {
        dd *DiscoveryDocument
 
        defaultRequestID string
+
+       // APIHost and AuthToken were loaded from ARVADOS_* env vars
+       // (used to customize "no host/token" error messages)
+       loadedFromEnv bool
 }
 
 // InsecureHTTPClient is the default http.Client used by a Client with
@@ -123,6 +128,7 @@ func NewClientFromEnv() *Client {
                Insecure:        insecure,
                KeepServiceURIs: svcs,
                Timeout:         5 * time.Minute,
+               loadedFromEnv:   true,
        }
 }
 
@@ -312,6 +318,13 @@ func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, m
                // Ensure body is closed even if we error out early
                defer body.Close()
        }
+       if c.APIHost == "" {
+               if c.loadedFromEnv {
+                       return errors.New("ARVADOS_API_HOST and/or ARVADOS_API_TOKEN environment variables are not set")
+               } else {
+                       return errors.New("arvados.Client cannot perform request: APIHost is not set")
+               }
+       }
        urlString := c.apiURL(path)
        urlValues, err := anythingToValues(params)
        if err != nil {
index 9dc9e17dd815b1818bc17bb20c074d69982e146d..8222035a330c8212b5c705ffe5160656ec51ab73 100644 (file)
@@ -429,6 +429,10 @@ type ContainersConfig struct {
                LogUpdatePeriod              Duration
                LogUpdateSize                ByteSize
        }
+       ShellAccess struct {
+               Admin bool
+               User  bool
+       }
        SLURM struct {
                PrioritySpread             int64
                SbatchArgumentsList        []string
index 8d65bb40061d88603db66acac16356b90692c7ca..b8530db342d212f3b64e746466db07bf8ffe04df 100644 (file)
@@ -8,28 +8,30 @@ import "time"
 
 // Container is an arvados#container resource.
 type Container struct {
-       UUID                 string                 `json:"uuid"`
-       Etag                 string                 `json:"etag"`
-       CreatedAt            time.Time              `json:"created_at"`
-       ModifiedByClientUUID string                 `json:"modified_by_client_uuid"`
-       ModifiedByUserUUID   string                 `json:"modified_by_user_uuid"`
-       ModifiedAt           time.Time              `json:"modified_at"`
-       Command              []string               `json:"command"`
-       ContainerImage       string                 `json:"container_image"`
-       Cwd                  string                 `json:"cwd"`
-       Environment          map[string]string      `json:"environment"`
-       LockedByUUID         string                 `json:"locked_by_uuid"`
-       Mounts               map[string]Mount       `json:"mounts"`
-       Output               string                 `json:"output"`
-       OutputPath           string                 `json:"output_path"`
-       Priority             int64                  `json:"priority"`
-       RuntimeConstraints   RuntimeConstraints     `json:"runtime_constraints"`
-       State                ContainerState         `json:"state"`
-       SchedulingParameters SchedulingParameters   `json:"scheduling_parameters"`
-       ExitCode             int                    `json:"exit_code"`
-       RuntimeStatus        map[string]interface{} `json:"runtime_status"`
-       StartedAt            *time.Time             `json:"started_at"`  // nil if not yet started
-       FinishedAt           *time.Time             `json:"finished_at"` // nil if not yet finished
+       UUID                      string                 `json:"uuid"`
+       Etag                      string                 `json:"etag"`
+       CreatedAt                 time.Time              `json:"created_at"`
+       ModifiedByClientUUID      string                 `json:"modified_by_client_uuid"`
+       ModifiedByUserUUID        string                 `json:"modified_by_user_uuid"`
+       ModifiedAt                time.Time              `json:"modified_at"`
+       Command                   []string               `json:"command"`
+       ContainerImage            string                 `json:"container_image"`
+       Cwd                       string                 `json:"cwd"`
+       Environment               map[string]string      `json:"environment"`
+       LockedByUUID              string                 `json:"locked_by_uuid"`
+       Mounts                    map[string]Mount       `json:"mounts"`
+       Output                    string                 `json:"output"`
+       OutputPath                string                 `json:"output_path"`
+       Priority                  int64                  `json:"priority"`
+       RuntimeConstraints        RuntimeConstraints     `json:"runtime_constraints"`
+       State                     ContainerState         `json:"state"`
+       SchedulingParameters      SchedulingParameters   `json:"scheduling_parameters"`
+       ExitCode                  int                    `json:"exit_code"`
+       RuntimeStatus             map[string]interface{} `json:"runtime_status"`
+       StartedAt                 *time.Time             `json:"started_at"`  // nil if not yet started
+       FinishedAt                *time.Time             `json:"finished_at"` // nil if not yet finished
+       GatewayAddress            string                 `json:"gateway_address"`
+       InteractiveSessionStarted bool                   `json:"interactive_session_started"`
 }
 
 // ContainerRequest is an arvados#container_request resource.
diff --git a/sdk/go/arvados/container_gateway.go b/sdk/go/arvados/container_gateway.go
new file mode 100644 (file)
index 0000000..00c98d5
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "context"
+       "io"
+       "net/http"
+       "sync"
+
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "github.com/sirupsen/logrus"
+)
+
+func (sshconn ContainerSSHConnection) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+       hj, ok := w.(http.Hijacker)
+       if !ok {
+               http.Error(w, "ResponseWriter does not support connection upgrade", http.StatusInternalServerError)
+               return
+       }
+       w.Header().Set("Connection", "upgrade")
+       w.Header().Set("Upgrade", "ssh")
+       w.WriteHeader(http.StatusSwitchingProtocols)
+       conn, bufrw, err := hj.Hijack()
+       if err != nil {
+               ctxlog.FromContext(req.Context()).WithError(err).Error("error hijacking ResponseWriter")
+               return
+       }
+       defer conn.Close()
+
+       var bytesIn, bytesOut int64
+       var wg sync.WaitGroup
+       ctx, cancel := context.WithCancel(context.Background())
+       wg.Add(1)
+       go func() {
+               defer wg.Done()
+               defer cancel()
+               n, err := io.CopyN(conn, sshconn.Bufrw, int64(sshconn.Bufrw.Reader.Buffered()))
+               bytesOut += n
+               if err == nil {
+                       n, err = io.Copy(conn, sshconn.Conn)
+                       bytesOut += n
+               }
+               if err != nil {
+                       ctxlog.FromContext(req.Context()).WithError(err).Error("error copying downstream")
+               }
+       }()
+       wg.Add(1)
+       go func() {
+               defer wg.Done()
+               defer cancel()
+               n, err := io.CopyN(sshconn.Conn, bufrw, int64(bufrw.Reader.Buffered()))
+               bytesIn += n
+               if err == nil {
+                       n, err = io.Copy(sshconn.Conn, conn)
+                       bytesIn += n
+               }
+               if err != nil {
+                       ctxlog.FromContext(req.Context()).WithError(err).Error("error copying upstream")
+               }
+       }()
+       <-ctx.Done()
+       if sshconn.Logger != nil {
+               go func() {
+                       wg.Wait()
+                       sshconn.Logger.WithFields(logrus.Fields{
+                               "bytesIn":  bytesIn,
+                               "bytesOut": bytesOut,
+                       }).Info("closed connection")
+               }()
+       }
+}
index df3e46febd5598f9d065fba24c964701b480c1d1..930eabf27ef997a2662d0a56c8c5a1494e59a98a 100644 (file)
@@ -105,6 +105,10 @@ func (as *APIStub) ContainerUnlock(ctx context.Context, options arvados.GetOptio
        as.appendCall(ctx, as.ContainerUnlock, options)
        return arvados.Container{}, as.Error
 }
+func (as *APIStub) ContainerSSH(ctx context.Context, options arvados.ContainerSSHOptions) (arvados.ContainerSSHConnection, error) {
+       as.appendCall(ctx, as.ContainerSSH, options)
+       return arvados.ContainerSSHConnection{}, as.Error
+}
 func (as *APIStub) ContainerRequestCreate(ctx context.Context, options arvados.CreateOptions) (arvados.ContainerRequest, error) {
        as.appendCall(ctx, as.ContainerRequestCreate, options)
        return arvados.ContainerRequest{}, as.Error
index 6a0f7d9f49d0820476449998030b951688aab7a6..953021f0e7a18bc622920a19e8d7e73cc764ae13 100644 (file)
@@ -802,6 +802,10 @@ def setup_config():
                         "GitInternalDir": os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'internal.git'),
                     },
                     "SupportedDockerImageFormats": {"v1": {}},
+                    "ShellAccess": {
+                        "Admin": True,
+                        "User": True,
+                    },
                 },
                 "Volumes": {
                     "zzzzz-nyw5e-%015d"%n: {
index d01787cbc7c0389e50fc0ebe421bc9bd0672f5ed..8feee77ff23553eaba0429125c1b06f3f5688d50 100644 (file)
@@ -77,6 +77,8 @@ class Container < ArvadosModel
     t.add :runtime_user_uuid
     t.add :runtime_auth_scopes
     t.add :lock_count
+    t.add :gateway_address
+    t.add :interactive_session_started
   end
 
   # Supported states for a container
@@ -102,11 +104,11 @@ class Container < ArvadosModel
   end
 
   def self.full_text_searchable_columns
-    super - ["secret_mounts", "secret_mounts_md5", "runtime_token"]
+    super - ["secret_mounts", "secret_mounts_md5", "runtime_token", "gateway_address"]
   end
 
   def self.searchable_columns *args
-    super - ["secret_mounts_md5", "runtime_token"]
+    super - ["secret_mounts_md5", "runtime_token", "gateway_address"]
   end
 
   def logged_attributes
@@ -478,7 +480,10 @@ class Container < ArvadosModel
     when Running
       permitted.push :priority, *progress_attrs
       if self.state_changed?
-        permitted.push :started_at
+        permitted.push :started_at, :gateway_address
+      end
+      if !self.interactive_session_started_was
+        permitted.push :interactive_session_started
       end
 
     when Complete
diff --git a/services/api/db/migrate/20210108033940_add_gateway_address_to_containers.rb b/services/api/db/migrate/20210108033940_add_gateway_address_to_containers.rb
new file mode 100644 (file)
index 0000000..8683b51
--- /dev/null
@@ -0,0 +1,9 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class AddGatewayAddressToContainers < ActiveRecord::Migration[5.2]
+  def change
+    add_column :containers, :gateway_address, :string
+  end
+end
diff --git a/services/api/db/migrate/20210126183521_add_interactive_session_started_to_containers.rb b/services/api/db/migrate/20210126183521_add_interactive_session_started_to_containers.rb
new file mode 100644 (file)
index 0000000..3fe23f6
--- /dev/null
@@ -0,0 +1,9 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class AddInteractiveSessionStartedToContainers < ActiveRecord::Migration[5.2]
+  def change
+    add_column :containers, :interactive_session_started, :boolean, null: false, default: false
+  end
+end
index 12a28c6c723609bd14b2c20129b8c749695bdc28..14eca609eb0e35c91215a2d70e5af898d90168d4 100644 (file)
@@ -521,7 +521,9 @@ CREATE TABLE public.containers (
     runtime_user_uuid text,
     runtime_auth_scopes jsonb,
     runtime_token text,
-    lock_count integer DEFAULT 0 NOT NULL
+    lock_count integer DEFAULT 0 NOT NULL,
+    gateway_address character varying,
+    interactive_session_started boolean DEFAULT false NOT NULL
 );
 
 
@@ -3187,6 +3189,8 @@ INSERT INTO "schema_migrations" (version) VALUES
 ('20200914203202'),
 ('20201103170213'),
 ('20201105190435'),
-('20201202174753');
+('20201202174753'),
+('20210108033940'),
+('20210126183521');
 
 
index 773a9a8766a640e90bb50468460c4d1def9c457d..35e2b7ed1d0501d02162e22cd08e3556107b2799 100644 (file)
@@ -803,6 +803,8 @@ class ContainerTest < ActiveSupport::TestCase
     [Container::Running, {priority: 123456789}],
     [Container::Running, {runtime_status: {'error' => 'oops'}}],
     [Container::Running, {cwd: '/'}],
+    [Container::Running, {gateway_address: "172.16.0.1:12345"}],
+    [Container::Running, {interactive_session_started: true}],
     [Container::Complete, {state: Container::Cancelled}],
     [Container::Complete, {priority: 123456789}],
     [Container::Complete, {runtime_status: {'error' => 'oops'}}],