src/jetstream/main.go
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
}