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 }