Skip to content

Commit

Permalink
!feature: support multiple S3 backends
Browse files Browse the repository at this point in the history
This implements support for multiple S3 backends. Based on the region that is provided in the request the proxy will select the backend to send the request to.

BREAKING CHANGE: remove AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and FAKES3PP_S3_PROXY_TARGET environment variables in favor of backend-config.yaml which allows specifying similar config per proxy backend.

Same functionality can be achieved but the configuration needs to be specified in backend-config.yaml which is pointed to by environment variable `FAKES3PP_S3_BACKEND_CONFIG`.
  • Loading branch information
Peter Van Bouwel committed Nov 18, 2024
1 parent 13251f5 commit fe35400
Show file tree
Hide file tree
Showing 17 changed files with 401 additions and 108 deletions.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,9 @@ cp -R etc etc.private
```

Next adapt the config under etc.private. To get a minimal working local proxy you need to change at least:
- etc.private/.env.docker
- AWS_ACCESS_KEY_ID: The access key id for the object store that you are proxying
- AWS_SECRET_ACCESS_KEY: The secret access key for the object store that you are proxying
- FAKES3PP_S3_PROXY_TARGET: The hostname of the object store you are proxying (e.g. `s3.waw3-1.cloudferro.com`)
- etc.private/backend-config.yaml
- The example shows config for 2 S3 backends. Remove and add config as per your use case.
- The additional files you can create under etc.private as they get mounted under /etc/fakes3pp

### Production configuration

Expand Down
195 changes: 195 additions & 0 deletions cmd/backendconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package cmd

import (
"errors"
"fmt"
"os"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/spf13/viper"
"sigs.k8s.io/yaml"
)

type backendConfigFileEntry struct {
RegionName string `yaml:"region" json:"region"`
Credentials map[string]string `yaml:"credentials" json:"credentials"`
Endpoint string `yaml:"endpoint" json:"endpoint"`
}


type awsBackendCredentialFile struct {
AccessKey string `yaml:"aws_access_key_id" json:"aws_access_key_id"`
SecretKey string `yaml:"aws_secret_access_key" json:"aws_secret_access_key"`
SessionToken string `yaml:"aws_session_token,omitempty" json:"aws_session_token,omitempty"`
}

//The config file could host different types of credentials. Check cases 1 by one
//and fail if there was no valid type of credentials found
func (entry backendConfigFileEntry) getCredentials() (creds aws.Credentials, err error) {
filePath, ok := entry.Credentials["file"]
if ok {
// We are indeed a file
buf, err := os.ReadFile(filePath)
if err != nil {
return creds, fmt.Errorf("could not read credentials file %s; %s", filePath, err)
}

c := &awsBackendCredentialFile{}
err = yaml.Unmarshal(buf, c)
if err != nil {
return creds, fmt.Errorf("error unmarshalling file %s; %s", filePath, err)
}
if c.AccessKey == "" {
return creds, errors.New("invalid credentials file, missing access key")
}
creds.AccessKeyID = c.AccessKey
if c.SecretKey == "" {
return creds, errors.New("invalid credentials file, missing secret key")
}
creds.SecretAccessKey = c.SecretKey
if c.SessionToken != "" {
creds.SessionToken = c.SessionToken
creds.CanExpire = true
}
return creds, nil
}
return creds, errors.New("unable to find a valid type of credentials")
}

type backendsConfigFile struct {
Backends []backendConfigFileEntry `yaml:"s3backends" json:"s3backends"`
Default string `yaml:"default" json:"default"`
}


func getBackendsConfig() (*backendsConfig, error) {
buf, err := os.ReadFile(viper.GetString(s3BackendConfigFile))
if err != nil {
return nil, err
}
return getBackendsConfigFromBytes(buf)
}

func getBackendsConfigFromBytes(inputBytes []byte) (*backendsConfig, error) {
c := &backendsConfigFile{}
err := yaml.Unmarshal(inputBytes, c)
if err != nil {
return nil, err
}

result := backendsConfig{
backends: map[string]backendConfigEntry{},
}

for _, backendRawCfg := range c.Backends {
backendCfg := backendConfigEntry{}
err = backendCfg.fromBackendConfigFileEntry(backendRawCfg)
if err != nil {
return nil, fmt.Errorf("invalid config %v resulted in %s", backendRawCfg, err)
}
result.backends[backendRawCfg.RegionName] = backendCfg
}

defaultBackend := c.Default
_, defaultExists := result.backends[defaultBackend]
if !defaultExists {
return nil, fmt.Errorf("default backend %s does not exist", defaultBackend)
}
result.defaultBackend = defaultBackend

return &result, err
}

type backendConfigEntry struct {
credentials aws.Credentials
endpoint endpoint
}

//A dedicated type for endpoint allows to have the semantics of endpoints of the config.
//When creating these endpoints we do certain checks so by typing them we can assume these
//checks had passed whenever we encounter an endpoint later on
type endpoint string

func buildEndpoint(uri string) (endpoint, error) {
if !strings.HasPrefix(uri, "https://") && !strings.HasPrefix(uri, "http://") {
return "", errors.New("endpoint URIs must start with https:// or http://")
}
return endpoint(uri), nil
}

//This method is to get rid of the protocol from the endpoint specification
func (e endpoint) getHost() string {
uriString := string(e)
return strings.Split(uriString, "://")[1]
}

//The endpoint base URI is of form protocol://hostname and can be used to identify the backend
//service
func (e endpoint) getBaseURI() string {
return string(e)
}

func (bce *backendConfigEntry) fromBackendConfigFileEntry(input backendConfigFileEntry) error {
endpoint, err := buildEndpoint(input.Endpoint)

Check failure on line 134 in cmd/backendconfig.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to err (ineffassign)
bce.endpoint = endpoint

awsCredentials, err := input.getCredentials()
bce.credentials = awsCredentials
return err
}

type backendsConfig struct {
backends map[string]backendConfigEntry
defaultBackend string
}

func (cfg* backendsConfig) getBackendConfig(backendId string) (cfgEntry backendConfigEntry, err error) {
if cfg == nil {
return cfgEntry, errors.New("backendsConfig not initialised")
}
if backendId == "" {
backendId = cfg.defaultBackend
}
backendCfg, ok := cfg.backends[backendId]
if ok {
return backendCfg, nil
} else {
return cfgEntry, fmt.Errorf("no such backend: %s", backendId)
}
}

//Get credentials for a backendId.
func (cfg *backendsConfig) getBackendCredentials(backendId string) (creds aws.Credentials, err error) {
backendCfg, err := cfg.getBackendConfig(backendId)
if err != nil {
return creds, err
}
creds = backendCfg.credentials
return
}

var globalBackendsConfig *backendsConfig

//Get the server credentials for a specific backend identified by its identifier
//At this time we use the region name and we do support the empty string in case the region
//cannot be determined and the default backend should be used.
func getBackendCredentials(backendId string) (creds aws.Credentials, err error) {
return globalBackendsConfig.getBackendCredentials(backendId)
}

//Get endpoint for a backend. The endpoint contains the protocol and the hostname
//to arrive at the backend.
func (cfg *backendsConfig) getBackendEndpoint(backendId string) (endpoint, error) {
backendCfg, err := cfg.getBackendConfig(backendId)
if err != nil {
return "", err
}
return backendCfg.endpoint, nil
}

//Get endpoint for a backend. The endpoint contains the protocol and the hostname
//to arrive at the backend.
func getBackendEndpoint(backendId string) (endpoint, error) {
return globalBackendsConfig.getBackendEndpoint(backendId)
}
56 changes: 56 additions & 0 deletions cmd/backendconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
)


var testDefaultBackendRegion = "waw3-1"
var testSecondBackendRegion = "eu-nl"
var testDefaultBackendCredentials = aws.Credentials{
AccessKeyID: "fake_key_id",
SecretAccessKey: "fake_secret",
}
var testSecondaryBackendCredentials = aws.Credentials{
AccessKeyID: "fake_key_id_otc",
SecretAccessKey: "fake_secret_otc",
SessionToken: "fakeSessionTokOtc1",
CanExpire: true,
}

func TestLoadingOfExampleConfig(t *testing.T) {
BindEnvVariables(proxys3)
cfg , err := getBackendsConfig()
if err != nil {
t.Error("Could not load S3 backend config")
t.Fail()
}
if cfg.defaultBackend != testDefaultBackendRegion {
t.Errorf("Incorrect default backend. Got %s, Expected %s", cfg.defaultBackend, testDefaultBackendRegion)
}
_, err = cfg.getBackendConfig(testDefaultBackendRegion)
if err != nil {
t.Error("Default backend config is not available")
}
creds1, err := cfg.getBackendCredentials(testDefaultBackendRegion)
if err != nil {
t.Error("Default backend credentials are not available")
}
if creds1 != testDefaultBackendCredentials {
t.Error("Default backend credentials are not correctly loaded")
}

_, err = cfg.getBackendConfig(testSecondBackendRegion)
if err != nil {
t.Error("Secondary backend config is not available")
}
creds2, err := cfg.getBackendCredentials(testSecondBackendRegion)
if err != nil {
t.Error("Secondary backend credentials are not available")
}
if creds2 != testSecondaryBackendCredentials {
t.Error("Secondary backend credentials are not correctly loaded")
}
}
36 changes: 9 additions & 27 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,62 +32,44 @@ func (e envVarDef) shouldBeSetFor(cmd string) (bool) {
}

const(
awsAccessKeyId = "awsAccessKeyId"
awsSecretAccessKey = "awsSecretAccessKey"
s3ProxyFQDN = "s3ProxyFQDN"
s3ProxyPort = "s3ProxyPort"
s3ProxyCertFile = "s3ProxyCertFile"
s3ProxyKeyFile = "s3ProxyKeyFile"
s3ProxyJwtPublicRSAKey = "s3ProxyJwtPublicRSAKey"
s3ProxyJwtPrivateRSAKey = "s3ProxyJwtPrivateRSAKey"
s3ProxyTarget = "s3ProxyTarget"
stsProxyFQDN = "stsProxyFQDN"
stsProxyPort = "stsProxyPort"
stsProxyCertFile = "stsProxyCertFile"
stsProxyKeyFile = "stsProxyKeyFile"
rolePolicyPath = "rolePolicyPath"
secure = "secure"
stsOIDCConfigFile = "stsOIDCConfigFile"
s3BackendConfigFile = "s3BackendConfigFile"
stsMaxDurationSeconds = "stsMaxDurationSeconds"
signedUrlGraceTimeSeconds = "signedUrlGraceTimeSeconds"

//Environment variables are upper cased
//Unless they are wellknown environment variables they should be prefixed
AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY"
FAKES3PP_S3_PROXY_FQDN = "FAKES3PP_S3_PROXY_FQDN"
FAKES3PP_S3_PROXY_PORT = "FAKES3PP_S3_PROXY_PORT"
FAKES3PP_S3_PROXY_CERT_FILE = "FAKES3PP_S3_PROXY_CERT_FILE"
FAKES3PP_S3_PROXY_KEY_FILE = "FAKES3PP_S3_PROXY_KEY_FILE"
FAKES3PP_S3_PROXY_JWT_PUBLIC_RSA_KEY = "FAKES3PP_S3_PROXY_JWT_PUBLIC_RSA_KEY"
FAKES3PP_S3_PROXY_JWT_PRIVATE_RSA_KEY = "FAKES3PP_S3_PROXY_JWT_PRIVATE_RSA_KEY"
FAKES3PP_S3_PROXY_TARGET = "FAKES3PP_S3_PROXY_TARGET"
FAKES3PP_STS_PROXY_FQDN = "FAKES3PP_STS_PROXY_FQDN"
FAKES3PP_STS_PROXY_PORT = "FAKES3PP_STS_PROXY_PORT"
FAKES3PP_STS_PROXY_CERT_FILE = "FAKES3PP_STS_PROXY_CERT_FILE"
FAKES3PP_STS_PROXY_KEY_FILE = "FAKES3PP_STS_PROXY_KEY_FILE"
FAKES3PP_SECURE = "FAKES3PP_SECURE"
FAKES3PP_STS_OIDC_CONFIG = "FAKES3PP_STS_OIDC_CONFIG"
FAKES3PP_S3_BACKEND_CONFIG = "FAKES3PP_S3_BACKEND_CONFIG"
FAKES3PP_ROLE_POLICY_PATH = "FAKES3PP_ROLE_POLICY_PATH"
FAKES3PP_STS_MAX_DURATION_SECONDS = "FAKES3PP_STS_MAX_DURATION_SECONDS"
FAKES3PP_SIGNEDURL_GRACE_TIME_SECONDS = "FAKES3PP_SIGNEDURL_GRACE_TIME_SECONDS"
)

var envVarDefs = []envVarDef{
{
awsAccessKeyId,
AWS_ACCESS_KEY_ID,
true,
"The AWS_ACCESS_KEY_ID that will be used to go to the upstream S3 API.",
[]string{proxys3},
},
{
awsSecretAccessKey,
AWS_SECRET_ACCESS_KEY,
true,
"The AWS_SECRET_ACCESS_KEY that will be used to go to the upstream S3 API.",
[]string{proxys3},
},
{
s3ProxyFQDN,
FAKES3PP_S3_PROXY_FQDN,
Expand Down Expand Up @@ -130,13 +112,6 @@ var envVarDefs = []envVarDef{
"The key file used for signing JWT tokens",
[]string{proxys3, proxysts},
},
{
s3ProxyTarget,
FAKES3PP_S3_PROXY_TARGET,
true,
"The hostname to be used to connect to the backend.",
[]string{proxys3},
},
{
stsProxyFQDN,
FAKES3PP_STS_PROXY_FQDN,
Expand Down Expand Up @@ -179,6 +154,13 @@ var envVarDefs = []envVarDef{
"The configuration of which issuers are trusted for OIDC tokens",
[]string{proxysts},
},
{
s3BackendConfigFile,
FAKES3PP_S3_BACKEND_CONFIG,
true,
"The configuration of the backends that are proxied. See the sample start config for details how to configure these backends",
[]string{proxys3},
},
{
rolePolicyPath,
FAKES3PP_ROLE_POLICY_PATH,
Expand Down
Loading

0 comments on commit fe35400

Please sign in to comment.