package http import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/urfave/negroni" "github.com/keel-hq/keel/approvals" "github.com/keel-hq/keel/internal/k8s" "github.com/keel-hq/keel/pkg/auth" "github.com/keel-hq/keel/pkg/store" "github.com/keel-hq/keel/provider" "github.com/keel-hq/keel/provider/kubernetes" "github.com/keel-hq/keel/types" "github.com/keel-hq/keel/version" log "github.com/sirupsen/logrus" ) // Opts - http server options type Opts struct { Port int // available providers Providers provider.Providers ApprovalManager approvals.Manager Authenticator auth.Authenticator GRC *k8s.GenericResourceCache KubernetesClient kubernetes.Implementer Store store.Store UIDir string AuthenticatedWebhooks bool } // TriggerServer - webhook trigger & healthcheck server type TriggerServer struct { grc *k8s.GenericResourceCache kubernetesClient kubernetes.Implementer providers provider.Providers approvalsManager approvals.Manager port int server *http.Server router *mux.Router store store.Store authenticator auth.Authenticator uiDir string authenticatedWebhooks bool } // NewTriggerServer - create new HTTP trigger based server func NewTriggerServer(opts *Opts) *TriggerServer { return &TriggerServer{ port: opts.Port, grc: opts.GRC, kubernetesClient: opts.KubernetesClient, providers: opts.Providers, approvalsManager: opts.ApprovalManager, router: mux.NewRouter(), authenticator: opts.Authenticator, store: opts.Store, uiDir: opts.UIDir, authenticatedWebhooks: opts.AuthenticatedWebhooks, } } // Start - start server func (s *TriggerServer) Start() error { s.registerRoutes(s.router) n := negroni.New(negroni.NewRecovery()) n.Use(negroni.HandlerFunc(corsHeadersMiddleware)) n.UseHandler(s.router) s.server = &http.Server{ Addr: fmt.Sprintf(":%d", s.port), Handler: n, } log.WithFields(log.Fields{ "port": s.port, }).Info("webhook trigger server starting...") return s.server.ListenAndServe() } // Stop - stop webhook server func (s *TriggerServer) Stop() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() s.server.Shutdown(ctx) } func getID(req *http.Request) string { return mux.Vars(req)["id"] } func (s *TriggerServer) registerRoutes(mux *mux.Router) { if os.Getenv("DEBUG") == "true" { DebugHandler{}.AddRoutes(mux) } s.registerWebhookRoutes(mux) // health endpoint for k8s to be happy mux.HandleFunc("/healthz", s.healthHandler).Methods("GET", "OPTIONS") // version handler mux.HandleFunc("/version", s.versionHandler).Methods("GET", "OPTIONS") mux.Handle("/metrics", promhttp.Handler()) if s.authenticator.Enabled() { log.Info("authentication enabled, setting up admin HTTP handlers") // auth mux.HandleFunc("/v1/auth/login", s.loginHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/auth/info", s.requireAdminAuthorization(s.userInfoHandler)).Methods("GET", "OPTIONS") mux.HandleFunc("/v1/auth/user", s.requireAdminAuthorization(s.userInfoHandler)).Methods("GET", "OPTIONS") mux.HandleFunc("/v1/auth/logout", s.requireAdminAuthorization(s.logoutHandler)).Methods("POST", "GET", "OPTIONS") mux.HandleFunc("/v1/auth/refresh", s.requireAdminAuthorization(s.refreshHandler)).Methods("GET", "OPTIONS") // approvals mux.HandleFunc("/v1/approvals", s.requireAdminAuthorization(s.approvalsHandler)).Methods("GET", "OPTIONS") // approving/rejecting mux.HandleFunc("/v1/approvals", s.requireAdminAuthorization(s.approvalApproveHandler)).Methods("POST", "OPTIONS") // updating required approvals count mux.HandleFunc("/v1/approvals", s.requireAdminAuthorization(s.approvalSetHandler)).Methods("PUT", "OPTIONS") // available resources mux.HandleFunc("/v1/resources", s.requireAdminAuthorization(s.resourcesHandler)).Methods("GET", "OPTIONS") mux.HandleFunc("/v1/policies", s.requireAdminAuthorization(s.policyUpdateHandler)).Methods("PUT", "OPTIONS") // tracked images mux.HandleFunc("/v1/tracked", s.requireAdminAuthorization(s.trackedHandler)).Methods("GET", "OPTIONS") mux.HandleFunc("/v1/tracked", s.requireAdminAuthorization(s.trackSetHandler)).Methods("PUT", "OPTIONS") // status mux.HandleFunc("/v1/audit", s.requireAdminAuthorization(s.adminAuditLogHandler)).Methods("GET", "OPTIONS") mux.HandleFunc("/v1/stats", s.requireAdminAuthorization(s.statsHandler)).Methods("GET", "OPTIONS") if s.uiDir != "" { // Serve static assets directly. mux.PathPrefix("/css/").Handler(http.FileServer(http.Dir(s.uiDir))) mux.PathPrefix("/assets/").Handler(http.FileServer(http.Dir(s.uiDir))) mux.PathPrefix("/js/").Handler(http.FileServer(http.Dir(s.uiDir))) mux.PathPrefix("/img/").Handler(http.FileServer(http.Dir(s.uiDir))) mux.PathPrefix("/loading/").Handler(http.FileServer(http.Dir(s.uiDir))) mux.PathPrefix("/").HandlerFunc(indexHandler(s.uiDir)) } } else { log.Info("authentication is not enabled, admin HTTP handlers are not initialized") } } func (s *TriggerServer) registerWebhookRoutes(mux *mux.Router) { if s.authenticatedWebhooks { mux.HandleFunc("/v1/webhooks/native", s.requireAdminAuthorization(s.nativeHandler)).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/dockerhub", s.requireAdminAuthorization(s.dockerHubHandler)).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/jfrog", s.requireAdminAuthorization(s.jfrogHandler)).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/quay", s.requireAdminAuthorization(s.quayHandler)).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/azure", s.requireAdminAuthorization(s.azureHandler)).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/github", s.requireAdminAuthorization(s.githubHandler)).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/harbor", s.requireAdminAuthorization(s.harborHandler)).Methods("POST", "OPTIONS") // Docker registry notifications, used by Docker, Gitlab, Harbor // https://docs.docker.com/registry/notifications/ //https://docs.gitlab.com/ee/administration/container_registry.html#configure-container-registry-notifications mux.HandleFunc("/v1/webhooks/registry", s.registryNotificationHandler).Methods("POST", "OPTIONS") } else { mux.HandleFunc("/v1/webhooks/native", s.nativeHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/dockerhub", s.dockerHubHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/jfrog", s.jfrogHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/quay", s.quayHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/azure", s.azureHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/github", s.githubHandler).Methods("POST", "OPTIONS") mux.HandleFunc("/v1/webhooks/harbor", s.harborHandler).Methods("POST", "OPTIONS") // Docker registry notifications, used by Docker, Gitlab, Harbor // https://docs.docker.com/registry/notifications/ //https://docs.gitlab.com/ee/administration/container_registry.html#configure-container-registry-notifications mux.HandleFunc("/v1/webhooks/registry", s.registryNotificationHandler).Methods("POST", "OPTIONS") } } func (s *TriggerServer) healthHandler(resp http.ResponseWriter, req *http.Request) { resp.WriteHeader(http.StatusOK) } func (s *TriggerServer) versionHandler(resp http.ResponseWriter, req *http.Request) { v := version.GetKeelVersion() encoded, err := json.Marshal(v) if err != nil { log.WithError(err).Error("trigger.http: failed to marshal version") resp.WriteHeader(http.StatusInternalServerError) return } resp.WriteHeader(http.StatusOK) resp.Write(encoded) } func (s *TriggerServer) trigger(event types.Event) error { return s.providers.Submit(event) } func response(obj interface{}, statusCode int, err error, resp http.ResponseWriter, req *http.Request) { // Check for an error if err != nil { code := 500 errMsg := err.Error() if strings.Contains(errMsg, "Permission denied") { code = 403 } resp.WriteHeader(code) resp.Write([]byte(err.Error())) return } // Write out the JSON object if obj != nil { resp.Header().Set("Content-Type", "application/json") resp.WriteHeader(statusCode) // Set up the pipe to write data directly into the Reader. pr, pw := io.Pipe() // Write JSON-encoded data to the Writer end of the pipe. // Write in a separate concurrent goroutine, and remember // to Close the PipeWriter, to signal to the paired PipeReader // that we’re done writing. go func() { pw.CloseWithError(json.NewEncoder(pw).Encode(obj)) }() io.Copy(resp, pr) // encoding/json library has a specific bug(feature) to turn empty slices into json null object, // let's make an empty array instead // resp.Write(buf) } } // corsHeadersMiddleware - cors middleware func corsHeadersMiddleware(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { rw.Header().Set("Access-Control-Allow-Origin", "*") rw.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") rw.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") rw.Header().Set("Access-Control-Expose-Headers", "Authorization") rw.Header().Set("Access-Control-Request-Headers", "Authorization") if r.Method == "OPTIONS" { rw.WriteHeader(200) return } next(rw, r) } type UserInfo struct { ID string `json:"id"` Name string `json:"name"` Username string `json:"username"` Avatar string `json:"avatar"` Status int `json:"status"` LastLoginIP string `json:"last_login_ip"` LastLoginTime int64 `json:"last_login_time"` RoleID string `json:"role_id"` } func (s *TriggerServer) userInfoHandler(resp http.ResponseWriter, req *http.Request) { user := auth.GetAccountFromCtx(req.Context()) ui := UserInfo{ ID: "1", Name: user.Username, Avatar: "", Status: 1, LastLoginIP: "", LastLoginTime: time.Now().Unix(), RoleID: "admin", } response(&ui, 200, nil, resp, req) } type APIResponse struct { Status string `json:"status"` } func indexHandler(uiDir string) func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, uiDir+"/index.html") } return http.HandlerFunc(fn) }