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

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