cloudfoundry-incubator/stratos

View on GitHub
src/jetstream/main.go

Summary

Maintainability
F
4 days
Test Coverage
package main

import (
    "crypto/sha1"
    "crypto/tls"
    "database/sql"
    "encoding/gob"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "io/ioutil"
    "math/rand"
    "net"
    "net/http"
    "os"
    "os/signal"
    "path"
    "path/filepath"
    "regexp"
    "sort"
    "strconv"
    "strings"
    "syscall"
    "time"

    "github.com/cloudfoundry-incubator/stratos/src/jetstream/custombinder"
    _ "github.com/cloudfoundry-incubator/stratos/src/jetstream/docs"

    "bitbucket.org/liamstask/goose/lib/goose"
    "github.com/antonlindstrom/pgstore"
    "github.com/cf-stratos/mysqlstore"
    cfenv "github.com/cloudfoundry-community/go-cfenv"
    "github.com/gorilla/sessions"
    "github.com/govau/cf-common/env"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/nwmac/sqlitestore"
    uuid "github.com/satori/go.uuid"
    log "github.com/sirupsen/logrus"
    echoSwagger "github.com/swaggo/echo-swagger"

    "github.com/cloudfoundry-incubator/stratos/src/jetstream/crypto"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/factory"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/apikeys"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/cnsis"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/console_config"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/localusers"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/sessiondata"
    "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/tokens"
)

// @title Stratos API
// @version 1.0
// @description Stratos backend API.

// @contact.name Stratos maintainers
// @contact.url https://github.com/cloudfoundry/stratos/issues

// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html

// @tag.name admin
// @tag.description Endpoints that require admin permissions

// @BasePath /api/v1
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authentication

// TimeoutBoundary represents the amount of time we'll wait for the database
// server to come online before we bail out.
const (
    TimeoutBoundary      = 10
    SessionExpiry        = 20 // Default value for session cookies expiration (20 minutes)
    UpgradeVolume        = "UPGRADE_VOLUME"
    UpgradeLockFileName  = "UPGRADE_LOCK_FILENAME"
    LogToJSON            = "LOG_TO_JSON"
    LogAPIRequests       = "LOG_API_REQUESTS" // Defaults to true
    VCapApplication      = "VCAP_APPLICATION"
    defaultSessionSecret = "wheeee!"
)

var appVersion string

var (
    // Standard clients
    httpClient        = http.Client{}
    httpClientSkipSSL = http.Client{}
    // Clients to use typically for mutating operations - typically allow a longer request timeout
    httpClientMutating        = http.Client{}
    httpClientMutatingSkipSSL = http.Client{}
)

// getEnvironmentLookup return a search path for configuration settings
func getEnvironmentLookup() *env.VarSet {
    // Make environment lookup
    envLookup := env.NewVarSet()

    // Config database store topmost priority
    envLookup.AppendSource(console_config.ConfigLookup)

    // Environment variables
    envLookup.AppendSource(os.LookupEnv)

    // If running in CloudFoundry, fallback to a user provided service (if set)
    cfApp, err := cfenv.Current()
    if err == nil {
        envLookup.AppendSource(env.NewLookupFromUPS(cfApp, os.Getenv("CF_UPS_NAME")))
    }

    // Fallback to a "config.properties" files in our directory
    envLookup.AppendSource(config.NewConfigFileLookup("./config.properties"))

    // Fallback to individual files in the "/etc/secrets" directory
    envLookup.AppendSource(config.NewSecretsDirLookup("/etc/secrets"))

    return envLookup
}

func main() {

    // Register time.Time in gob
    gob.Register(time.Time{})

    // Create common method for looking up config
    envLookup := getEnvironmentLookup()

    log.SetFormatter(&log.TextFormatter{ForceColors: true, FullTimestamp: true, TimestampFormat: time.UnixDate})

    // Change to JSON logging if configured
    if logToJSON, ok := envLookup.Lookup(LogToJSON); ok {
        if logToJSON == "true" {
            log.SetFormatter(&log.JSONFormatter{TimestampFormat: time.UnixDate})
        }
    }

    rand.Seed(time.Now().UnixNano())

    log.SetOutput(os.Stdout)

    log.Info("========================================")
    log.Info("=== Stratos Jetstream Backend Server ===")
    log.Info("========================================")
    log.Info("")
    log.Info("Initialization started.")

    // Load the portal configuration from env vars
    var portalConfig interfaces.PortalConfig
    portalConfig, err := loadPortalConfig(portalConfig, envLookup)
    if err != nil {
        log.Fatal(err) // calls os.Exit(1) after logging
    }
    if portalConfig.LogLevel != "" {
        log.Infof("Setting log level to: %s", portalConfig.LogLevel)
        level, _ := log.ParseLevel(portalConfig.LogLevel)
        log.SetLevel(level)
    }

    // Initially, default state is that DB Migrations can be performed
    portalConfig.CanMigrateDatabaseSchema = true

    log.Info("Configuration loaded.")
    isUpgrading := isConsoleUpgrading(envLookup)

    if isUpgrading {
        log.Info("Upgrade in progress (lock file detected) ... waiting for lock file to be removed ...")
        start(portalConfig, &portalProxy{env: envLookup}, false, true, envLookup)
    }
    // Grab the Console Version from the executable
    portalConfig.ConsoleVersion = appVersion
    log.Infof("Stratos Version: %s", portalConfig.ConsoleVersion)

    // Initialize an empty config for the console - initially not setup
    portalConfig.ConsoleConfig = new(interfaces.ConsoleConfig)

    // Initialize the HTTP client
    initializeHTTPClients(portalConfig.HTTPClientTimeoutInSecs, portalConfig.HTTPClientTimeoutMutatingInSecs, portalConfig.HTTPConnectionTimeoutInSecs)
    log.Info("HTTP client initialized.")

    // Get the encryption key we need for tokens in the database
    portalConfig.EncryptionKeyInBytes, err = getEncryptionKey(portalConfig)
    if err != nil {
        log.Fatal(err)
    }
    log.Info("Encryption key set.")

    // Load database configuration
    var dc datastore.DatabaseConfig
    dc, err = loadDatabaseConfig(dc, envLookup)
    if err != nil {
        log.Fatal(err)
    }

    // Store database provider name for diagnostics
    portalConfig.DatabaseProviderName = dc.DatabaseProvider

    cnsis.InitRepositoryProvider(dc.DatabaseProvider)
    tokens.InitRepositoryProvider(dc.DatabaseProvider)
    console_config.InitRepositoryProvider(dc.DatabaseProvider)
    localusers.InitRepositoryProvider(dc.DatabaseProvider)
    sessiondata.InitRepositoryProvider(dc.DatabaseProvider)
    apikeys.InitRepositoryProvider(dc.DatabaseProvider)

    // Establish a Postgresql connection pool
    databaseConnectionPool, migratorConf, err := initConnPool(dc, envLookup)
    if err != nil {
        log.Fatal(err.Error())
    }
    defer func() {
        log.Info(`... Closing database connection pool`)
        databaseConnectionPool.Close()
    }()
    log.Info("Database connection pool created.")

    // Before any changes it, log that we detected a non-default session store secret, so we can tell it has been set from the log
    if portalConfig.SessionStoreSecret != defaultSessionSecret {
        log.Info("Session Store Secret detected okay")
    }

    for _, configPlugin := range interfaces.JetstreamConfigPlugins {
        configPlugin(envLookup, &portalConfig)
    }

    if portalConfig.SessionStoreSecret == defaultSessionSecret {
        // The Session store secret needs to be set for secure cookies to work properly
        // We should not be using the default value - this indicates that it has not been set by the user
        // So for saftey, set a random value
        log.Warn("When running in production, ensure you set SESSION_STORE_SECRET to a secure value")
        portalConfig.SessionStoreSecret = uuid.NewV4().String()
    }

    // Config plugins get to determine if we should run migrations on this instance
    if portalConfig.CanMigrateDatabaseSchema {
        // Create the database schema otherwise wait for the datbase schema
        err = datastore.ApplyMigrations(migratorConf, databaseConnectionPool)
        if err != nil {
            log.Fatal(err)
        }
    } else {
        log.Warn("Waiting for migrations ...")
        // Wait for Database Schema to be initialized (or exit if this times out)
        if err = datastore.WaitForMigrations(databaseConnectionPool); err != nil {
            log.Fatal(err)
        }
    }

    sSessionExpiry := envLookup.String("SESSION_STORE_EXPIRY", strconv.Itoa(SessionExpiry))
    sessionExpiry, err := strconv.Atoi(sSessionExpiry)
    if err != nil {
        sessionExpiry = SessionExpiry
    }
    log.Infof("Session expiration (minutes): %d", sessionExpiry)
    // Convert to seconds
    sessionExpiry *= 60
    // Initialize session store for Gorilla sessions
    sessionStore, sessionStoreOptions, err := initSessionStore(databaseConnectionPool, dc.DatabaseProvider, portalConfig, sessionExpiry, envLookup)
    if err != nil {
        log.Fatal(err)
    }

    defer func() {
        log.Info(`... Closing session store`)
        sessionStore.Close()
    }()

    // Ensure the cleanup tick starts now (this will delete expired sessions from the DB)
    quitCleanup, doneCleanup := sessionStore.Cleanup(time.Minute * 3)
    defer func() {
        log.Info(`... Cleaning up session store`)
        sessionStore.StopCleanup(quitCleanup, doneCleanup)
    }()
    log.Info("Session store initialized.")

    // Create session data store
    sessionDataStore, err := sessiondata.NewPostgresSessionDataRepository(databaseConnectionPool)
    if err != nil {
        log.Fatal(err)
    }

    // Session Data Store: Ensure the cleanup tick starts now (this will delete expired session data from the DB)
    dataQuitCleanup, dataDoneCleanup := sessionDataStore.Cleanup(time.Minute * 3)
    defer func() {
        log.Info(`... Cleaning up session data store`)
        sessionDataStore.StopCleanup(dataQuitCleanup, dataDoneCleanup)
    }()
    log.Info("Session data store initialized.")

    // Setup the global interface for the proxy
    portalProxy := newPortalProxy(portalConfig, databaseConnectionPool, sessionStore, sessionStoreOptions, envLookup)
    portalProxy.SessionDataStore = sessionDataStore

    store := factory.NewDefaultStoreFactory(databaseConnectionPool)
    portalProxy.SetStoreFactory(store)

    log.Info("Initialization complete.")

    c := make(chan os.Signal, 2)
    signal.Notify(c, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-c
        // Print a newline - if you pressed CTRL+C, the alighment will be slightly out, so start a new line first
        fmt.Println()
        log.Info("Attempting to shut down gracefully...")

        // Database connection pool
        log.Info(`... Closing database connection pool`)
        databaseConnectionPool.Close()

        // Session store
        log.Info(`... Closing session store`)
        sessionStore.Close()
        log.Info(`... Stopping sessionStore cleanup`)
        sessionStore.StopCleanup(quitCleanup, doneCleanup)

        // Session Data Store
        log.Info(`... Stopping sessiondata store cleanup`)
        sessionDataStore.StopCleanup(dataQuitCleanup, dataDoneCleanup)

        // Plugin cleanup
        for _, plugin := range portalProxy.Plugins {
            if pCleanup, ok := plugin.(interfaces.StratosPluginCleanup); ok {
                pCleanup.Destroy()
            }
        }

        log.Info("Graceful shut down complete")
        os.Exit(1)
    }()

    // Initialise configuration
    err = initialiseConsoleConfiguration(portalProxy)
    if err != nil {
        log.Infof("Failed to initialise console config due to: %s", err)
        return
    }

    // Init auth service
    err = portalProxy.InitStratosAuthService(interfaces.AuthEndpointTypes[portalProxy.Config.AuthEndpointType])
    if err != nil {
        log.Warnf("Defaulting to UAA authentication: %v", err)
        err = portalProxy.InitStratosAuthService(interfaces.Remote)
        if err != nil {
            log.Fatalf("Could not initialise auth service. %v", err)
        }
    }

    // Initialise Plugins
    portalProxy.loadPlugins()

    initedPlugins := make(map[string]interfaces.StratosPlugin)
    portalProxy.PluginsStatus = make(map[string]bool)

    // Initialise general plugins
    for name, plugin := range portalProxy.Plugins {
        if err = plugin.Init(); err == nil {
            initedPlugins[name] = plugin
            portalProxy.PluginsStatus[name] = true
        } else {
            log.Infof("Plugin %s is disabled: %s", name, err.Error())
            portalProxy.PluginsStatus[name] = false
        }
    }

    portalProxy.Plugins = initedPlugins
    log.Info("Plugins initialized")

    var needSetupMiddleware bool

    // At this stage, all plugins have had a chance to modify configurtion based on hosting environment
    // Check to see if we are setup or not
    if !portalProxy.Config.ConsoleConfig.IsSetupComplete() {
        needSetupMiddleware = true
        log.Info("Console does not have a complete configuration - going to enter setup mode (adding `setup` route and middleware)")
    } else {
        needSetupMiddleware = false
        showStratosConfig(portalProxy, portalProxy.Config.ConsoleConfig)
        showSSOConfig(portalProxy)
    }

    // Get Diagnostics and store them once - ensure this is done after plugins are loaded
    portalProxy.StoreDiagnostics()

    // Start the back-end
    if err := start(portalProxy.Config, portalProxy, needSetupMiddleware, false, envLookup); err != nil {
        log.Fatalf("Unable to start: %v", err)
    }
    log.Info("Unable to start Stratos JetStream backend")

}

// GetDatabaseConnection makes db connection available to plugins
func (portalProxy *portalProxy) GetDatabaseConnection() *sql.DB {
    return portalProxy.DatabaseConnectionPool
}

// GetSessionDataStore returns the store that can be used for extra session data
func (portalProxy *portalProxy) GetSessionDataStore() interfaces.SessionDataStore {
    return portalProxy.SessionDataStore
}

func (portalProxy *portalProxy) GetPlugin(name string) interface{} {
    plugin := portalProxy.Plugins[name]
    return plugin
}

func initialiseConsoleConfiguration(portalProxy *portalProxy) error {

    consoleRepo, err := console_config.NewPostgresConsoleConfigRepository(portalProxy.DatabaseConnectionPool)
    if err != nil {
        log.Errorf("Unable to initialize Stratos backend config due to: %+v", err)
        return err
    }

    // Do this BEFORE we load the config from the database, so env var lookup at this stage
    // looks at environment variables etc but NOT the database
    // Migrate data from old setup table to new config table (if needed)
    err = console_config.MigrateSetupData(portalProxy, consoleRepo)
    if err != nil {
        log.Warnf("Unable to initialize config environment provider: %+v", err)
    }

    // Load config stored in the database
    err = console_config.InitializeConfEnvProvider(consoleRepo)
    if err != nil {
        log.Warnf("Unable to load configuration from database: %+v", err)
    }

    // Now that the config DB is an env provider, we can just use the env to fetch the setup values
    consoleConfig, err := portalProxy.initialiseConsoleConfig(portalProxy.Env())
    if err != nil {
        // Could not read config - this should not happen - so abort if it does
        log.Fatalf("Unable to load console config; %+v", err)
    }

    if consoleConfig.IsSetupComplete() {
        portalProxy.Config.ConsoleConfig = consoleConfig
        portalProxy.Config.SSOLogin = consoleConfig.UseSSO
        portalProxy.Config.AuthEndpointType = consoleConfig.AuthEndpointType
    }

    return nil
}

func showStratosConfig(portalProxy *portalProxy, config *interfaces.ConsoleConfig) {
    log.Infof("Stratos is initialized with the following setup:")
    log.Infof("... Auth Endpoint Type      : %s", config.AuthEndpointType)

    // Ask the auto provider to display their config
    portalProxy.StratosAuthService.ShowConfig(config)

    log.Infof("... Skip SSL Validation     : %t", config.SkipSSLValidation)
    log.Infof("... Setup Complete          : %t", config.IsSetupComplete())
}

func showSSOConfig(portalProxy *portalProxy) {
    // Show SSO Configuration
    log.Infof("SSO Configuration:")
    log.Infof("... SSO Enabled             : %t", portalProxy.Config.SSOLogin)
    log.Infof("... SSO Options             : %s", portalProxy.Config.SSOOptions)
    log.Infof("... SSO Redirect Allow-list : %s", portalProxy.Config.SSOAllowList)
}

func getEncryptionKey(pc interfaces.PortalConfig) ([]byte, error) {
    log.Debug("getEncryptionKey")

    // If it exists in "EncryptionKey" we must be in compose; use it.
    if len(pc.EncryptionKey) > 0 {
        key32bytes, err := hex.DecodeString(string(pc.EncryptionKey))
        if err != nil {
            log.Error(err)
        }

        return key32bytes, nil
    }

    // Check we have volume and filename
    if len(pc.EncryptionKeyVolume) == 0 && len(pc.EncryptionKeyFilename) == 0 {
        return nil, errors.New("You must configure either an Encryption key or the Encryption key filename")
    }

    // Read the key from the shared volume
    key, err := crypto.ReadEncryptionKey(pc.EncryptionKeyVolume, pc.EncryptionKeyFilename)
    if err != nil {
        log.Errorf("Unable to read the encryption key from the shared volume: %v", err)
        return nil, err
    }

    return key, nil
}

func initConnPool(dc datastore.DatabaseConfig, env *env.VarSet) (*sql.DB, *goose.DBConf, error) {
    log.Debug("initConnPool")

    // initialize the database connection pool
    pool, conf, err := datastore.GetConnection(dc, env)
    if err != nil {
        return nil, nil, err
    }

    // Ensure that the database is responsive
    for {

        // establish an outer timeout boundary
        timeout := time.Now().Add(time.Minute * TimeoutBoundary)

        // Ping the database
        err = datastore.Ping(pool)
        if err == nil {
            log.Info("Database appears to now be available.")
            break
        }

        // If our timeout boundary has been exceeded, bail out
        if timeout.Sub(time.Now()) < 0 {
            return nil, nil, fmt.Errorf("timeout boundary of %d minutes has been exceeded. Exiting", TimeoutBoundary)
        }

        // Circle back and try again
        log.Infof("Waiting for database to be responsive: %+v", err)
        time.Sleep(time.Second)
    }

    return pool, conf, nil
}

func initSessionStore(db *sql.DB, databaseProvider string, pc interfaces.PortalConfig, sessionExpiry int, env *env.VarSet) (HttpSessionStore, *sessions.Options, error) {
    log.Debug("initSessionStore")

    sessionsTable := "sessions"

    // Allow the cookie domain to be configured
    domain := pc.CookieDomain
    if domain == "-" {
        domain = ""
    }

    log.Infof("Session Cookie Domain: %s", domain)

    // Store depends on the DB Type
    if databaseProvider == datastore.PGSQL {
        log.Info("Creating Postgres session store")
        sessionStore, err := pgstore.NewPGStoreFromPool(db, []byte(pc.SessionStoreSecret))
        // Setup cookie-store options
        sessionStore.Options.MaxAge = sessionExpiry
        sessionStore.Options.HttpOnly = true
        sessionStore.Options.Secure = true
        if len(domain) > 0 {
            sessionStore.Options.Domain = domain
        }
        return sessionStore, sessionStore.Options, err
    }
    // Store depends on the DB Type
    if databaseProvider == datastore.MYSQL {
        log.Info("Creating MySQL session store")
        sessionStore, err := mysqlstore.NewMySQLStoreFromConnection(db, sessionsTable, "/", 3600, []byte(pc.SessionStoreSecret))
        // Setup cookie-store options
        sessionStore.Options.MaxAge = sessionExpiry
        sessionStore.Options.HttpOnly = true
        sessionStore.Options.Secure = true
        if len(domain) > 0 {
            sessionStore.Options.Domain = domain
        }
        return sessionStore, sessionStore.Options, err
    }

    log.Info("Creating SQLite session store")
    sessionStore, err := sqlitestore.NewSqliteStoreFromConnection(db, sessionsTable, "/", 3600, []byte(pc.SessionStoreSecret))
    // Setup cookie-store options
    sessionStore.Options.MaxAge = sessionExpiry
    sessionStore.Options.HttpOnly = true
    sessionStore.Options.Secure = true
    if len(domain) > 0 {
        sessionStore.Options.Domain = domain
    }
    return sessionStore, sessionStore.Options, err
}

func loadPortalConfig(pc interfaces.PortalConfig, env *env.VarSet) (interfaces.PortalConfig, error) {
    log.Debug("loadPortalConfig")

    if err := config.Load(&pc, env.Lookup); err != nil {
        return pc, fmt.Errorf("Unable to load configuration. %v", err)
    }

    // Add custom properties
    pc.CFAdminIdentifier = CFAdminIdentifier
    pc.HTTPS = true
    pc.PluginConfig = make(map[string]string)

    // Default to standard timeout if the mutating one is not configured
    if pc.HTTPClientTimeoutMutatingInSecs == 0 {
        pc.HTTPClientTimeoutMutatingInSecs = pc.HTTPClientTimeoutInSecs
    }

    if len(pc.AuthEndpointType) == 0 {
        //Default to "remote" if AUTH_ENDPOINT_TYPE is not set
        pc.AuthEndpointType = string(interfaces.Remote)
    } else {
        val, endpointTypeSupported := interfaces.AuthEndpointTypes[pc.AuthEndpointType]
        if endpointTypeSupported {
            pc.AuthEndpointType = string(val)
        } else {
            return pc, fmt.Errorf("AUTH_ENDPOINT_TYPE: '%v' is not valid. Must be set to local or remote (defaults to remote)", pc.AuthEndpointType)
        }
    }

    log.Debugf("Portal config auth endpoint type initialised to: %v", pc.AuthEndpointType)
    return pc, nil
}

func loadDatabaseConfig(dc datastore.DatabaseConfig, env *env.VarSet) (datastore.DatabaseConfig, error) {
    log.Debug("loadDatabaseConfig")

    parsedDBConfig, err := datastore.ParseCFEnvs(&dc, env)
    if err != nil {
        return dc, errors.New("Could not parse Cloud Foundry Services environment")
    }

    if parsedDBConfig {
        log.Info("Using Cloud Foundry DB service")
    } else if err := config.Load(&dc, env.Lookup); err != nil {
        return dc, fmt.Errorf("Unable to load database configuration. %v", err)
    }

    dc, err = datastore.NewDatabaseConnectionParametersFromConfig(dc)
    if err != nil {
        return dc, fmt.Errorf("Unable to load database configuration. %v", err)
    }

    return dc, nil
}

func detectTLSCert(pc interfaces.PortalConfig) (string, string, error) {
    log.Debug("detectTLSCert")
    certFilename := "pproxy.crt"
    certKeyFilename := "pproxy.key"

    // If there's a developer cert/key, use that instead of using what's in the
    // config. This is to bypass an issue with docker-compose not being able to
    // handle multi-line variables in an env_file
    devCertsDir := "dev-certs/"
    _, errDevcert := os.Stat(devCertsDir + certFilename)
    _, errDevkey := os.Stat(devCertsDir + certKeyFilename)
    if errDevcert == nil && errDevkey == nil {
        return devCertsDir + certFilename, devCertsDir + certKeyFilename, nil
    }

    // Check if certificate have been provided as files (as is the case in kubernetes)
    if pc.TLSCertPath != "" && pc.TLSCertKeyPath != "" {
        log.Infof("Using TLS cert: %s, %s", pc.TLSCertPath, pc.TLSCertKeyPath)
        _, errCertMissing := os.Stat(pc.TLSCertPath)
        _, errCertKeyMissing := os.Stat(pc.TLSCertKeyPath)
        if errCertMissing != nil || errCertKeyMissing != nil {
            return "", "", fmt.Errorf("unable to find certificate %s or certificate key %s", pc.TLSCertPath, pc.TLSCertKeyPath)
        }
        return pc.TLSCertPath, pc.TLSCertKeyPath, nil
    }

    err := ioutil.WriteFile(certFilename, []byte(pc.TLSCert), 0600)
    if err != nil {
        return "", "", err
    }

    err = ioutil.WriteFile(certKeyFilename, []byte(pc.TLSCertKey), 0600)
    if err != nil {
        return "", "", err
    }
    return certFilename, certKeyFilename, nil
}

func newPortalProxy(pc interfaces.PortalConfig, dcp *sql.DB, ss HttpSessionStore, sessionStoreOptions *sessions.Options, env *env.VarSet) *portalProxy {
    log.Debug("newPortalProxy")

    // Generate cookie name - avoids issues if the cookie domain is changed
    cookieName := jetstreamSessionName
    domain := pc.CookieDomain
    if len(domain) > 0 && domain != "-" {
        h := sha1.New()
        io.WriteString(h, domain)
        hash := fmt.Sprintf("%x", h.Sum(nil))
        cookieName = fmt.Sprintf("%s-%s", jetstreamSessionName, hash[0:10])
    }

    log.Infof("Session Cookie name: %s", cookieName)

    // Setting default value for APIKeysEnabled
    if pc.APIKeysEnabled == "" {
        log.Info(`APIKeysEnabled not set, setting to "admin_only"`)
        pc.APIKeysEnabled = config.APIKeysConfigEnum.AdminOnly
    }

    // Setting default value for UserEndpointsEnabled
    if pc.UserEndpointsEnabled == "" {
        log.Info(`UserEndpointsEnabled not set, setting to "disabled"`)
        pc.UserEndpointsEnabled = config.UserEndpointsConfigEnum.Disabled
    }

    pp := &portalProxy{
        Config:                 pc,
        DatabaseConnectionPool: dcp,
        SessionStore:           ss,
        SessionStoreOptions:    sessionStoreOptions,
        SessionCookieName:      cookieName,
        EmptyCookieMatcher:     regexp.MustCompile(cookieName + "=(?:;[ ]*|$)"),
        AuthProviders:          make(map[string]interfaces.AuthProvider),
        env:                    env,
    }

    // Initialize built-in auth providers

    // Basic Auth
    pp.AddAuthProvider(interfaces.AuthTypeHttpBasic, interfaces.AuthProvider{
        Handler:  pp.doHttpBasicFlowRequest,
        UserInfo: pp.GetCNSIUserFromBasicToken,
    })

    // No authentication
    pp.AddAuthProvider(interfaces.AuthConnectTypeNone, interfaces.AuthProvider{
        Handler:  pp.doNoAuthFlowRequest,
        UserInfo: pp.getCNSIUserForNoAuth,
    })

    // Generic Bearer Auth (HTTP Authorization header with 'bearer' prefix)
    pp.AddAuthProvider(interfaces.AuthTypeBearer, interfaces.AuthProvider{
        Handler: pp.doBearerFlowRequest,
        UserInfo: func(cnsiGUID string, cfTokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) {
            // don't fetch user info for the generic token auth
            return &interfaces.ConnectedUser{
                Name: cfTokenRecord.RefreshToken,
                GUID: cfTokenRecord.RefreshToken,
            }, true
        },
    })

    // Generic Token Auth (HTTP Authorization header with 'token' prefix)
    pp.AddAuthProvider(interfaces.AuthTypeToken, interfaces.AuthProvider{
        Handler: pp.doTokenFlowRequest,
        UserInfo: func(cnsiGUID string, cfTokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) {
            // don't fetch user info for the generic token auth
            return &interfaces.ConnectedUser{
                Name: cfTokenRecord.RefreshToken,
                GUID: cfTokenRecord.RefreshToken,
            }, true
        },
    })

    // OIDC
    pp.AddAuthProvider(interfaces.AuthTypeOIDC, interfaces.AuthProvider{
        Handler: pp.DoOidcFlowRequest,
    })

    var err error
    pp.APIKeysRepository, err = apikeys.NewPgsqlAPIKeysRepository(pp.DatabaseConnectionPool)
    if err != nil {
        panic(fmt.Errorf("Can't initialize APIKeysRepository: %v", err))
    }

    return pp
}

func initializeHTTPClients(timeout int64, timeoutMutating int64, connectionTimeout int64) {
    log.Debug("initializeHTTPClients")

    // Common KeepAlive dialer shared by both transports
    dial := (&net.Dialer{
        Timeout:   time.Duration(connectionTimeout) * time.Second,
        KeepAlive: 30 * time.Second, // should be less than any proxy connection timeout (typically 2-3 minutes)
    }).Dial

    tr := &http.Transport{
        Proxy:               http.ProxyFromEnvironment,
        Dial:                dial,
        TLSHandshakeTimeout: 10 * time.Second, // 10 seconds is a sound default value (default is 0)
        TLSClientConfig:     &tls.Config{InsecureSkipVerify: false},
        MaxIdleConnsPerHost: 6, // (default is 2)
    }
    httpClient.Transport = tr
    httpClient.Timeout = time.Duration(timeout) * time.Second

    trSkipSSL := &http.Transport{
        Proxy:               http.ProxyFromEnvironment,
        Dial:                dial,
        TLSHandshakeTimeout: 10 * time.Second, // 10 seconds is a sound default value (default is 0)
        TLSClientConfig:     &tls.Config{InsecureSkipVerify: true},
        MaxIdleConnsPerHost: 6, // (default is 2)
    }

    httpClientSkipSSL.Transport = trSkipSSL
    httpClientSkipSSL.Timeout = time.Duration(timeout) * time.Second

    // Clients with longer timeouts (use for mutating operations)
    httpClientMutating.Transport = tr
    httpClientMutating.Timeout = time.Duration(timeoutMutating) * time.Second
    httpClientMutatingSkipSSL.Transport = trSkipSSL
    httpClientMutatingSkipSSL.Timeout = time.Duration(timeoutMutating) * time.Second
}

func echoShouldNotLog(ec echo.Context) bool {
    // Don't log readiness probes
    if ec.Request().RequestURI == "/pp/v1/ping" {
        return true
    }
    return false
}

func start(config interfaces.PortalConfig, p *portalProxy, needSetupMiddleware bool, isUpgrade bool, envLookup *env.VarSet) error {
    log.Debug("start")
    e := echo.New()
    e.HideBanner = true
    e.HidePort = true

    e.Binder = new(custombinder.CustomBinder)

    // Root level middleware
    if !isUpgrade {
        e.Use(sessionCleanupMiddleware)
    }

    logAPIRequests := "true"
    if envLogAPIRequests, ok := envLookup.Lookup(LogAPIRequests); ok {
        logAPIRequests = envLogAPIRequests
    }
    if logAPIRequests == "true" {
        customLoggerConfig := middleware.LoggerConfig{
            Format: `Request: [${time_rfc3339}] Remote-IP:"${remote_ip}" ` +
                `Method:"${method}" Path:"${path}" Status:${status} Latency:${latency_human} ` +
                `Bytes-In:${bytes_in} Bytes-Out:${bytes_out}` + "\n",
        }
        customLoggerConfig.Skipper = echoShouldNotLog

        e.Use(middleware.LoggerWithConfig(customLoggerConfig))
    } else {
        log.Warn("Disabled logging of API requests received by Jetstream")
    }

    e.Use(middleware.Recover())
    e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins:     config.AllowedOrigins,
        AllowMethods:     []string{echo.GET, echo.PUT, echo.POST, echo.DELETE},
        AllowCredentials: true,
    }))
    e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
        XFrameOptions: "SAMEORIGIN",
    }))

    if !isUpgrade {
        e.Use(errorLoggingMiddleware)
    }
    e.Use(bindToEnv(retryAfterUpgradeMiddleware, p.Env()))

    if !isUpgrade {
        p.registerRoutes(e, needSetupMiddleware)
    }

    if isUpgrade {
        go stopEchoWhenUpgraded(e, p.Env())
    }

    var engineErr error
    address := config.TLSAddress
    if config.HTTPS {
        certFile, certKeyFile, err := detectTLSCert(config)
        if err != nil {
            return err
        }
        log.Infof("Starting HTTPS Server at address: %s", address)
        engineErr = e.StartTLS(address, certFile, certKeyFile)
    } else {
        log.Infof("Starting HTTP Server at address: %s", address)
        engineErr = e.Start(address)
    }

    if engineErr != nil {
        engineErrStr := fmt.Sprintf("%s", engineErr)
        if !strings.Contains(engineErrStr, "Server closed") {
            log.Warnf("Failed to start HTTP/S server: %+v", engineErr)
        }
    }

    return nil
}

func (p *portalProxy) GetEndpointTypeSpec(typeName string) (interfaces.EndpointPlugin, error) {

    for _, plugin := range p.Plugins {
        endpointPlugin, err := plugin.GetEndpointPlugin()
        if err != nil {
            // Plugin doesn't implement an Endpoint Plugin interface, skip
            continue
        }
        endpointType := endpointPlugin.GetType()

        if endpointType == typeName {
            return endpointPlugin, nil
        }
    }

    return nil, errors.New("Endpoint type plugin not loaded")
}

func (p *portalProxy) GetHttpClient(skipSSLValidation bool) http.Client {
    return p.getHttpClient(skipSSLValidation, false)
}

// GetHttpClientForRequest returns an Http Client for the giving request
func (p *portalProxy) GetHttpClientForRequest(req *http.Request, skipSSLValidation bool) http.Client {
    isMutating := req.Method != "GET" && req.Method != "HEAD"
    client := p.getHttpClient(skipSSLValidation, isMutating)

    // Is this is a long-running request, then use a different timeout
    if req.Header.Get(longRunningTimeoutHeader) == "true" {
        longRunningClient := http.Client{}
        longRunningClient.Transport = client.Transport
        longRunningClient.Timeout = time.Duration(p.GetConfig().HTTPClientTimeoutLongRunningInSecs) * time.Second
        return longRunningClient
    }

    return client
}

func (p *portalProxy) getHttpClient(skipSSLValidation bool, mutating bool) http.Client {
    var client http.Client
    if !mutating {
        if skipSSLValidation {
            client = httpClientSkipSSL
        } else {
            client = httpClient
        }
    } else {
        if skipSSLValidation {
            client = httpClientMutatingSkipSSL
        } else {
            client = httpClientMutating
        }
    }
    return client
}

// routes endpoint registration requests to Register functions of respective plugins
// based on endpoint_type parameter

// pluginRegisterRouter godoc
// @Summary Register endpoint
// @Description
// @Tags admin
// @Accept    x-www-form-urlencoded
// @Produce    json
// @Param endpoint_type formData string true "Endpoint type"
// @Param cnsi_name formData string true "Endpoint name"
// @Param api_endpoint formData string true "Endpoint URL"
// @Param skip_ssl_validation formData string false "Skip SSL validation" Enums(true, false)
// @Param sso_allowed formData string false "SSO allowed" Enums(true, false)
// @Param cnsi_client_id formData string false "Client ID"
// @Param cnsi_client_secret formData string false "Client secret"
// @Param sub_type formData string false "Endpoint subtype"
// @Success 200 {object} interfaces.CNSIRecord "Endpoint object"
// @Failure 400 {object} interfaces.ErrorResponseBody "Error response"
// @Failure 401 {object} interfaces.ErrorResponseBody "Error response"
// @Security ApiKeyAuth
// @Router /endpoints [post]
func (p *portalProxy) pluginRegisterRouter(c echo.Context) error {
    log.Debug("pluginRegisterRouter")

    params := new(interfaces.RegisterEndpointParams)
    err := interfaces.BindOnce(params, c)
    if err != nil {
        return err
    }

    if params.EndpointType == "" {
        return errors.New("endpoint_type parameter is missing")
    }

    if val, ok := p.PluginRegisterRoutes[params.EndpointType]; ok {
        log.Debugf("Routing to plugin: %s.Register", params.EndpointType)
        return val(c)
    }

    return fmt.Errorf("Unknown endpoint_type %s", params.EndpointType)
}

func (p *portalProxy) registerRoutes(e *echo.Echo, needSetupMiddleware bool) {
    log.Debug("registerRoutes")

    e.GET("/swagger/*", echoSwagger.WrapHandler)

    for _, plugin := range p.Plugins {
        middlewarePlugin, err := plugin.GetMiddlewarePlugin()
        if err != nil {
            // Plugin doesn't implement an middleware Plugin interface, skip
            continue
        }
        e.Use(middlewarePlugin.EchoMiddleware)
    }

    staticDir, staticDirErr := getStaticFiles(p.Env().String("UI_PATH", "./ui"))

    api := e.Group("/api")
    api.Use(p.setSecureCacheContentMiddleware)

    // Verify Session
    api.GET("/v1/auth/verify", p.verifySession)

    // Always serve the backend API from /pp
    pp := e.Group("/pp")

    pp.Use(p.setSecureCacheContentMiddleware)

    // Add middleware to block requests if unconfigured
    if needSetupMiddleware {
        e.Use(p.SetupMiddleware())
        pp.POST("/v1/setup/check", p.setupGetAvailableScopes)
        pp.POST("/v1/setup/save", p.setupSaveConfig)
    }

    loginAuthGroup := pp.Group("/v1/auth")
    loginAuthGroup.POST("/login/uaa", p.consoleLogin)
    loginAuthGroup.POST("/logout", p.consoleLogout)

    // SSO Routes will only respond if SSO is enabled
    loginAuthGroup.GET("/sso_login", p.initSSOlogin)
    loginAuthGroup.GET("/sso_logout", p.ssoLogoutOfUAA)

    // Callback is used by both login to Stratos and login to an Endpoint
    loginAuthGroup.GET("/sso_login_callback", p.ssoLoginToUAA)

    // Version info
    pp.GET("/v1/version", p.getVersions)

    // Ping - returns version (but is not logged)
    pp.GET("/v1/ping", p.getVersions)

    // All routes in the session group need the user to be authenticated
    sessionGroup := pp.Group("/v1")
    sessionGroup.Use(p.sessionMiddleware())
    sessionGroup.Use(p.xsrfMiddleware())

    sessionGroup.POST("/api_keys", p.addAPIKey)
    sessionGroup.GET("/api_keys", p.listAPIKeys)
    sessionGroup.DELETE("/api_keys", p.deleteAPIKey)

    for _, plugin := range p.Plugins {
        middlewarePlugin, err := plugin.GetMiddlewarePlugin()
        if err != nil {
            // Plugin doesn't implement an middleware Plugin interface, skip
            continue
        }
        e.Use(middlewarePlugin.SessionEchoMiddleware)
    }

    apiKeyGroupConfig := MiddlewareConfig{Skipper: p.apiKeySkipper}

    // API endpoints with Swagger documentation and accessible with an API key
    stableAPIGroup := api.Group("/v1")
    stableAPIGroup.Use(p.apiKeyMiddleware)
    stableAPIGroup.Use(p.sessionMiddlewareWithConfig(apiKeyGroupConfig))
    stableAPIGroup.Use(p.xsrfMiddlewareWithConfig(apiKeyGroupConfig))

    // Connect to endpoint
    stableAPIGroup.POST("/tokens", p.loginToCNSI)

    // Disconnect endpoint
    stableAPIGroup.DELETE("/tokens/:cnsi_guid", p.logoutOfCNSI)

    // Connect to Endpoint (SSO)
    stableAPIGroup.GET("/tokens", p.ssoLoginToCNSI)

    // CNSI operations
    stableAPIGroup.GET("/endpoints", p.listCNSIs)

    // Proxy single request
    stableAPIGroup.GET("/proxy/:uuid/*", p.ProxySingleRequest)

    sessionAuthGroup := sessionGroup.Group("/auth")

    // Connect to Endpoint (SSO)
    sessionAuthGroup.GET("/tokens", p.ssoLoginToCNSI)

    // Info
    sessionGroup.GET("/info", p.info)

    for _, plugin := range p.Plugins {
        routePlugin, err := plugin.GetRoutePlugin()
        if err != nil {
            // Plugin doesn't implement an Endpoint Plugin interface, skip
            continue
        }
        routePlugin.AddSessionGroupRoutes(sessionGroup)
    }

    // This is used for passthru of requests
    group := sessionGroup.Group("/proxy")
    group.Any("/*", p.proxy)

    // The admin-only routes need to be last as the admin middleware will be
    // applied to any routes below it's instantiation
    adminGroup := sessionGroup
    adminGroup.Use(p.adminMiddleware)

    p.PluginRegisterRoutes = make(map[string]func(echo.Context) error)

    for _, plugin := range p.Plugins {
        endpointPlugin, err := plugin.GetEndpointPlugin()
        if err == nil {
            // Plugin supports endpoint plugin
            p.PluginRegisterRoutes[endpointPlugin.GetType()] = endpointPlugin.Register
        }

        routePlugin, err := plugin.GetRoutePlugin()
        if err == nil {
            routePlugin.AddAdminGroupRoutes(adminGroup)
        }
    }

    // API endpoints with Swagger documentation and accessible with an API key that require admin permissions
    stableAdminAPIGroup := stableAPIGroup

    // If path "/endpoints" is used, then stableAPIGroup.GET("/endpoints", p.listCNSIs) won't be executed anymore
    // static html will be returned instead. That's why we use the path ""
    stableEndpointAdminAPIGroup := stableAdminAPIGroup.Group("")

    if p.GetConfig().UserEndpointsEnabled == config.UserEndpointsConfigEnum.Enabled {
        stableEndpointAdminAPIGroup.Use(p.endpointAdminMiddleware)
        stableEndpointAdminAPIGroup.POST("/endpoints", p.pluginRegisterRouter)
        // Use middleware in route directly, because documentation is faulty
        // Apply middleware to group with .Use() when this issue is resolved:
        // https://github.com/labstack/echo/issues/1519
        stableEndpointAdminAPIGroup.POST("/endpoints/:id", p.updateEndpoint, p.endpointUpdateDeleteMiddleware)
        stableEndpointAdminAPIGroup.DELETE("/endpoints/:id", p.unregisterCluster, p.endpointUpdateDeleteMiddleware)
    } else {
        stableEndpointAdminAPIGroup.Use(p.adminMiddleware)
        stableEndpointAdminAPIGroup.POST("/endpoints", p.pluginRegisterRouter)
        stableEndpointAdminAPIGroup.POST("/endpoints/:id", p.updateEndpoint)
        stableEndpointAdminAPIGroup.DELETE("/endpoints/:id", p.unregisterCluster)
    }

    // sessionGroup.DELETE("/cnsis", p.removeCluster)

    // Serve up static resources
    if staticDirErr == nil {
        e.Use(p.setStaticCacheContentMiddleware)
        log.Debug("Add URL Check Middleware")
        e.Use(p.urlCheckMiddleware)
        e.Group("", middleware.Gzip()).Static("/", staticDir)
        e.HTTPErrorHandler = getUICustomHTTPErrorHandler(staticDir, e.DefaultHTTPErrorHandler)
        log.Info("Serving static UI resources")
    } else {
        // Not serving UI - use V2 Error compatability error handler
        e.HTTPErrorHandler = echoV2DefaultHTTPErrorHandler
    }
}

func (p *portalProxy) AddLoginHook(priority int, function interfaces.LoginHookFunc) error {
    p.GetConfig().LoginHooks = append(p.GetConfig().LoginHooks, interfaces.LoginHook{
        Priority: priority,
        Function: function,
    })
    return nil
}

func (p *portalProxy) ExecuteLoginHooks(c echo.Context) error {
    hooks := p.GetConfig().LoginHooks
    sort.SliceStable(hooks, func(i, j int) bool {
        return hooks[i].Priority < hooks[j].Priority
    })

    erred := false
    for _, hook := range hooks {
        err := hook.Function(c)
        if err != nil {
            erred = true
            log.Errorf("Failed to execute log in hook: %v", err)
        }
    }

    if erred {
        return fmt.Errorf("Failed to execute one or more login hooks")
    }
    return nil
}

// Custom error handler to let Angular app handle application URLs (catches non-backend 404 errors)
func getUICustomHTTPErrorHandler(staticDir string, defaultHandler echo.HTTPErrorHandler) echo.HTTPErrorHandler {
    return func(err error, c echo.Context) {
        code := http.StatusInternalServerError
        if he, ok := err.(*echo.HTTPError); ok {
            code = he.Code
        }

        // If this was not a back-end request and the error code is 404, serve the app and let it route
        if strings.Index(c.Request().RequestURI, "/pp") != 0 && code == 404 {
            c.File(path.Join(staticDir, "index.html"))
            // Let the default handler handle it
            defaultHandler(err, c)
        } else {
            // Use V2 Error compatability error handler
            echoV2DefaultHTTPErrorHandler(err, c)
        }
    }
}

// EchoV2DefaultHTTPErrorHandler ensures we get V2 error behaviour
// i.e. no wrapping in 'message' JSON object
func echoV2DefaultHTTPErrorHandler(err error, c echo.Context) {

    code := http.StatusInternalServerError
    msg := http.StatusText(code)
    if he, ok := err.(*echo.HTTPError); ok {
        code = he.Code
        if msgStr, ok := he.Message.(string); ok {
            msg = msgStr
        } else {
            msg = he.Error()
        }
        if he.Internal != nil {
            err = fmt.Errorf("%v, %v", err, he.Internal)
        }
    }

    // Send response
    if !c.Response().Committed {
        if c.Request().Method == http.MethodHead { // Issue #608
            c.NoContent(code)
        } else {
            c.String(code, msg)
        }
    }

    // Always log error
    if err != nil {
        c.Logger().Error(err)
    }
}

func getStaticFiles(uiFolder string) (string, error) {
    dir, err := filepath.Abs(uiFolder)
    if err == nil {
        // Check if folder exists
        _, err := os.Stat(dir)
        if err == nil || !os.IsNotExist(err) {
            return dir, nil
        }
    }
    return "", errors.New("UI folder not found")
}

func isConsoleUpgrading(env *env.VarSet) bool {

    upgradeVolume, noUpgradeVolumeOK := env.Lookup(UpgradeVolume)
    upgradeLockFile, noUpgradeLockFileNameOK := env.Lookup(UpgradeLockFileName)

    // If any of those properties are not set, consider Console is running in a non-upgradeable environment
    if !noUpgradeVolumeOK || !noUpgradeLockFileNameOK {
        return false
    }

    upgradeLockPath := fmt.Sprintf("/%s/%s", upgradeVolume, upgradeLockFile)
    if string(upgradeVolume[0]) == "/" {
        upgradeLockPath = fmt.Sprintf("%s/%s", upgradeVolume, upgradeLockFile)
    }

    if _, err := os.Stat(upgradeLockPath); err == nil {
        return true
    }
    return false
}

func stopEchoWhenUpgraded(e *echo.Echo, env *env.VarSet) {
    for isConsoleUpgrading(env) {
        time.Sleep(1 * time.Second)
    }
    log.Info("Upgrade has completed! Shutting down Upgrade web server instance")
    e.Close()
}

// GetStoreFactory gets the store factory
func (portalProxy *portalProxy) GetStoreFactory() interfaces.StoreFactory {
    return portalProxy.StoreFactory
}

// SetStoreFactory sets the store factory
func (portalProxy *portalProxy) SetStoreFactory(f interfaces.StoreFactory) interfaces.StoreFactory {
    old := portalProxy.StoreFactory
    portalProxy.StoreFactory = f
    return old
}