github.com/argoproj/argo-cd/v2@v2.10.9/server/application/terminal.go (about)

     1  package application
     2  
     3  import (
     4  	"context"
     5  	"io"
     6  	"net/http"
     7  	"time"
     8  
     9  	util_session "github.com/argoproj/argo-cd/v2/util/session"
    10  	"github.com/argoproj/gitops-engine/pkg/utils/kube"
    11  	log "github.com/sirupsen/logrus"
    12  	v1 "k8s.io/api/core/v1"
    13  	apierr "k8s.io/apimachinery/pkg/api/errors"
    14  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    15  	"k8s.io/client-go/kubernetes"
    16  	"k8s.io/client-go/kubernetes/scheme"
    17  	"k8s.io/client-go/rest"
    18  	"k8s.io/client-go/tools/remotecommand"
    19  
    20  	appv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
    21  	applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1"
    22  	servercache "github.com/argoproj/argo-cd/v2/server/cache"
    23  	"github.com/argoproj/argo-cd/v2/server/rbacpolicy"
    24  	"github.com/argoproj/argo-cd/v2/util/argo"
    25  	"github.com/argoproj/argo-cd/v2/util/db"
    26  	"github.com/argoproj/argo-cd/v2/util/rbac"
    27  	"github.com/argoproj/argo-cd/v2/util/security"
    28  	sessionmgr "github.com/argoproj/argo-cd/v2/util/session"
    29  	"github.com/argoproj/argo-cd/v2/util/settings"
    30  )
    31  
    32  type terminalHandler struct {
    33  	appLister         applisters.ApplicationLister
    34  	db                db.ArgoDB
    35  	enf               *rbac.Enforcer
    36  	cache             *servercache.Cache
    37  	appResourceTreeFn func(ctx context.Context, app *appv1.Application) (*appv1.ApplicationTree, error)
    38  	allowedShells     []string
    39  	namespace         string
    40  	enabledNamespaces []string
    41  	sessionManager    *util_session.SessionManager
    42  }
    43  
    44  // NewHandler returns a new terminal handler.
    45  func NewHandler(appLister applisters.ApplicationLister, namespace string, enabledNamespaces []string, db db.ArgoDB, enf *rbac.Enforcer, cache *servercache.Cache,
    46  	appResourceTree AppResourceTreeFn, allowedShells []string, sessionManager *util_session.SessionManager) *terminalHandler {
    47  	return &terminalHandler{
    48  		appLister:         appLister,
    49  		db:                db,
    50  		enf:               enf,
    51  		cache:             cache,
    52  		appResourceTreeFn: appResourceTree,
    53  		allowedShells:     allowedShells,
    54  		namespace:         namespace,
    55  		enabledNamespaces: enabledNamespaces,
    56  		sessionManager:    sessionManager,
    57  	}
    58  }
    59  
    60  func (s *terminalHandler) getApplicationClusterRawConfig(ctx context.Context, a *appv1.Application) (*rest.Config, error) {
    61  	if err := argo.ValidateDestination(ctx, &a.Spec.Destination, s.db); err != nil {
    62  		return nil, err
    63  	}
    64  	clst, err := s.db.GetCluster(ctx, a.Spec.Destination.Server)
    65  	if err != nil {
    66  		return nil, err
    67  	}
    68  	return clst.RawRestConfig(), nil
    69  }
    70  
    71  type GetSettingsFunc func() (*settings.ArgoCDSettings, error)
    72  
    73  // WithFeatureFlagMiddleware is an HTTP middleware to verify if the terminal
    74  // feature is enabled before invoking the main handler
    75  func (s *terminalHandler) WithFeatureFlagMiddleware(getSettings GetSettingsFunc) http.Handler {
    76  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    77  		argocdSettings, err := getSettings()
    78  		if err != nil {
    79  			log.Errorf("error executing WithFeatureFlagMiddleware: error getting settings: %s", err)
    80  			http.Error(w, "Failed to get settings", http.StatusBadRequest)
    81  			return
    82  		}
    83  		if !argocdSettings.ExecEnabled {
    84  			w.WriteHeader(http.StatusNotFound)
    85  			return
    86  		}
    87  		s.ServeHTTP(w, r)
    88  	})
    89  }
    90  
    91  func (s *terminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    92  	q := r.URL.Query()
    93  
    94  	podName := q.Get("pod")
    95  	container := q.Get("container")
    96  	app := q.Get("appName")
    97  	project := q.Get("projectName")
    98  	namespace := q.Get("namespace")
    99  
   100  	if podName == "" || container == "" || app == "" || project == "" || namespace == "" {
   101  		http.Error(w, "Missing required parameters", http.StatusBadRequest)
   102  		return
   103  	}
   104  
   105  	appNamespace := q.Get("appNamespace")
   106  
   107  	if !argo.IsValidPodName(podName) {
   108  		http.Error(w, "Pod name is not valid", http.StatusBadRequest)
   109  		return
   110  	}
   111  	if !argo.IsValidContainerName(container) {
   112  		http.Error(w, "Container name is not valid", http.StatusBadRequest)
   113  		return
   114  	}
   115  	if !argo.IsValidAppName(app) {
   116  		http.Error(w, "App name is not valid", http.StatusBadRequest)
   117  		return
   118  	}
   119  	if !argo.IsValidProjectName(project) {
   120  		http.Error(w, "Project name is not valid", http.StatusBadRequest)
   121  		return
   122  	}
   123  	if !argo.IsValidNamespaceName(namespace) {
   124  		http.Error(w, "Namespace name is not valid", http.StatusBadRequest)
   125  		return
   126  	}
   127  	if !argo.IsValidNamespaceName(appNamespace) {
   128  		http.Error(w, "App namespace name is not valid", http.StatusBadRequest)
   129  		return
   130  	}
   131  
   132  	ns := appNamespace
   133  	if ns == "" {
   134  		ns = s.namespace
   135  	}
   136  
   137  	if !security.IsNamespaceEnabled(ns, s.namespace, s.enabledNamespaces) {
   138  		http.Error(w, security.NamespaceNotPermittedError(ns).Error(), http.StatusForbidden)
   139  		return
   140  	}
   141  
   142  	shell := q.Get("shell") // No need to validate. Will only be used if it's in the allow-list.
   143  
   144  	ctx := r.Context()
   145  
   146  	appRBACName := security.RBACName(s.namespace, project, appNamespace, app)
   147  	if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName); err != nil {
   148  		http.Error(w, err.Error(), http.StatusUnauthorized)
   149  		return
   150  	}
   151  
   152  	if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceExec, rbacpolicy.ActionCreate, appRBACName); err != nil {
   153  		http.Error(w, err.Error(), http.StatusUnauthorized)
   154  		return
   155  	}
   156  
   157  	fieldLog := log.WithFields(log.Fields{"application": app, "userName": sessionmgr.Username(ctx), "container": container,
   158  		"podName": podName, "namespace": namespace, "project": project, "appNamespace": appNamespace})
   159  
   160  	a, err := s.appLister.Applications(ns).Get(app)
   161  	if err != nil {
   162  		if apierr.IsNotFound(err) {
   163  			http.Error(w, "App not found", http.StatusNotFound)
   164  			return
   165  		}
   166  		fieldLog.Errorf("Error when getting app %q when launching a terminal: %s", app, err)
   167  		http.Error(w, "Cannot get app", http.StatusInternalServerError)
   168  		return
   169  	}
   170  
   171  	if a.Spec.Project != project {
   172  		fieldLog.Warnf("The wrong project (%q) was specified for the app %q when launching a terminal", project, app)
   173  		http.Error(w, "The wrong project was specified for the app", http.StatusBadRequest)
   174  		return
   175  	}
   176  
   177  	config, err := s.getApplicationClusterRawConfig(ctx, a)
   178  	if err != nil {
   179  		http.Error(w, "Cannot get raw cluster config", http.StatusBadRequest)
   180  		return
   181  	}
   182  
   183  	kubeClientset, err := kubernetes.NewForConfig(config)
   184  	if err != nil {
   185  		http.Error(w, "Cannot initialize kubeclient", http.StatusBadRequest)
   186  		return
   187  	}
   188  
   189  	resourceTree, err := s.appResourceTreeFn(ctx, a)
   190  	if err != nil {
   191  		http.Error(w, err.Error(), http.StatusInternalServerError)
   192  		return
   193  	}
   194  
   195  	// From the tree find pods which match the given pod.
   196  	if !podExists(resourceTree.Nodes, podName, namespace) {
   197  		http.Error(w, "Pod doesn't belong to specified app", http.StatusBadRequest)
   198  		return
   199  	}
   200  
   201  	pod, err := kubeClientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{})
   202  	if err != nil {
   203  		fieldLog.Errorf("error retrieving pod: %s", err)
   204  		http.Error(w, "Cannot find pod", http.StatusBadRequest)
   205  		return
   206  	}
   207  
   208  	if pod.Status.Phase != v1.PodRunning {
   209  		http.Error(w, "Pod not running", http.StatusBadRequest)
   210  		return
   211  	}
   212  
   213  	var findContainer bool
   214  	for _, c := range pod.Spec.Containers {
   215  		if container == c.Name {
   216  			findContainer = true
   217  			break
   218  		}
   219  	}
   220  	if !findContainer {
   221  		fieldLog.Warn("terminal container not found")
   222  		http.Error(w, "Cannot find container", http.StatusBadRequest)
   223  		return
   224  	}
   225  
   226  	fieldLog.Info("terminal session starting")
   227  
   228  	session, err := newTerminalSession(w, r, nil, s.sessionManager)
   229  	if err != nil {
   230  		http.Error(w, "Failed to start terminal session", http.StatusBadRequest)
   231  		return
   232  	}
   233  	defer session.Done()
   234  
   235  	// send pings across the WebSocket channel at regular intervals to keep it alive through
   236  	// load balancers which may close an idle connection after some period of time
   237  	go session.StartKeepalives(time.Second * 5)
   238  
   239  	if isValidShell(s.allowedShells, shell) {
   240  		cmd := []string{shell}
   241  		err = startProcess(kubeClientset, config, namespace, podName, container, cmd, session)
   242  	} else {
   243  		// No shell given or the given shell was not allowed: try the configured shells until one succeeds or all fail.
   244  		for _, testShell := range s.allowedShells {
   245  			cmd := []string{testShell}
   246  			if err = startProcess(kubeClientset, config, namespace, podName, container, cmd, session); err == nil {
   247  				break
   248  			}
   249  		}
   250  	}
   251  
   252  	if err != nil {
   253  		http.Error(w, "Failed to exec container", http.StatusBadRequest)
   254  		session.Close()
   255  		return
   256  	}
   257  
   258  	session.Close()
   259  }
   260  
   261  func podExists(treeNodes []appv1.ResourceNode, podName, namespace string) bool {
   262  	for _, treeNode := range treeNodes {
   263  		if treeNode.Kind == kube.PodKind && treeNode.Group == "" && treeNode.UID != "" &&
   264  			treeNode.Name == podName && treeNode.Namespace == namespace {
   265  			return true
   266  		}
   267  	}
   268  	return false
   269  }
   270  
   271  const EndOfTransmission = "\u0004"
   272  
   273  // PtyHandler is what remotecommand expects from a pty
   274  type PtyHandler interface {
   275  	io.Reader
   276  	io.Writer
   277  	remotecommand.TerminalSizeQueue
   278  }
   279  
   280  // TerminalMessage is the struct for websocket message.
   281  type TerminalMessage struct {
   282  	Operation string `json:"operation"`
   283  	Data      string `json:"data"`
   284  	Rows      uint16 `json:"rows"`
   285  	Cols      uint16 `json:"cols"`
   286  }
   287  
   288  // TerminalCommand is the struct for websocket commands,For example you need ask client to reconnect
   289  type TerminalCommand struct {
   290  	Code int
   291  }
   292  
   293  // startProcess executes specified commands in the container and connects it up with the ptyHandler (a session)
   294  func startProcess(k8sClient kubernetes.Interface, cfg *rest.Config, namespace, podName, containerName string, cmd []string, ptyHandler PtyHandler) error {
   295  	req := k8sClient.CoreV1().RESTClient().Post().
   296  		Resource("pods").
   297  		Name(podName).
   298  		Namespace(namespace).
   299  		SubResource("exec")
   300  
   301  	req.VersionedParams(&v1.PodExecOptions{
   302  		Container: containerName,
   303  		Command:   cmd,
   304  		Stdin:     true,
   305  		Stdout:    true,
   306  		Stderr:    true,
   307  		TTY:       true,
   308  	}, scheme.ParameterCodec)
   309  
   310  	exec, err := remotecommand.NewSPDYExecutor(cfg, "POST", req.URL())
   311  	if err != nil {
   312  		return err
   313  	}
   314  
   315  	return exec.StreamWithContext(context.Background(), remotecommand.StreamOptions{
   316  		Stdin:             ptyHandler,
   317  		Stdout:            ptyHandler,
   318  		Stderr:            ptyHandler,
   319  		TerminalSizeQueue: ptyHandler,
   320  		Tty:               true,
   321  	})
   322  }
   323  
   324  // isValidShell checks if the shell is an allowed one
   325  func isValidShell(validShells []string, shell string) bool {
   326  	for _, validShell := range validShells {
   327  		if validShell == shell {
   328  			return true
   329  		}
   330  	}
   331  	return false
   332  }