Connect MCS with Minio insecure TLS/Custom CAs (#102)
This PR adds support to connect MCS to minio instances running TLS with self-signed certificates or certificates signed by custom Certificate Authorities ``` export MCS_MINIO_SERVER_TLS_ROOT_CAS=file1,file2,file3 ``` Note: TLS Skip Verification is not supported unless there's a clear need for it
This commit is contained in:
@@ -68,6 +68,15 @@ export MCS_MINIO_SERVER=http://localhost:9000
|
||||
./mcs server
|
||||
```
|
||||
|
||||
## Connect MCS to a Minio using TLS and a self-signed certificate
|
||||
|
||||
```
|
||||
...
|
||||
export MCS_MINIO_SERVER_TLS_SKIP_VERIFICATION=on
|
||||
export MCS_MINIO_SERVER=https://localhost:9000
|
||||
./mcs server
|
||||
```
|
||||
|
||||
You can verify that the apis work by doing the request on `localhost:9090/api/v1/...`
|
||||
|
||||
# Contribute to mcs Project
|
||||
|
||||
1
go.sum
1
go.sum
@@ -12,6 +12,7 @@ github.com/Azure/azure-storage-blob-go v0.8.0 h1:53qhf0Oxa0nOjgbDeeYPUeyiNmafAFE
|
||||
github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0=
|
||||
github.com/Azure/go-autorest v11.7.1+incompatible h1:M2YZIajBBVekV86x0rr1443Lc1F/Ylxb9w+5EtSyX3Q=
|
||||
github.com/Azure/go-autorest v11.7.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
|
||||
@@ -44,7 +44,9 @@ func NewAdminClient(url, accessKey, secretKey string) (*madmin.AdminClient, *pro
|
||||
AppName: appName,
|
||||
AppVersion: McsVersion,
|
||||
AppComments: []string{appName, runtime.GOOS, runtime.GOARCH},
|
||||
Insecure: false,
|
||||
})
|
||||
s3Client.SetCustomTransport(STSClient.Transport)
|
||||
if err != nil {
|
||||
return nil, err.Trace(url)
|
||||
}
|
||||
@@ -240,13 +242,15 @@ func newMAdminClient(jwt string) (*madmin.AdminClient, error) {
|
||||
|
||||
// newAdminFromClaims creates a minio admin from Decrypted claims using Assume role credentials
|
||||
func newAdminFromClaims(claims *auth.DecryptedClaims) (*madmin.AdminClient, error) {
|
||||
tlsEnabled := getMinIOEndpointIsSecure()
|
||||
adminClient, err := madmin.NewWithOptions(getMinIOEndpoint(), &madmin.Options{
|
||||
Creds: credentials.NewStaticV4(claims.AccessKeyID, claims.SecretAccessKey, claims.SessionToken),
|
||||
Secure: getMinIOEndpointIsSecure(),
|
||||
Secure: tlsEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminClient.SetCustomTransport(STSClient.Transport)
|
||||
return adminClient, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package restapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
mc "github.com/minio/mc/cmd"
|
||||
@@ -133,13 +134,45 @@ func (c mcsCredentials) Expire() {
|
||||
c.minioCredentials.Expire()
|
||||
}
|
||||
|
||||
// mcsSTSAssumeRole it's a STSAssumeRole wrapper, in general
|
||||
// there's no need to use this struct anywhere else in the project, it's only required
|
||||
// for passing a custom *http.Client to *credentials.STSAssumeRole
|
||||
type mcsSTSAssumeRole struct {
|
||||
stsAssumeRole *credentials.STSAssumeRole
|
||||
}
|
||||
|
||||
func (s mcsSTSAssumeRole) Retrieve() (credentials.Value, error) {
|
||||
return s.stsAssumeRole.Retrieve()
|
||||
}
|
||||
|
||||
func (s mcsSTSAssumeRole) IsExpired() bool {
|
||||
return s.stsAssumeRole.IsExpired()
|
||||
}
|
||||
|
||||
// STSClient contains http.client configuration need it by STSAssumeRole
|
||||
var STSClient = PrepareSTSClient()
|
||||
|
||||
func newMcsCredentials(accessKey, secretKey, location string) (*credentials.Credentials, error) {
|
||||
return credentials.NewSTSAssumeRole(getMinIOServer(), credentials.STSAssumeRoleOptions{
|
||||
stsEndpoint := getMinIOServer()
|
||||
if stsEndpoint == "" {
|
||||
return nil, errors.New("STS endpoint cannot be empty")
|
||||
}
|
||||
if accessKey == "" || secretKey == "" {
|
||||
return nil, errors.New("AssumeRole credentials access/secretkey is mandatory")
|
||||
}
|
||||
opts := credentials.STSAssumeRoleOptions{
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
Location: location,
|
||||
DurationSeconds: xjwt.GetMcsSTSAndJWTDurationInSeconds(),
|
||||
})
|
||||
}
|
||||
stsAssumeRole := &credentials.STSAssumeRole{
|
||||
Client: STSClient,
|
||||
STSEndpoint: stsEndpoint,
|
||||
Options: opts,
|
||||
}
|
||||
mcsSTSWrapper := mcsSTSAssumeRole{stsAssumeRole: stsAssumeRole}
|
||||
return credentials.New(mcsSTSWrapper), nil
|
||||
}
|
||||
|
||||
// getMcsCredentialsFromJWT returns the *minioCredentials.Credentials associated to the
|
||||
@@ -160,14 +193,15 @@ func newMinioClient(jwt string) (*minio.Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminClient, err := minio.NewWithOptions(getMinIOEndpoint(), &minio.Options{
|
||||
minioClient, err := minio.NewWithOptions(getMinIOEndpoint(), &minio.Options{
|
||||
Creds: creds,
|
||||
Secure: getMinIOEndpointIsSecure(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adminClient, nil
|
||||
minioClient.SetCustomTransport(STSClient.Transport)
|
||||
return minioClient, nil
|
||||
}
|
||||
|
||||
// newS3BucketClient creates a new mc S3Client to talk to the server based on a bucket
|
||||
|
||||
@@ -48,7 +48,17 @@ func getSecretKey() string {
|
||||
}
|
||||
|
||||
func getMinIOServer() string {
|
||||
return env.Get(McsMinIOServer, "http://localhost:9000")
|
||||
return strings.TrimSpace(env.Get(McsMinIOServer, "http://localhost:9000"))
|
||||
}
|
||||
|
||||
// If MCS_MINIO_SERVER_TLS_ROOT_CAS is true mcs 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(McsMinIOServerTLSRootCAs, ""))
|
||||
if caCertFileNames == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(caCertFileNames, ",")
|
||||
}
|
||||
|
||||
func getMinIOEndpoint() string {
|
||||
@@ -67,7 +77,7 @@ func getMinIOEndpointIsSecure() bool {
|
||||
if strings.Contains(server, "://") {
|
||||
parts := strings.Split(server, "://")
|
||||
if len(parts) > 1 {
|
||||
if parts[1] == "https" {
|
||||
if parts[0] == "https" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,16 @@ package restapi
|
||||
|
||||
const (
|
||||
// consts for common configuration
|
||||
McsVersion = `0.1.0`
|
||||
McsAccessKey = "MCS_ACCESS_KEY"
|
||||
McsSecretKey = "MCS_SECRET_KEY"
|
||||
McsMinIOServer = "MCS_MINIO_SERVER"
|
||||
McsProductionMode = "MCS_PRODUCTION_MODE"
|
||||
McsHostname = "MCS_HOSTNAME"
|
||||
McsPort = "MCS_PORT"
|
||||
McsTLSHostname = "MCS_TLS_HOSTNAME"
|
||||
McsTLSPort = "MCS_TLS_PORT"
|
||||
McsVersion = `0.1.0`
|
||||
McsAccessKey = "MCS_ACCESS_KEY"
|
||||
McsSecretKey = "MCS_SECRET_KEY"
|
||||
McsMinIOServer = "MCS_MINIO_SERVER"
|
||||
McsMinIOServerTLSRootCAs = "MCS_MINIO_SERVER_TLS_ROOT_CAS"
|
||||
McsProductionMode = "MCS_PRODUCTION_MODE"
|
||||
McsHostname = "MCS_HOSTNAME"
|
||||
McsPort = "MCS_PORT"
|
||||
McsTLSHostname = "MCS_TLS_HOSTNAME"
|
||||
McsTLSPort = "MCS_TLS_PORT"
|
||||
|
||||
// consts for Secure middleware
|
||||
McsSecureAllowedHosts = "MCS_SECURE_ALLOWED_HOSTS"
|
||||
|
||||
95
restapi/tls.go
Normal file
95
restapi/tls.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// This file is part of MinIO Orchestrator
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
certDontExists = "File certificate doesn't exists: %s"
|
||||
)
|
||||
|
||||
func prepareSTSClientTransport() *http.Transport {
|
||||
// This takes github.com/minio/minio/pkg/madmin/transport.go as an example
|
||||
//
|
||||
// DefaultTransport - this default transport is similar to
|
||||
// http.DefaultTransport but with additional param DisableCompression
|
||||
// is set to true to avoid decompressing content with 'gzip' encoding.
|
||||
DefaultTransport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 15 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 1024,
|
||||
MaxIdleConnsPerHost: 1024,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableCompression: true,
|
||||
}
|
||||
// If Minio instance is running with TLS enabled and it's using a self-signed certificate
|
||||
// or a certificate issued by a custom certificate authority we prepare a new custom *http.Transport
|
||||
if getMinIOEndpointIsSecure() {
|
||||
caCertFileNames := getMinioServerTLSRootCAs()
|
||||
tlsConfig := &tls.Config{
|
||||
// Can't use SSLv3 because of POODLE and BEAST
|
||||
// Can't use TLSv1.0 because of POODLE and BEAST using CBC cipher
|
||||
// Can't use TLSv1.1 because of RC4 cipher usage
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
// If root CAs are configured we save them to the http.Client RootCAs store
|
||||
if len(caCertFileNames) > 0 {
|
||||
certs := x509.NewCertPool()
|
||||
for _, caCert := range caCertFileNames {
|
||||
// Validate certificate exists
|
||||
if FileExists(caCert) {
|
||||
pemData, err := ioutil.ReadFile(caCert)
|
||||
if err != nil {
|
||||
// if there was an error reading pem file stop mcs
|
||||
panic(err)
|
||||
}
|
||||
certs.AppendCertsFromPEM(pemData)
|
||||
} else {
|
||||
// if provided cert filename doesn't exists stop mcs
|
||||
panic(fmt.Sprintf(certDontExists, caCert))
|
||||
}
|
||||
}
|
||||
tlsConfig.RootCAs = certs
|
||||
}
|
||||
DefaultTransport.TLSClientConfig = tlsConfig
|
||||
}
|
||||
return DefaultTransport
|
||||
}
|
||||
|
||||
// PrepareSTSClient returns an http.Client with custom configurations need it by *credentials.STSAssumeRole
|
||||
// custom configurations include skipVerification flag, and root CA certificates
|
||||
func PrepareSTSClient() *http.Client {
|
||||
transport := prepareSTSClientTransport()
|
||||
// Return http client with default configuration
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errorGeneric = errors.New("an error occurred, please try again")
|
||||
errorGeneric = errors.New("an error occurred, please try again")
|
||||
errInvalidCredentials = errors.New("invalid Credentials")
|
||||
)
|
||||
|
||||
func registerLoginHandlers(api *operations.McsAPI) {
|
||||
@@ -61,35 +62,35 @@ func registerLoginHandlers(api *operations.McsAPI) {
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidCredentials = errors.New("invalid minioCredentials")
|
||||
|
||||
// login performs a check of minioCredentials against MinIO
|
||||
func login(credentials MCSCredentials) (*string, error) {
|
||||
// try to obtain minioCredentials,
|
||||
tokens, err := credentials.Get()
|
||||
if err != nil {
|
||||
log.Println("error authenticating user", err)
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
// if we made it here, the minioCredentials work, generate a jwt with claims
|
||||
jwt, err := auth.NewJWTWithClaimsForClient(&tokens, getMinIOServer())
|
||||
if err != nil {
|
||||
log.Println("error authenticating user", err)
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
return &jwt, nil
|
||||
}
|
||||
|
||||
func getConfiguredRegion(client MinioAdmin) string {
|
||||
func getConfiguredRegionForLogin(client MinioAdmin) (string, error) {
|
||||
location := ""
|
||||
configuration, err := getConfig(client, "region")
|
||||
if err != nil {
|
||||
log.Println("error obtaining MinIO region:", err)
|
||||
return location
|
||||
return location, errorGeneric
|
||||
}
|
||||
// region is an array of 1 element
|
||||
if len(configuration) > 0 {
|
||||
location = configuration[0].Value
|
||||
}
|
||||
return location
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// getLoginResponse performs login() and serializes it to the handler's output
|
||||
@@ -102,16 +103,18 @@ func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, error) {
|
||||
adminClient := adminClient{client: mAdmin}
|
||||
// obtain the configured MinIO region
|
||||
// need it for user authentication
|
||||
location := getConfiguredRegion(adminClient)
|
||||
location, err := getConfiguredRegionForLogin(adminClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds, err := newMcsCredentials(*lr.AccessKey, *lr.SecretKey, location)
|
||||
if err != nil {
|
||||
log.Println("error login:", err)
|
||||
return nil, err
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
credentials := mcsCredentials{minioCredentials: creds}
|
||||
sessionID, err := login(credentials)
|
||||
if err != nil {
|
||||
log.Println("error login:", err)
|
||||
return nil, err
|
||||
}
|
||||
// serialize output
|
||||
@@ -131,7 +134,8 @@ func getLoginDetailsResponse() (*models.LoginDetails, error) {
|
||||
// initialize new oauth2 client
|
||||
oauth2Client, err := oauth2.NewOauth2ProviderClient(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Println("error getting new oauth2 provider client", err)
|
||||
return nil, errorGeneric
|
||||
}
|
||||
// Validate user against IDP
|
||||
identityProvider := &auth.IdentityProvider{Client: oauth2Client}
|
||||
@@ -147,7 +151,8 @@ func getLoginDetailsResponse() (*models.LoginDetails, error) {
|
||||
func loginOauth2Auth(ctx context.Context, provider *auth.IdentityProvider, code, state string) (*oauth2.User, error) {
|
||||
userIdentity, err := provider.VerifyIdentity(ctx, code, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Println("error validating user identity against idp:", err)
|
||||
return nil, errorGeneric
|
||||
}
|
||||
return userIdentity, nil
|
||||
}
|
||||
@@ -166,8 +171,7 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
|
||||
// Validate user against IDP
|
||||
identity, err := loginOauth2Auth(ctx, identityProvider, *lr.Code, *lr.State)
|
||||
if err != nil {
|
||||
log.Println("error validating user identity against idp:", err)
|
||||
return nil, errorGeneric
|
||||
return nil, err
|
||||
}
|
||||
mAdmin, err := newSuperMAdminClient()
|
||||
if err != nil {
|
||||
@@ -179,7 +183,10 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
|
||||
secretKey := utils.RandomCharString(32)
|
||||
// obtain the configured MinIO region
|
||||
// need it for user authentication
|
||||
location := getConfiguredRegion(adminClient)
|
||||
location, err := getConfiguredRegionForLogin(adminClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// create user in MinIO
|
||||
if _, err := addUser(ctx, adminClient, &accessKey, &secretKey, []string{}); err != nil {
|
||||
log.Println("error adding user:", err)
|
||||
@@ -207,8 +214,7 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
|
||||
credentials := mcsCredentials{minioCredentials: creds}
|
||||
jwt, err := login(credentials)
|
||||
if err != nil {
|
||||
log.Println("error login:", err)
|
||||
return nil, errorGeneric
|
||||
return nil, err
|
||||
}
|
||||
// serialize output
|
||||
loginResponse := &models.LoginResponse{
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestLoginOauth2Auth(t *testing.T) {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if _, err := loginOauth2Auth(ctx, identityProvider, mockCode, mockState); funcAssert.Error(err) {
|
||||
funcAssert.Equal("error", err.Error())
|
||||
funcAssert.Equal("an error occurred, please try again", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +189,8 @@ func Test_getConfiguredRegion(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
tt.mock()
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := getConfiguredRegion(tt.args.client); got != tt.want {
|
||||
t.Errorf("getConfiguredRegion() = %v, want %v", got, tt.want)
|
||||
if got, _ := getConfiguredRegionForLogin(tt.args.client); got != tt.want {
|
||||
t.Errorf("getConfiguredRegionForLogin() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
package restapi
|
||||
|
||||
import "os"
|
||||
|
||||
// DifferenceArrays returns the elements in `a` that aren't in `b`.
|
||||
func DifferenceArrays(a, b []string) []string {
|
||||
mb := make(map[string]struct{}, len(b))
|
||||
@@ -54,3 +56,12 @@ func UniqueKeys(a []string) []string {
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// FileExists verifies if a file exist on the desired location and its not a folder
|
||||
func FileExists(filename string) bool {
|
||||
info, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user