1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
19 "git.arvados.org/arvados.git/sdk/go/httpserver"
20 "github.com/creack/pty"
21 "github.com/google/shlex"
22 "golang.org/x/crypto/ssh"
25 // startGatewayServer starts an http server that allows authenticated
26 // clients to open an interactive "docker exec" session and (in
27 // future) connect to tcp ports inside the docker container.
28 func (runner *ContainerRunner) startGatewayServer() error {
29 runner.gatewaySSHConfig = &ssh.ServerConfig{
31 PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
35 return nil, fmt.Errorf("cannot specify user %q via ssh client", c.User())
38 PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
40 return &ssh.Permissions{
41 Extensions: map[string]string{
42 "pubkey-fp": ssh.FingerprintSHA256(pubKey),
46 return nil, fmt.Errorf("cannot specify user %q via ssh client", c.User())
50 pvt, err := rsa.GenerateKey(rand.Reader, 4096)
58 signer, err := ssh.NewSignerFromKey(pvt)
62 runner.gatewaySSHConfig.AddHostKey(signer)
64 // GatewayAddress (provided by arvados-dispatch-cloud) is
65 // HOST:PORT where HOST is our IP address or hostname as seen
66 // from arvados-controller, and PORT is either the desired
67 // port where we should run our gateway server, or "0" if
68 // we should choose an available port.
69 host, port, err := net.SplitHostPort(os.Getenv("GatewayAddress"))
73 srv := &httpserver.Server{
75 Handler: http.HandlerFunc(runner.handleSSH),
83 // Get the port number we are listening on (the port might be
84 // "0" or a port name, in which case this will be different).
85 _, port, err = net.SplitHostPort(srv.Addr)
89 // When changing state to Running, we will set
90 // gateway_address to "HOST:PORT" where HOST is our
91 // external hostname/IP as provided by arvados-dispatch-cloud,
92 // and PORT is the port number we ended up listening on.
93 runner.gatewayAddress = net.JoinHostPort(host, port)
97 // handleSSH connects to an SSH server that runs commands as root in
98 // the container. The tunnel itself can only be created by an
99 // authenticated caller, so the SSH server itself is wide open (any
100 // password or key will be accepted).
102 // Requests must have path "/ssh" and the following headers:
104 // Connection: upgrade
106 // X-Arvados-Target-Uuid: uuid of container
107 // X-Arvados-Authorization: must match GatewayAuthSecret provided by
108 // a-d-c (this prevents other containers and shell nodes from
109 // connecting directly)
113 // X-Arvados-Detach-Keys: argument to "docker attach --detach-keys",
114 // e.g., "ctrl-p,ctrl-q"
115 func (runner *ContainerRunner) handleSSH(w http.ResponseWriter, req *http.Request) {
116 // In future we'll handle browser traffic too, but for now the
117 // only traffic we expect is an SSH tunnel from
118 // (*lib/controller/localdb.Conn)ContainerSSH()
119 if req.Method != "GET" || req.Header.Get("Upgrade") != "ssh" {
120 http.Error(w, "path not found", http.StatusNotFound)
123 if want := req.Header.Get("X-Arvados-Target-Uuid"); want != runner.Container.UUID {
124 http.Error(w, fmt.Sprintf("misdirected request: meant for %q but received by crunch-run %q", want, runner.Container.UUID), http.StatusBadGateway)
127 if req.Header.Get("X-Arvados-Authorization") != runner.gatewayAuthSecret {
128 http.Error(w, "bad X-Arvados-Authorization header", http.StatusUnauthorized)
131 detachKeys := req.Header.Get("X-Arvados-Detach-Keys")
132 username := req.Header.Get("X-Arvados-Login-Username")
136 hj, ok := w.(http.Hijacker)
138 http.Error(w, "ResponseWriter does not support connection upgrade", http.StatusInternalServerError)
141 netconn, _, err := hj.Hijack()
143 http.Error(w, err.Error(), http.StatusInternalServerError)
146 defer netconn.Close()
147 w.Header().Set("Connection", "upgrade")
148 w.Header().Set("Upgrade", "ssh")
149 netconn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n"))
150 w.Header().Write(netconn)
151 netconn.Write([]byte("\r\n"))
155 conn, newchans, reqs, err := ssh.NewServerConn(netconn, runner.gatewaySSHConfig)
157 runner.CrunchLog.Printf("ssh.NewServerConn: %s", err)
161 go ssh.DiscardRequests(reqs)
162 for newch := range newchans {
163 if newch.ChannelType() != "session" {
164 newch.Reject(ssh.UnknownChannelType, "unknown channel type")
167 ch, reqs, err := newch.Accept()
169 runner.CrunchLog.Printf("accept channel: %s", err)
172 var pty0, tty0 *os.File
176 // Where to send errors/messages for the
178 logw := io.Writer(ch.Stderr())
179 // How to end lines when sending
180 // errors/messages to the client (changes to
181 // \r\n when using a pty)
183 // Env vars to add to child process
184 termEnv := []string(nil)
185 for req := range reqs {
188 case "shell", "exec":
193 ssh.Unmarshal(req.Payload, &payload)
194 execargs, err := shlex.Split(payload.Command)
196 fmt.Fprintf(logw, "error parsing supplied command: %s"+eol, err)
199 if len(execargs) == 0 {
200 execargs = []string{"/bin/bash", "-login"}
203 cmd := exec.CommandContext(ctx, "docker", "exec", "-i", "--detach-keys="+detachKeys, "--user="+username)
206 cmd.Stderr = ch.Stderr()
208 cmd.Args = append(cmd.Args, "-t")
212 var wg sync.WaitGroup
215 go func() { io.Copy(ch, pty0); wg.Done() }()
216 go func() { io.Copy(pty0, ch); wg.Done() }()
217 // Send our own debug messages to tty as well.
220 cmd.Args = append(cmd.Args, runner.ContainerID)
221 cmd.Args = append(cmd.Args, execargs...)
222 cmd.SysProcAttr = &syscall.SysProcAttr{
223 Setctty: tty0 != nil,
226 cmd.Env = append(os.Environ(), termEnv...)
228 errClose := ch.CloseWrite()
232 if err, ok := err.(*exec.ExitError); ok {
233 if status, ok := err.Sys().(syscall.WaitStatus); ok {
234 resp.Status = uint32(status.ExitStatus())
237 if resp.Status == 0 && (err != nil || errClose != nil) {
240 ch.SendRequest("exit-status", false, ssh.Marshal(&resp))
245 p, t, err := pty.Open()
247 fmt.Fprintf(ch.Stderr(), "pty failed: %s"+eol, err)
259 ssh.Unmarshal(req.Payload, &payload)
260 termEnv = []string{"TERM=" + payload.Term, "USE_TTY=1"}
261 err = pty.Setsize(pty0, &pty.Winsize{Rows: uint16(payload.Rows), Cols: uint16(payload.Cols), X: uint16(payload.X), Y: uint16(payload.Y)})
263 fmt.Fprintf(logw, "pty-req: setsize failed: %s"+eol, err)
265 case "window-change":
272 ssh.Unmarshal(req.Payload, &payload)
273 err := pty.Setsize(pty0, &pty.Winsize{Rows: uint16(payload.Rows), Cols: uint16(payload.Cols), X: uint16(payload.X), Y: uint16(payload.Y)})
275 fmt.Fprintf(logw, "window-change: setsize failed: %s"+eol, err)
280 // TODO: implement "env"
281 // requests by setting env
282 // vars in the docker-exec
283 // command (not docker-exec's
284 // own environment, which
285 // would be a gaping security
288 // fmt.Fprintf(logw, "declining %q req"+eol, req.Type)