329 lines
10 KiB
Go
329 lines
10 KiB
Go
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)
|
||
}
|