316 lines
9.1 KiB
Go
316 lines
9.1 KiB
Go
// Copyright 2018 The Kubesphere Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package models
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
restful "github.com/emicklei/go-restful"
|
|
"github.com/golang/glog"
|
|
"gopkg.in/igm/sockjs-go.v2/sockjs"
|
|
"k8s.io/api/core/v1"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/remotecommand"
|
|
|
|
"kubesphere.io/kubesphere/pkg/client"
|
|
"kubesphere.io/kubesphere/pkg/options"
|
|
)
|
|
|
|
// TerminalResponse is sent by handleExecShell. The Id is a random session id that binds the original REST request and the SockJS connection.
|
|
// Any clientapi in possession of this Id can hijack the terminal session.
|
|
type TerminalResponse struct {
|
|
Id string `json:"id"`
|
|
}
|
|
|
|
// PtyHandler is what remotecommand expects from a pty
|
|
type PtyHandler interface {
|
|
io.Reader
|
|
io.Writer
|
|
remotecommand.TerminalSizeQueue
|
|
}
|
|
|
|
// TerminalSession implements PtyHandler (using a SockJS connection)
|
|
type TerminalSession struct {
|
|
id string
|
|
bound chan error
|
|
sockJSSession sockjs.Session
|
|
sizeChan chan remotecommand.TerminalSize
|
|
}
|
|
|
|
// TerminalMessage is the messaging protocol between ShellController and TerminalSession.
|
|
//
|
|
// OP DIRECTION FIELD(S) USED DESCRIPTION
|
|
// ---------------------------------------------------------------------
|
|
// bind fe->be SessionID Id sent back from TerminalResponse
|
|
// stdin fe->be Data Keystrokes/paste buffer
|
|
// resize fe->be Rows, Cols New terminal size
|
|
// stdout be->fe Data Output from the process
|
|
// toast be->fe Data OOB message to be shown to the user
|
|
type TerminalMessage struct {
|
|
Op, Data, SessionID string
|
|
Rows, Cols uint16
|
|
}
|
|
|
|
// TerminalSize handles pty->process resize events
|
|
// Called in a loop from remotecommand as long as the process is running
|
|
func (t TerminalSession) Next() *remotecommand.TerminalSize {
|
|
select {
|
|
case size := <-t.sizeChan:
|
|
return &size
|
|
}
|
|
}
|
|
|
|
// Read handles pty->process messages (stdin, resize)
|
|
// Called in a loop from remotecommand as long as the process is running
|
|
func (t TerminalSession) Read(p []byte) (int, error) {
|
|
m, err := t.sockJSSession.Recv()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var msg TerminalMessage
|
|
if err := json.Unmarshal([]byte(m), &msg); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
switch msg.Op {
|
|
case "stdin":
|
|
return copy(p, msg.Data), nil
|
|
case "resize":
|
|
t.sizeChan <- remotecommand.TerminalSize{msg.Cols, msg.Rows}
|
|
return 0, nil
|
|
default:
|
|
return 0, fmt.Errorf("unknown message type '%s'", msg.Op)
|
|
}
|
|
}
|
|
|
|
// Write handles process->pty stdout
|
|
// Called from remotecommand whenever there is any output
|
|
func (t TerminalSession) Write(p []byte) (int, error) {
|
|
msg, err := json.Marshal(TerminalMessage{
|
|
Op: "stdout",
|
|
Data: string(p),
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if err = t.sockJSSession.Send(string(msg)); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// Toast can be used to send the user any OOB messages
|
|
// hterm puts these in the center of the terminal
|
|
func (t TerminalSession) Toast(p string) error {
|
|
msg, err := json.Marshal(TerminalMessage{
|
|
Op: "toast",
|
|
Data: p,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = t.sockJSSession.Send(string(msg)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close shuts down the SockJS connection and sends the status code and reason to the client
|
|
// Can happen if the process exits or if there is an error starting up the process
|
|
// For now the status code is unused and reason is shown to the user (unless "")
|
|
func (t TerminalSession) Close(status uint32, reason string) {
|
|
t.sockJSSession.Close(status, reason)
|
|
}
|
|
|
|
// terminalSessions stores a map of all TerminalSession objects
|
|
// FIXME: this structure needs locking
|
|
var terminalSessions = make(map[string]TerminalSession)
|
|
|
|
// handleTerminalSession is Called by net/http for any new /api/sockjs connections
|
|
func handleTerminalSession(session sockjs.Session) {
|
|
glog.Infof("handleTerminalSession, ID:%s", session.ID())
|
|
var (
|
|
buf string
|
|
err error
|
|
msg TerminalMessage
|
|
terminalSession TerminalSession
|
|
ok bool
|
|
)
|
|
|
|
if buf, err = session.Recv(); err != nil {
|
|
glog.Errorf("handleTerminalSession: can't Recv: %v", err)
|
|
return
|
|
}
|
|
|
|
if err = json.Unmarshal([]byte(buf), &msg); err != nil {
|
|
glog.Errorf("handleTerminalSession: can't UnMarshal (%v): %s", err, buf)
|
|
return
|
|
}
|
|
|
|
if msg.Op != "bind" {
|
|
glog.Errorf("handleTerminalSession: expected 'bind' message, got: %s", buf)
|
|
return
|
|
}
|
|
|
|
if terminalSession, ok = terminalSessions[msg.SessionID]; !ok {
|
|
glog.Errorf("handleTerminalSession: can't find session '%s'", msg.SessionID)
|
|
return
|
|
}
|
|
|
|
terminalSession.sockJSSession = session
|
|
terminalSessions[msg.SessionID] = terminalSession
|
|
terminalSession.bound <- nil
|
|
}
|
|
|
|
// CreateAttachHandler is called from main for /api/sockjs
|
|
func CreateTerminalHandler(path string) http.Handler {
|
|
return sockjs.NewHandler(path, sockjs.DefaultOptions, handleTerminalSession)
|
|
}
|
|
|
|
// startProcess is called by handleAttach
|
|
// Executed cmd in the container specified in request and connects it up with the ptyHandler (a session)
|
|
func startProcess(k8sClient kubernetes.Interface, cfg *rest.Config, request *restful.Request, cmd []string, ptyHandler PtyHandler) error {
|
|
namespace := request.PathParameter("namespace")
|
|
podName := request.PathParameter("pod")
|
|
containerName := request.PathParameter("container")
|
|
|
|
req := k8sClient.CoreV1().RESTClient().Post().
|
|
Resource("pods").
|
|
Name(podName).
|
|
Namespace(namespace).
|
|
SubResource("exec")
|
|
|
|
req.VersionedParams(&v1.PodExecOptions{
|
|
Container: containerName,
|
|
Command: cmd,
|
|
Stdin: true,
|
|
Stdout: true,
|
|
Stderr: true,
|
|
TTY: true,
|
|
}, scheme.ParameterCodec)
|
|
|
|
exec, err := remotecommand.NewSPDYExecutor(cfg, "POST", req.URL())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = exec.Stream(remotecommand.StreamOptions{
|
|
Stdin: ptyHandler,
|
|
Stdout: ptyHandler,
|
|
Stderr: ptyHandler,
|
|
TerminalSizeQueue: ptyHandler,
|
|
Tty: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// genTerminalSessionId generates a random session ID string. The format is not really interesting.
|
|
// This ID is used to identify the session when the client opens the SockJS connection.
|
|
// Not the same as the SockJS session id! We can't use that as that is generated
|
|
// on the client side and we don't have it yet at this point.
|
|
func genTerminalSessionId() (string, error) {
|
|
|
|
bytes := make([]byte, 16)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
id := make([]byte, hex.EncodedLen(len(bytes)))
|
|
hex.Encode(id, bytes)
|
|
glog.Infof("genTerminalSessionId, id:" + string(id))
|
|
return string(id), nil
|
|
}
|
|
|
|
// isValidShell checks if the shell is an allowed one
|
|
func isValidShell(validShells []string, shell string) bool {
|
|
for _, validShell := range validShells {
|
|
if validShell == shell {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WaitForTerminal is called from apihandler.handleAttach as a goroutine
|
|
// Waits for the SockJS connection to be opened by the client the session to be bound in handleTerminalSession
|
|
func WaitForTerminal(k8sClient kubernetes.Interface, cfg *rest.Config, request *restful.Request, sessionId string) {
|
|
glog.Infof("WaitForTerminal, ID:%s", sessionId)
|
|
shell := request.QueryParameter("shell")
|
|
|
|
select {
|
|
case <-terminalSessions[sessionId].bound:
|
|
close(terminalSessions[sessionId].bound)
|
|
|
|
var err error
|
|
validShells := []string{"bash", "sh"}
|
|
|
|
if isValidShell(validShells, shell) {
|
|
cmd := []string{shell}
|
|
err = startProcess(k8sClient, cfg, request, cmd, terminalSessions[sessionId])
|
|
} else {
|
|
// No shell given or it was not valid: try some shells until one succeeds or all fail
|
|
// FIXME: if the first shell fails then the first keyboard event is lost
|
|
for _, testShell := range validShells {
|
|
cmd := []string{testShell}
|
|
if err = startProcess(k8sClient, cfg, request, cmd, terminalSessions[sessionId]); err == nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
terminalSessions[sessionId].Close(2, err.Error())
|
|
return
|
|
}
|
|
|
|
terminalSessions[sessionId].Close(1, "Process exited")
|
|
}
|
|
}
|
|
|
|
// Handles execute shell API call
|
|
func HandleExecShell(request *restful.Request) (*TerminalResponse, error) {
|
|
sessionId, err := genTerminalSessionId()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
terminalSessions[sessionId] = TerminalSession{
|
|
id: sessionId,
|
|
bound: make(chan error),
|
|
sizeChan: make(chan remotecommand.TerminalSize),
|
|
}
|
|
|
|
kubeconfig, err := options.ServerOptions.GetKubeConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
go WaitForTerminal(client.NewK8sClient(), kubeconfig, request, sessionId)
|
|
return &TerminalResponse{Id: sessionId}, nil
|
|
}
|