From 7529d8686ee2be5fc89aca1d1e529c5c46dc20ac Mon Sep 17 00:00:00 2001 From: Lenin Alevski Date: Tue, 27 Oct 2020 23:03:54 -0700 Subject: [PATCH] SNI support for Console --- README.md | 34 +++++- cmd/console/server.go | 34 ++++-- go.mod | 1 + pkg/certs/certs.go | 222 +++++++++++++++++++++++++++++++++++ pkg/certs/const.go | 34 ++++++ restapi/config.go | 21 ++-- restapi/configure_console.go | 29 +++-- restapi/consts.go | 28 ++--- restapi/tls.go | 28 +---- 9 files changed, 356 insertions(+), 75 deletions(-) create mode 100644 pkg/certs/certs.go create mode 100644 pkg/certs/const.go diff --git a/README.md b/README.md index fb410d27fa..63401402ed 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,41 @@ export CONSOLE_MINIO_SERVER=http://localhost:9000 ./console server ``` +## Run Console with TLS enable + +Copy your `public.crt` and `private.key` to `~/.console/certs`, then: + +```bash +./console server +``` + +Additionally, `Console` has support for multiple certificates, clients can request them using `SNI`. It expects the following structure: + +```bash + certs/ + │ + ├─ public.crt + ├─ private.key + │ + ├─ example.com/ + │ │ + │ ├─ public.crt + │ └─ private.key + └─ foobar.org/ + │ + ├─ public.crt + └─ private.key + ... + +``` + +Therefore, we read all filenames in the cert directory and check +for each directory whether it contains a public.crt and private.key. + ## Connect Console to a Minio using TLS and a self-signed certificate +Copy the MinIO `ca.crt` under `~/.console/certs/CAs`, then: ``` -... -export CONSOLE_MINIO_SERVER_TLS_ROOT_CAS= export CONSOLE_MINIO_SERVER=https://localhost:9000 ./console server ``` diff --git a/cmd/console/server.go b/cmd/console/server.go index 171b50f5f1..8f32fd662f 100644 --- a/cmd/console/server.go +++ b/cmd/console/server.go @@ -20,12 +20,16 @@ import ( "fmt" "log" "os" + "path/filepath" "github.com/go-openapi/loads" "github.com/jessevdk/go-flags" "github.com/minio/cli" + "github.com/minio/console/pkg/certs" "github.com/minio/console/restapi" "github.com/minio/console/restapi/operations" + "github.com/minio/minio/cmd/logger" + certsx "github.com/minio/minio/pkg/certs" ) // starts the server @@ -56,14 +60,9 @@ var serverCmd = cli.Command{ Usage: "HTTPS server port", }, cli.StringFlag{ - Name: "tls-certificate", - Value: "", - Usage: "filename of public cert", - }, - cli.StringFlag{ - Name: "tls-key", - Value: "", - Usage: "filename of private key", + Name: "certs-dir", + Value: certs.GlobalCertsCADir.Get(), + Usage: "path to certs directory", }, }, } @@ -82,7 +81,9 @@ func startServer(ctx *cli.Context) error { parser := flags.NewParser(server, flags.Default) parser.ShortDescription = "MinIO Console Server" parser.LongDescription = swaggerSpec.Spec().Info.Description + server.ConfigureFlags() + for _, optsGroup := range api.CommandLineOptionsGroups { _, err := parser.AddGroup(optsGroup.ShortDescription, optsGroup.LongDescription, optsGroup.Options) if err != nil { @@ -106,12 +107,19 @@ func startServer(ctx *cli.Context) error { restapi.Hostname = ctx.String("host") restapi.Port = fmt.Sprintf("%v", ctx.Int("port")) - tlsCertificatePath := ctx.String("tls-certificate") - tlsCertificateKeyPath := ctx.String("tls-key") + // Set all certs and CAs directories. + globalCertsDir, _ := certs.NewConfigDirFromCtx(ctx, "certs-dir", certs.DefaultCertsDir.Get) + certs.GlobalCertsCADir = &certs.ConfigDir{Path: filepath.Join(globalCertsDir.Get(), certs.CertsCADir)} + logger.FatalIf(certs.MkdirAllIgnorePerm(certs.GlobalCertsCADir.Get()), "Unable to create certs CA directory at %s", certs.GlobalCertsCADir.Get()) + + // load all CAs from ~/.console/certs/CAs + restapi.GlobalRootCAs, err = certsx.GetRootCAs(certs.GlobalCertsCADir.Get()) + logger.FatalIf(err, "Failed to read root CAs (%v)", err) + // load all certs from ~/.console/certs + restapi.GlobalPublicCerts, restapi.GlobalTLSCertsManager, err = certs.GetTLSConfig() + logger.FatalIf(err, "Unable to load the TLS configuration") - if tlsCertificatePath != "" && tlsCertificateKeyPath != "" { - server.TLSCertificate = flags.Filename(tlsCertificatePath) - server.TLSCertificateKey = flags.Filename(tlsCertificateKeyPath) + if len(restapi.GlobalPublicCerts) > 0 && restapi.GlobalRootCAs != nil { // If TLS certificates are provided enforce the HTTPS schema, meaning console will redirect // plain HTTP connections to HTTPS server server.EnabledListeners = []string{"http", "https"} diff --git a/go.mod b/go.mod index 37578e00b4..621e708fdc 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/minio/minio v0.0.0-20200927172404-27d9bd04e544 github.com/minio/minio-go/v7 v7.0.6-0.20200923173112-bc846cb9b089 github.com/minio/operator v0.0.0-20200930213302-ab2bbdfae96c + github.com/mitchellh/go-homedir v1.1.0 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/secure-io/sio-go v0.3.1 github.com/stretchr/testify v1.6.1 diff --git a/pkg/certs/certs.go b/pkg/certs/certs.go new file mode 100644 index 0000000000..a4303468d5 --- /dev/null +++ b/pkg/certs/certs.go @@ -0,0 +1,222 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2020 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package certs + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/minio/cli" + "github.com/minio/minio/cmd/config" + "github.com/minio/minio/cmd/logger" + "github.com/minio/minio/pkg/certs" + "github.com/mitchellh/go-homedir" +) + +type GetCertificateFunc = certs.GetCertificateFunc + +// ConfigDir - points to a user set directory. +type ConfigDir struct { + Path string +} + +// Get - returns current directory. +func (dir *ConfigDir) Get() string { + return dir.Path +} + +func getDefaultConfigDir() string { + homeDir, err := homedir.Dir() + if err != nil { + return "" + } + return filepath.Join(homeDir, DefaultConsoleConfigDir) +} + +func getDefaultCertsDir() string { + return filepath.Join(getDefaultConfigDir(), CertsDir) +} + +func getDefaultCertsCADir() string { + return filepath.Join(getDefaultCertsDir(), CertsCADir) +} + +// isFile - returns whether given Path is a file or not. +func isFile(path string) bool { + if fi, err := os.Stat(path); err == nil { + return fi.Mode().IsRegular() + } + + return false +} + +var ( + // DefaultCertsDir certs directory. + DefaultCertsDir = &ConfigDir{Path: getDefaultCertsDir()} + // DefaultCertsCADir CA directory. + DefaultCertsCADir = &ConfigDir{Path: getDefaultCertsCADir()} + // GlobalCertsDir points to current certs directory set by user with --certs-dir + GlobalCertsDir = DefaultCertsDir + // GlobalCertsCADir points to relative Path to certs directory and is /CAs + GlobalCertsCADir = DefaultCertsCADir +) + +// MkdirAllIgnorePerm attempts to create all directories, ignores any permission denied errors. +func MkdirAllIgnorePerm(path string) error { + err := os.MkdirAll(path, 0700) + if err != nil { + // It is possible in kubernetes like deployments this directory + // is already mounted and is not writable, ignore any write errors. + if os.IsPermission(err) { + err = nil + } + } + return err +} + +func NewConfigDirFromCtx(ctx *cli.Context, option string, getDefaultDir func() string) (*ConfigDir, bool) { + var dir string + var dirSet bool + + switch { + case ctx.IsSet(option): + dir = ctx.String(option) + dirSet = true + case ctx.GlobalIsSet(option): + dir = ctx.GlobalString(option) + dirSet = true + // cli package does not expose parent's option option. Below code is workaround. + if dir == "" || dir == getDefaultDir() { + dirSet = false // Unset to false since GlobalIsSet() true is a false positive. + if ctx.Parent().GlobalIsSet(option) { + dir = ctx.Parent().GlobalString(option) + dirSet = true + } + } + default: + // Neither local nor global option is provided. In this case, try to use + // default directory. + dir = getDefaultDir() + if dir == "" { + logger.FatalIf(errors.New("invalid arguments specified"), "%s option must be provided", option) + } + } + + if dir == "" { + logger.FatalIf(errors.New("empty directory"), "%s directory cannot be empty", option) + } + + // Disallow relative paths, figure out absolute paths. + dirAbs, err := filepath.Abs(dir) + logger.FatalIf(err, "Unable to fetch absolute path for %s=%s", option, dir) + logger.FatalIf(MkdirAllIgnorePerm(dirAbs), "Unable to create directory specified %s=%s", option, dir) + + return &ConfigDir{Path: dirAbs}, dirSet +} + +func getPublicCertFile() string { + return filepath.Join(GlobalCertsDir.Get(), PublicCertFile) +} + +func getPrivateKeyFile() string { + return filepath.Join(GlobalCertsDir.Get(), PrivateKeyFile) +} + +func GetTLSConfig() (x509Certs []*x509.Certificate, manager *certs.Manager, err error) { + + ctx := context.Background() + + if !(isFile(getPublicCertFile()) && isFile(getPrivateKeyFile())) { + return nil, nil, nil + } + + if x509Certs, err = config.ParsePublicCertFile(getPublicCertFile()); err != nil { + return nil, nil, err + } + + manager, err = certs.NewManager(ctx, getPublicCertFile(), getPrivateKeyFile(), config.LoadX509KeyPair) + if err != nil { + return nil, nil, err + } + + //Console has support for multiple certificates. It expects the following structure: + // certs/ + // │ + // ├─ public.crt + // ├─ private.key + // │ + // ├─ example.com/ + // │ │ + // │ ├─ public.crt + // │ └─ private.key + // └─ foobar.org/ + // │ + // ├─ public.crt + // └─ private.key + // ... + // + //Therefore, we read all filenames in the cert directory and check + //for each directory whether it contains a public.crt and private.key. + // If so, we try to add it to certificate manager. + root, err := os.Open(GlobalCertsDir.Get()) + if err != nil { + return nil, nil, err + } + defer root.Close() + + files, err := root.Readdir(-1) + if err != nil { + return nil, nil, err + } + for _, file := range files { + // Ignore all + // - regular files + // - "CAs" directory + // - any directory which starts with ".." + if file.Mode().IsRegular() || file.Name() == "CAs" || strings.HasPrefix(file.Name(), "..") { + continue + } + if file.Mode()&os.ModeSymlink == os.ModeSymlink { + file, err = os.Stat(filepath.Join(root.Name(), file.Name())) + if err != nil { + // not accessible ignore + continue + } + if !file.IsDir() { + continue + } + } + + var ( + certFile = filepath.Join(root.Name(), file.Name(), PublicCertFile) + keyFile = filepath.Join(root.Name(), file.Name(), PrivateKeyFile) + ) + if !isFile(certFile) || !isFile(keyFile) { + continue + } + if err = manager.AddCertificate(certFile, keyFile); err != nil { + err = fmt.Errorf("unable to load TLS certificate '%s,%s': %w", certFile, keyFile, err) + logger.LogIf(ctx, err, logger.Application) + } + } + return x509Certs, manager, nil +} diff --git a/pkg/certs/const.go b/pkg/certs/const.go new file mode 100644 index 0000000000..3dbd7715e5 --- /dev/null +++ b/pkg/certs/const.go @@ -0,0 +1,34 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2020 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package certs + +const ( + // Default minio configuration directory where below configuration files/directories are stored. + DefaultConsoleConfigDir = ".console" + + // Directory contains below files/directories for HTTPS configuration. + CertsDir = "certs" + + // Directory contains all CA certificates other than system defaults for HTTPS. + CertsCADir = "CAs" + + // Public certificate file for HTTPS. + PublicCertFile = "public.crt" + + // Private key file for HTTPS. + PrivateKeyFile = "private.key" +) diff --git a/restapi/config.go b/restapi/config.go index 9811cfad02..b7a380491a 100644 --- a/restapi/config.go +++ b/restapi/config.go @@ -17,10 +17,12 @@ package restapi import ( + "crypto/x509" "fmt" "strconv" "strings" + "github.com/minio/minio/pkg/certs" "github.com/minio/minio/pkg/env" ) @@ -51,16 +53,6 @@ func getMinIOServer() string { return strings.TrimSpace(env.Get(ConsoleMinIOServer, "http://localhost:9000")) } -// If CONSOLE_MINIO_SERVER_TLS_ROOT_CAS is true console will load a list of certificates into the -// http.client rootCAs store, this is useful for testing or when working with self-signed certificates -func getMinioServerTLSRootCAs() []string { - caCertFileNames := strings.TrimSpace(env.Get(ConsoleMinIOServerTLSRootCAs, "")) - if caCertFileNames == "" { - return []string{} - } - return strings.Split(caCertFileNames, ",") -} - func getMinIOEndpoint() string { server := getMinIOServer() if strings.Contains(server, "://") { @@ -228,3 +220,12 @@ func getSecureFeaturePolicy() string { func getSecureExpectCTHeader() string { return env.Get(ConsoleSecureExpectCTHeader, "") } + +var ( + // GlobalRootCAs is CA root certificates, a nil value means system certs pool will be used + GlobalRootCAs *x509.CertPool + // GlobalPublicCerts has certificates Console will use to serve clients + GlobalPublicCerts []*x509.Certificate + // GlobalTLSCertsManager custom TLS Manager for SNI support + GlobalTLSCertsManager *certs.Manager +) diff --git a/restapi/configure_console.go b/restapi/configure_console.go index 40467e40e5..8e28bb8343 100644 --- a/restapi/configure_console.go +++ b/restapi/configure_console.go @@ -26,25 +26,33 @@ import ( "strings" "time" - "github.com/minio/console/pkg/auth" - - "github.com/minio/console/models" - "github.com/minio/console/pkg" - assetFS "github.com/elazarl/go-bindata-assetfs" portalUI "github.com/minio/console/portal-ui" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" + "github.com/go-openapi/swag" + "github.com/minio/console/models" + "github.com/minio/console/pkg" + "github.com/minio/console/pkg/auth" "github.com/minio/console/restapi/operations" "github.com/unrolled/secure" ) //go:generate swagger generate server --target ../../console --name Console --spec ../swagger.yml +var additionalServerFlags = struct { + CertsDir string `long:"certs-dir" description:"path to certs directory" env:"CONSOLE_CERTS_DIR"` +}{} + func configureFlags(api *operations.ConsoleAPI) { - // api.CommandLineOptionsGroups = []swag.CommandLineOptionsGroup{ ... } + api.CommandLineOptionsGroups = []swag.CommandLineOptionsGroup{ + { + ShortDescription: "additional server flags", + Options: &additionalServerFlags, + }, + } } func configureAPI(api *operations.ConsoleAPI) http.Handler { @@ -134,7 +142,14 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler { // The TLS configuration before HTTPS server starts. func configureTLS(tlsConfig *tls.Config) { - // Make all necessary changes to the TLS configuration here. + // Add the global public crts as part of global root CAs + for _, publicCrt := range GlobalPublicCerts { + GlobalRootCAs.AddCert(publicCrt) + } + tlsConfig.RootCAs = GlobalRootCAs + if GlobalTLSCertsManager != nil { + tlsConfig.GetCertificate = GlobalTLSCertsManager.GetCertificate + } } // As soon as server is initialized but not run yet, this function will be called. diff --git a/restapi/consts.go b/restapi/consts.go index 59b31e8642..25a5aa7d08 100644 --- a/restapi/consts.go +++ b/restapi/consts.go @@ -17,19 +17,18 @@ package restapi const ( - // consts for common configuration - ConsoleVersion = `0.1.0` - ConsoleAccessKey = "CONSOLE_ACCESS_KEY" - ConsoleSecretKey = "CONSOLE_SECRET_KEY" - ConsoleMinIOServer = "CONSOLE_MINIO_SERVER" - ConsoleMinIOServerTLSRootCAs = "CONSOLE_MINIO_SERVER_TLS_ROOT_CAS" - ConsoleProductionMode = "CONSOLE_PRODUCTION_MODE" - ConsoleHostname = "CONSOLE_HOSTNAME" - ConsolePort = "CONSOLE_PORT" - ConsoleTLSHostname = "CONSOLE_TLS_HOSTNAME" - ConsoleTLSPort = "CONSOLE_TLS_PORT" + // Constants for common configuration + ConsoleVersion = `0.2.0` + ConsoleAccessKey = "CONSOLE_ACCESS_KEY" + ConsoleSecretKey = "CONSOLE_SECRET_KEY" + ConsoleMinIOServer = "CONSOLE_MINIO_SERVER" + ConsoleProductionMode = "CONSOLE_PRODUCTION_MODE" + ConsoleHostname = "CONSOLE_HOSTNAME" + ConsolePort = "CONSOLE_PORT" + ConsoleTLSHostname = "CONSOLE_TLS_HOSTNAME" + ConsoleTLSPort = "CONSOLE_TLS_PORT" - // consts for Secure middleware + // Constants for Secure middleware ConsoleSecureAllowedHosts = "CONSOLE_SECURE_ALLOWED_HOSTS" ConsoleSecureAllowedHostsAreRegex = "CONSOLE_SECURE_ALLOWED_HOSTS_ARE_REGEX" ConsoleSecureFrameDeny = "CONSOLE_SECURE_FRAME_DENY" @@ -49,11 +48,8 @@ const ( ConsoleSecureReferrerPolicy = "CONSOLE_SECURE_REFERRER_POLICY" ConsoleSecureFeaturePolicy = "CONSOLE_SECURE_FEATURE_POLICY" ConsoleSecureExpectCTHeader = "CONSOLE_SECURE_EXPECT_CT_HEADER" -) - -// prometheus annotations -const ( + // Constants for prometheus annotations prometheusPath = "prometheus.io/path" prometheusPort = "prometheus.io/port" prometheusScrape = "prometheus.io/scrape" diff --git a/restapi/tls.go b/restapi/tls.go index 1f0beadb86..7eab8e0d1e 100644 --- a/restapi/tls.go +++ b/restapi/tls.go @@ -18,37 +18,11 @@ package restapi import ( "crypto/tls" - "crypto/x509" - "io/ioutil" - "log" "net" "net/http" "time" ) -func getCertPool() *x509.CertPool { - rootCAs, _ := x509.SystemCertPool() - if rootCAs == nil { - // In some systems (like Windows) system cert pool is - // not supported or no certificates are present on the - // system - so we create a new cert pool. - rootCAs = x509.NewCertPool() - } - caCertFileNames := getMinioServerTLSRootCAs() - for _, caCert := range caCertFileNames { - pemData, err := ioutil.ReadFile(caCert) - if err != nil { - // logging this error - log.Println(err) - continue - } - rootCAs.AppendCertsFromPEM(pemData) - } - return rootCAs -} - -var certPool = getCertPool() - func prepareSTSClientTransport(insecure bool) *http.Transport { // This takes github.com/minio/minio/pkg/madmin/transport.go as an example // @@ -74,7 +48,7 @@ func prepareSTSClientTransport(insecure bool) *http.Transport { // Can't use TLSv1.1 because of RC4 cipher usage MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure, - RootCAs: certPool, + RootCAs: GlobalRootCAs, }, } return DefaultTransport