17170: Add "arvados-client shell" subcommand and backend support.
[arvados.git] / lib / crunchrun / container_gateway.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package crunchrun
6
7 import (
8         "crypto/rand"
9         "crypto/rsa"
10         "fmt"
11         "io"
12         "net"
13         "net/http"
14         "os"
15         "os/exec"
16         "sync"
17         "syscall"
18
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"
23 )
24
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{
30                 NoClientAuth: true,
31                 PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
32                         if c.User() == "root" {
33                                 return nil, nil
34                         } else {
35                                 return nil, fmt.Errorf("unimplemented: cannot log in as non-root user %q", c.User())
36                         }
37                 },
38                 PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
39                         if c.User() == "root" {
40                                 return &ssh.Permissions{
41                                         Extensions: map[string]string{
42                                                 "pubkey-fp": ssh.FingerprintSHA256(pubKey),
43                                         },
44                                 }, nil
45                         } else {
46                                 return nil, fmt.Errorf("unimplemented: cannot log in as non-root user %q", c.User())
47                         }
48                 },
49         }
50         pvt, err := rsa.GenerateKey(rand.Reader, 4096)
51         if err != nil {
52                 return err
53         }
54         err = pvt.Validate()
55         if err != nil {
56                 return err
57         }
58         signer, err := ssh.NewSignerFromKey(pvt)
59         if err != nil {
60                 return err
61         }
62         runner.gatewaySSHConfig.AddHostKey(signer)
63
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"))
70         if err != nil {
71                 return err
72         }
73         srv := &httpserver.Server{
74                 Server: http.Server{
75                         Handler: http.HandlerFunc(runner.handleSSH),
76                 },
77                 Addr: ":" + port,
78         }
79         err = srv.Start()
80         if err != nil {
81                 return err
82         }
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)
86         if err != nil {
87                 return err
88         }
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)
94         return nil
95 }
96
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).
101 //
102 // Requests must have path "/ssh" and the following headers:
103 //
104 // Connection: upgrade
105 // Upgrade: ssh
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)
110 //
111 // Optional header:
112 //
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)
121                 return
122         }
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)
125                 return
126         }
127         if req.Header.Get("X-Arvados-Authorization") != runner.gatewayAuthSecret {
128                 http.Error(w, "bad X-Arvados-Authorization header", http.StatusUnauthorized)
129                 return
130         }
131         detachKeys := req.Header.Get("X-Arvados-Detach-Keys")
132         hj, ok := w.(http.Hijacker)
133         if !ok {
134                 http.Error(w, "ResponseWriter does not support connection upgrade", http.StatusInternalServerError)
135                 return
136         }
137         netconn, _, err := hj.Hijack()
138         if !ok {
139                 http.Error(w, err.Error(), http.StatusInternalServerError)
140                 return
141         }
142         defer netconn.Close()
143         w.Header().Set("Connection", "upgrade")
144         w.Header().Set("Upgrade", "ssh")
145         netconn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n"))
146         w.Header().Write(netconn)
147         netconn.Write([]byte("\r\n"))
148
149         ctx := req.Context()
150
151         conn, newchans, reqs, err := ssh.NewServerConn(netconn, runner.gatewaySSHConfig)
152         if err != nil {
153                 runner.CrunchLog.Printf("ssh.NewServerConn: %s", err)
154                 return
155         }
156         defer conn.Close()
157         go ssh.DiscardRequests(reqs)
158         for newch := range newchans {
159                 if newch.ChannelType() != "session" {
160                         newch.Reject(ssh.UnknownChannelType, "unknown channel type")
161                         continue
162                 }
163                 ch, reqs, err := newch.Accept()
164                 if err != nil {
165                         runner.CrunchLog.Printf("accept channel: %s", err)
166                         return
167                 }
168                 var pty0, tty0 *os.File
169                 go func() {
170                         defer pty0.Close()
171                         defer tty0.Close()
172                         // Where to send errors/messages for the
173                         // client to see
174                         logw := io.Writer(ch.Stderr())
175                         // How to end lines when sending
176                         // errors/messages to the client (changes to
177                         // \r\n when using a pty)
178                         eol := "\n"
179                         // Env vars to add to child process
180                         termEnv := []string(nil)
181                         for req := range reqs {
182                                 ok := false
183                                 switch req.Type {
184                                 case "shell", "exec":
185                                         ok = true
186                                         var payload struct {
187                                                 Command string
188                                         }
189                                         ssh.Unmarshal(req.Payload, &payload)
190                                         execargs, err := shlex.Split(payload.Command)
191                                         if err != nil {
192                                                 fmt.Fprintf(logw, "error parsing supplied command: %s"+eol, err)
193                                                 return
194                                         }
195                                         if len(execargs) == 0 {
196                                                 execargs = []string{"/bin/bash", "-login"}
197                                         }
198                                         go func() {
199                                                 cmd := exec.CommandContext(ctx, "docker", "exec", "-i", "--detach-keys="+detachKeys)
200                                                 cmd.Stdin = ch
201                                                 cmd.Stdout = ch
202                                                 cmd.Stderr = ch.Stderr()
203                                                 if tty0 != nil {
204                                                         cmd.Args = append(cmd.Args, "-t")
205                                                         cmd.Stdin = tty0
206                                                         cmd.Stdout = tty0
207                                                         cmd.Stderr = tty0
208                                                         var wg sync.WaitGroup
209                                                         defer wg.Wait()
210                                                         wg.Add(2)
211                                                         go func() { io.Copy(ch, pty0); wg.Done() }()
212                                                         go func() { io.Copy(pty0, ch); wg.Done() }()
213                                                         // Send our own debug messages to tty as well.
214                                                         logw = tty0
215                                                 }
216                                                 cmd.Args = append(cmd.Args, runner.ContainerID)
217                                                 cmd.Args = append(cmd.Args, execargs...)
218                                                 cmd.SysProcAttr = &syscall.SysProcAttr{
219                                                         Setctty: tty0 != nil,
220                                                         Setsid:  true,
221                                                 }
222                                                 cmd.Env = append(os.Environ(), termEnv...)
223                                                 err := cmd.Run()
224                                                 errClose := ch.CloseWrite()
225                                                 var resp struct {
226                                                         Status uint32
227                                                 }
228                                                 if err, ok := err.(*exec.ExitError); ok {
229                                                         if status, ok := err.Sys().(syscall.WaitStatus); ok {
230                                                                 resp.Status = uint32(status.ExitStatus())
231                                                         }
232                                                 }
233                                                 if resp.Status == 0 && (err != nil || errClose != nil) {
234                                                         resp.Status = 1
235                                                 }
236                                                 ch.SendRequest("exit-status", false, ssh.Marshal(&resp))
237                                                 ch.Close()
238                                         }()
239                                 case "pty-req":
240                                         eol = "\r\n"
241                                         p, t, err := pty.Open()
242                                         if err != nil {
243                                                 fmt.Fprintf(ch.Stderr(), "pty failed: %s"+eol, err)
244                                                 break
245                                         }
246                                         pty0, tty0 = p, t
247                                         ok = true
248                                         var payload struct {
249                                                 Term string
250                                                 Cols uint32
251                                                 Rows uint32
252                                                 X    uint32
253                                                 Y    uint32
254                                         }
255                                         ssh.Unmarshal(req.Payload, &payload)
256                                         termEnv = []string{"TERM=" + payload.Term, "USE_TTY=1"}
257                                         err = pty.Setsize(pty0, &pty.Winsize{Rows: uint16(payload.Rows), Cols: uint16(payload.Cols), X: uint16(payload.X), Y: uint16(payload.Y)})
258                                         if err != nil {
259                                                 fmt.Fprintf(logw, "pty-req: setsize failed: %s"+eol, err)
260                                         }
261                                 case "window-change":
262                                         var payload struct {
263                                                 Cols uint32
264                                                 Rows uint32
265                                                 X    uint32
266                                                 Y    uint32
267                                         }
268                                         ssh.Unmarshal(req.Payload, &payload)
269                                         err := pty.Setsize(pty0, &pty.Winsize{Rows: uint16(payload.Rows), Cols: uint16(payload.Cols), X: uint16(payload.X), Y: uint16(payload.Y)})
270                                         if err != nil {
271                                                 fmt.Fprintf(logw, "window-change: setsize failed: %s"+eol, err)
272                                                 break
273                                         }
274                                         ok = true
275                                 case "env":
276                                         // TODO: implement "env"
277                                         // requests by setting env
278                                         // vars in the docker-exec
279                                         // command (not docker-exec's
280                                         // own environment, which
281                                         // would be a gaping security
282                                         // hole).
283                                 default:
284                                         // fmt.Fprintf(logw, "declining %q req"+eol, req.Type)
285                                 }
286                                 if req.WantReply {
287                                         req.Reply(ok, nil)
288                                 }
289                         }
290                 }()
291         }
292 }