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 }