diff --git a/.gitignore b/.gitignore index 9ff198d..8ec6883 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ *.log bin/ dist +./server/server +*.env +*.gox diff --git a/config/db.go b/config/db.go new file mode 100644 index 0000000..b40d26c --- /dev/null +++ b/config/db.go @@ -0,0 +1,32 @@ +package config + +import ( + "fmt" + "time" + + // postgres driver library + _ "github.com/lib/pq" +) + +// database configuration for server +type Database struct { + Driver string `default:"postgres"` + Host string `required:"true"` + User string `required:"true"` + Password string `required:"true"` + Port int `default:"5432"` + MaxIdleConns int `split_words:"true" default:"20"` + MaxOpenConns int `split_words:"true" default:"30"` + MaxConnLifetimeMs int `split_words:"true" default:"1000"` + Name string `split_words:"true" required:"true"` + SslMode string `split_words:"true" default:"disable"` + AesKey string `split_words:"true" required:"true"` +} + +func (db Database) MaxConnLifetime() time.Duration { + return time.Millisecond * time.Duration(db.MaxConnLifetimeMs) +} + +func (db Database) URL() string { + return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s sslmode=%s", db.User, db.Password, db.Host, db.Port, db.Name, db.SslMode) +} diff --git a/config/server.go b/config/server.go index 1a73ad3..cec94bf 100644 --- a/config/server.go +++ b/config/server.go @@ -2,12 +2,27 @@ package config import ( "fmt" + "log" "net/url" + + "github.com/kelseyhightower/envconfig" ) +type Application struct { + Server + DB Database +} + +var app Application + type Server struct { - Port int - Host string + DefaultManagedZone string + Port int + Host string +} + +func (app Application) Address() string { + return fmt.Sprintf("%s:%d", app.Server.Host, app.Server.Port) } func (s Server) Address() string { @@ -17,3 +32,15 @@ func (s Server) Address() string { type Backend struct { URL *url.URL } + +func MustLoadServer() Application { + var errs []error + if err := envconfig.Process("", &app); err != nil { + errs = append(errs, err) + } + if len(errs) != 0 { + log.Fatalf("Error loading configuration: %v", errs) + } + log.Println("config loaded successfully") + return app +} diff --git a/go.mod b/go.mod index 4eb3d23..83cb4b0 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,11 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/aws/aws-sdk-go-v2/config v1.23.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.42.1 + github.com/gorilla/handlers v1.5.2 + github.com/gorilla/mux v1.8.1 + github.com/jmoiron/sqlx v1.4.0 github.com/kelseyhightower/envconfig v1.4.0 + github.com/lib/pq v1.10.9 github.com/rs/zerolog v1.29.1 github.com/scalescape/go-metrics v0.0.0-20230825040750-1888415fe69a github.com/stretchr/testify v1.8.4 @@ -16,7 +20,9 @@ require ( google.golang.org/api v0.129.0 ) -require github.com/aws/aws-sdk-go-v2/credentials v1.15.2 // indirect +require github.com/felixge/httpsnoop v1.0.3 // indirect + +require github.com/aws/aws-sdk-go-v2/credentials v1.15.2 require ( cloud.google.com/go v0.110.0 // indirect @@ -47,7 +53,7 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/s2a-go v0.1.4 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.3.0 github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect github.com/googleapis/gax-go/v2 v2.11.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect diff --git a/go.sum b/go.sum index 36cdef9..7d56abf 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7Biccwk dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -127,6 +129,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -139,6 +143,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -211,6 +217,10 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4= github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -218,6 +228,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -243,6 +255,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -250,6 +264,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= diff --git a/server/cerr/errors.go b/server/cerr/errors.go new file mode 100644 index 0000000..2311517 --- /dev/null +++ b/server/cerr/errors.go @@ -0,0 +1,12 @@ +package cerr + +import "errors" + +var ( + ErrNoProjectFound = errors.New("no project found") + ErrInvalidOrg = errors.New("invalid org details") + ErrInvalidEnvironment = errors.New("invalid environment") + ErrInvalidOrgID = errors.New("invalid org id") + ErrInvalidSecretRequest = errors.New("invalid secrets request") + ErrNoSecretFound = errors.New("no secrets found") +) diff --git a/server/cloud/aws/creds.go b/server/cloud/aws/creds.go new file mode 100644 index 0000000..4cc5533 --- /dev/null +++ b/server/cloud/aws/creds.go @@ -0,0 +1,29 @@ +package aws + +import ( + "errors" + "fmt" +) + +var ErrInvalidAWSCredentials = errors.New("invalid AWS credentials") + +type Config struct { + Credentials + Token string + Region string +} + +type Credentials struct { + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` +} + +func (c Credentials) Valid() error { + if c.AccessKeyID == "" { + return fmt.Errorf("invalid access key id: %w", ErrInvalidAWSCredentials) + } + if c.SecretAccessKey == "" { + return fmt.Errorf("invalid access secret key: %w", ErrInvalidAWSCredentials) + } + return nil +} diff --git a/server/cloud/aws/storage.go b/server/cloud/aws/storage.go new file mode 100644 index 0000000..da0f497 --- /dev/null +++ b/server/cloud/aws/storage.go @@ -0,0 +1,123 @@ +package aws + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/rs/zerolog/log" + "github.com/scalescape/dolores/server/cloud/cld" +) + +type StorageClient struct { + client *s3.Client + region string +} + +func (s StorageClient) bucketExists(ctx context.Context, bucketName string) (bool, error) { + _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: aws.String(bucketName), + }) + if err != nil { + var notFoundType *types.NotFound + if errors.As(err, ¬FoundType) { + return false, nil + } + } + return true, err +} + +func (s StorageClient) CreateBucket(ctx context.Context, bucketName string) error { + lconst := types.BucketLocationConstraint(s.region) + cbCfg := &types.CreateBucketConfiguration{LocationConstraint: lconst} + bucket := &s3.CreateBucketInput{Bucket: aws.String(bucketName), + CreateBucketConfiguration: cbCfg} + _, err := s.client.CreateBucket(ctx, bucket) + existsErr := new(types.BucketAlreadyOwnedByYou) + if errors.As(err, &existsErr) { + log.Debug().Msgf("bucket %s already exists", bucketName) + return nil + } + if err != nil { + return fmt.Errorf("error creating bucket: %s at region %s: %w", bucketName, s.region, err) + } + return nil +} + +func (s StorageClient) ListObject(ctx context.Context, bucket, path string) ([]cld.Object, error) { + resp, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + Prefix: aws.String(path), + }) + if err != nil { + return nil, fmt.Errorf("failed to get object list: %w", err) + } + + items := resp.Contents + objs := make([]cld.Object, len(items)) + for i, item := range items { + o := cld.Object{Name: *item.Key, UpdatedAt: *item.LastModified, Bucket: bucket} + objs[i] = o + } + log.Trace().Msgf("list of objects from path: %s length: %+v", path, len(objs)) + return objs, nil +} + +func (s StorageClient) WriteToObject(ctx context.Context, bucketName, fileName string, data []byte) error { + log.Debug().Msgf("writing to %s/%s", bucketName, fileName) + bucketExist, err := s.bucketExists(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to fetch bucket: %w", err) + } + if !bucketExist { + if err := s.CreateBucket(ctx, bucketName); err != nil { + return err + } + } + + fileReader := bytes.NewReader(data) + _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(fileName), + Body: fileReader, + }) + + if err != nil { + return fmt.Errorf("failed to upload secret: %w", err) + } + return nil +} + +func (s StorageClient) ReadObject(ctx context.Context, bucketName, fileName string) ([]byte, error) { + resp, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(fileName), + }) + + if err != nil { + return nil, fmt.Errorf("failed to read object : %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body : %w", err) + } + return data, nil +} + +func NewStorageClient(ctx context.Context, acfg Config) (StorageClient, error) { + cp := credentials.NewStaticCredentialsProvider(acfg.AccessKeyID, acfg.SecretAccessKey, acfg.Token) + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(acfg.Region), config.WithCredentialsProvider(cp)) + if err != nil { + return StorageClient{}, err + } + cli := s3.NewFromConfig(cfg) + return StorageClient{client: cli, region: acfg.Region}, nil +} diff --git a/server/cloud/cld/environment.go b/server/cloud/cld/environment.go new file mode 100644 index 0000000..1d791e4 --- /dev/null +++ b/server/cloud/cld/environment.go @@ -0,0 +1,62 @@ +package cld + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" + "github.com/scalescape/dolores/server/cerr" +) + +const ( + StagingEnv Environment = "staging" + ProductionEnv Environment = "production" + GlobalEnv Environment = "global" + DemoEnv Environment = "demo" + EnvID contextKey = "env" +) + +type Environment string +type contextKey string + +func (e Environment) String() string { return string(e) } + +func (e Environment) Valid() error { + if e != StagingEnv && e != ProductionEnv && e != DemoEnv { + return cerr.ErrInvalidEnvironment + } + return nil +} + +func ParsePathEnv(r *http.Request) (Environment, error) { + environment, ok := mux.Vars(r)[string(EnvID)] + if !ok || environment == "" { + return "", cerr.ErrInvalidEnvironment + } + switch environment { + case "staging": + return StagingEnv, nil + case "demo": + return DemoEnv, nil + case "production": + return ProductionEnv, nil + default: + log.Error().Msgf("failed to parse env from path: %s", environment) + return "", cerr.ErrInvalidEnvironment + } +} + +func ParseQueryEnv(r *http.Request) (Environment, error) { + env := r.URL.Query().Get(string(EnvID)) + switch env { + case "staging": + return StagingEnv, nil + case "production": + return ProductionEnv, nil + case "demo": + return DemoEnv, nil + default: + log.Error().Msgf("failed to parse env from query: %s", env) + return "", cerr.ErrInvalidEnvironment + } +} diff --git a/server/cloud/cld/object.go b/server/cloud/cld/object.go new file mode 100644 index 0000000..3d87b3a --- /dev/null +++ b/server/cloud/cld/object.go @@ -0,0 +1,10 @@ +package cld + +import "time" + +type Object struct { + Name string `json:"name"` + Bucket string `json:"bucket"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/server/cloud/config.go b/server/cloud/config.go new file mode 100644 index 0000000..bd0bc5f --- /dev/null +++ b/server/cloud/config.go @@ -0,0 +1,43 @@ +package cloud + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/scalescape/dolores/server/cloud/aws" +) + +type Platform string + +var ( + GCP Platform = "GCP" + AWS Platform = "AWS" +) + +var ErrInvalidAWSCredentials = errors.New("invalid AWS credentials") + +type Config struct { + OrgID string + CreateManagedZone bool + ProjectID string + Credentials []byte + Platform + Region string +} + +func (c *Config) AWSConfig() (aws.Config, error) { + creds, err := c.AwsCredentials() + if err != nil { + return aws.Config{}, fmt.Errorf("error parsing aws credentials: %w", err) + } + return aws.Config{Credentials: *creds, Region: c.Region}, nil +} + +func (c *Config) AwsCredentials() (*aws.Credentials, error) { + creds := new(aws.Credentials) + if err := json.Unmarshal(c.Credentials, creds); err != nil { + return nil, err + } + return creds, creds.Valid() +} diff --git a/server/cloud/provider.go b/server/cloud/provider.go new file mode 100644 index 0000000..8487c54 --- /dev/null +++ b/server/cloud/provider.go @@ -0,0 +1,28 @@ +package cloud + +import ( + "context" + "fmt" + + "github.com/scalescape/dolores/server/cloud/aws" + "github.com/scalescape/dolores/server/cloud/cld" +) + +type StorageClient interface { + WriteToObject(ctx context.Context, bucket string, file string, data []byte) error + ReadObject(ctx context.Context, bucketName, fileName string) ([]byte, error) + ListObject(ctx context.Context, bucketName, fileName string) ([]cld.Object, error) +} + +type Option func(*Config) + +func NewStorageClient(ctx context.Context, cfg *Config, opts ...Option) (StorageClient, error) { + for _, opt := range opts { + opt(cfg) + } + acfg, err := cfg.AWSConfig() + if err != nil { + return nil, fmt.Errorf("error build aws config: %w", err) + } + return aws.NewStorageClient(ctx, acfg) +} diff --git a/server/lib/http.go b/server/lib/http.go new file mode 100644 index 0000000..410cc73 --- /dev/null +++ b/server/lib/http.go @@ -0,0 +1,25 @@ +package lib + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/rs/zerolog/log" +) + +type errMsg struct { + Error string `json:"error"` + Message string `json:"message"` +} + +func WriteError(w http.ResponseWriter, code int, err error, msg string, args ...any) { + w.WriteHeader(code) + msg = fmt.Sprintf(msg, args...) + log.Error().Msgf("%s: %v", msg, err) + resp := errMsg{Message: msg, Error: err.Error()} + if err := json.NewEncoder(w).Encode(resp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Error().Msgf("error writing response: %v", err) + } +} diff --git a/server/org/org.go b/server/org/org.go new file mode 100644 index 0000000..f4dc04f --- /dev/null +++ b/server/org/org.go @@ -0,0 +1,5 @@ +package org + +type UIDKey string + +const IDKey UIDKey = "org-id" diff --git a/server/platform/platform.go b/server/platform/platform.go new file mode 100644 index 0000000..cd77041 --- /dev/null +++ b/server/platform/platform.go @@ -0,0 +1,25 @@ +package platform + +import ( + "context" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Service struct { + Store + log zerolog.Logger + DefaultManagedZone string +} + +func (s Service) FetchProject(ctx context.Context, oid string, env string) (Project, error) { + return s.fetchProject(ctx, oid, env) +} + +func NewService(defZone string, st Store) Service { + logger := log.Output(zerolog.NewConsoleWriter()).With(). + Str("service", "platform"). + Logger() + return Service{DefaultManagedZone: defZone, Store: st, log: logger} +} diff --git a/server/platform/store.go b/server/platform/store.go new file mode 100644 index 0000000..66c3b77 --- /dev/null +++ b/server/platform/store.go @@ -0,0 +1,73 @@ +package platform + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/scalescape/dolores/server/cerr" +) + +type Store struct { + db *sqlx.DB + SaltKey string +} + +type Project struct { + OrgID string `db:"org_id"` + ID string `db:"id"` + Platform string `db:"platform"` + Credentials string `db:"credentials"` + Name string `db:"name"` + Environment string `db:"environment"` + Bucket string `db:"bucket"` + DNSZone sql.NullString `db:"dns_zone"` + Subdomain sql.NullString `db:"subdomain"` + Region sql.NullString `db:"region"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt sql.NullTime `db:"updated_at"` +} + +func (s Store) CreateProject(ctx context.Context, proj Project) error { + query := fmt.Sprintf(`INSERT into projects + (id, org_id, platform, credentials, name, environment, bucket) values + (:id, :org_id, :platform, pgp_sym_encrypt(:credentials, '%s'), :name, :environment, :bucket) + ON CONFLICT (org_id, environment) + DO UPDATE SET + credentials = pgp_sym_encrypt(:credentials, '%s') + `, s.SaltKey, s.SaltKey) + query = s.db.Rebind(query) + _, err := s.db.NamedExecContext(ctx, query, proj) + if err != nil { + return err + } + return nil +} + +func (s Store) fetchProject(ctx context.Context, oid string, env string) (Project, error) { + query := fmt.Sprintf(`SELECT id, + pgp_sym_decrypt(credentials::bytea, '%s') as credentials, + platform, + dns_zone, + subdomain, + region, + bucket from projects + WHERE org_id = ? + AND environment = ?`, s.SaltKey) + query = s.db.Rebind(query) + var res Project + err := s.db.GetContext(ctx, &res, query, oid, env) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return Project{}, cerr.ErrNoProjectFound + } else if err != nil { + return Project{}, err + } + return res, nil +} + +func NewStore(db *sqlx.DB, key string) Store { + return Store{db, key} +} diff --git a/server/secrets/fetch.go b/server/secrets/fetch.go new file mode 100644 index 0000000..8ed8d90 --- /dev/null +++ b/server/secrets/fetch.go @@ -0,0 +1,67 @@ +package secrets + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/scalescape/dolores/server/cerr" + "github.com/scalescape/dolores/server/cloud/cld" + "github.com/scalescape/dolores/server/lib" + "github.com/scalescape/dolores/server/org" +) + +type fetchRequest struct { + Environment cld.Environment `json:"environment"` + Name string `json:"name"` + orgID string `json:"-"` +} + +func (r fetchRequest) Valid() error { + if err := r.Environment.Valid(); err != nil { + return err + } + if r.Name == "" { + return cerr.ErrInvalidSecretRequest + } + return nil +} + +type fetchResponse struct { + Data string `json:"data"` +} + +func Fetch(svc Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := fetchRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "invalid request") + return + } + ctx := r.Context() + var ok bool + req.orgID, ok = ctx.Value(org.IDKey).(string) + if !ok || req.orgID == "" { + lib.WriteError(w, http.StatusBadRequest, cerr.ErrInvalidOrgID, "invalid org ID") + return + } + if err := req.Valid(); err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "invalid request") + return + } + data, err := svc.FetchSecret(ctx, req) + if err != nil && errors.Is(err, cerr.ErrNoSecretFound) { + lib.WriteError(w, http.StatusNotFound, err, "failed to fetch secrets") + return + } + if err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to fetch secrets") + return + } + resp := fetchResponse{Data: data} + if err := json.NewEncoder(w).Encode(resp); err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to encode result") + return + } + } +} diff --git a/server/secrets/list.go b/server/secrets/list.go new file mode 100644 index 0000000..d437d7c --- /dev/null +++ b/server/secrets/list.go @@ -0,0 +1,61 @@ +package secrets + +import ( + "encoding/json" + "net/http" + + "github.com/scalescape/dolores/server/cerr" + "github.com/scalescape/dolores/server/cloud/cld" + "github.com/scalescape/dolores/server/lib" + "github.com/scalescape/dolores/server/org" +) + +type listResponse struct { + Secrets []Secret `json:"secrets"` +} + +type listRequest struct { + environment cld.Environment + orgID string +} + +func (r listRequest) Valid() error { + return nil +} + +func List(svc Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req listRequest + var err error + req.environment, err = cld.ParsePathEnv(r) + if err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "failed to parse environment") + return + } + var ok bool + req.orgID, ok = ctx.Value(org.IDKey).(string) + if !ok { + lib.WriteError(w, http.StatusBadRequest, cerr.ErrInvalidOrgID, "invalid org ID") + return + } + if err := req.Valid(); err != nil { + lib.WriteError(w, http.StatusBadRequest, cerr.ErrInvalidOrgID, "invalid org ID") + return + } + data, err := svc.ListSecret(ctx, req) + if err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to list secrets") + return + } + if len(data) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + resp := listResponse{data} + if err := json.NewEncoder(w).Encode(resp); err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to encode result") + return + } + } +} diff --git a/server/secrets/recipients.go b/server/secrets/recipients.go new file mode 100644 index 0000000..2e51136 --- /dev/null +++ b/server/secrets/recipients.go @@ -0,0 +1,48 @@ +package secrets + +import ( + "encoding/json" + "net/http" + + "github.com/scalescape/dolores/server/cerr" + "github.com/scalescape/dolores/server/lib" +) + +type recipientsRequest struct { + Environment string `json:"environment"` +} + +func (r recipientsRequest) valid() error { + if r.Environment != "staging" && r.Environment != "production" { + return cerr.ErrInvalidEnvironment + } + return nil +} + +type recipientsResponse struct { + Recipients []Recipient `json:"recipients"` +} + +func ListRecipients(svc Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req recipientsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "failed to decode request") + return + } + if err := req.valid(); err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "invalid request") + return + } + data, err := svc.ListRecipients(r.Context(), req.Environment) + if err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to list recipients") + return + } + resp := recipientsResponse{Recipients: data} + if err := json.NewEncoder(w).Encode(resp); err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to encode result") + return + } + } +} diff --git a/server/secrets/service.go b/server/secrets/service.go new file mode 100644 index 0000000..50d7bce --- /dev/null +++ b/server/secrets/service.go @@ -0,0 +1,133 @@ +package secrets + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/scalescape/dolores/server/cerr" + "github.com/scalescape/dolores/server/cloud" + "github.com/scalescape/dolores/server/org" + "github.com/scalescape/dolores/server/platform" +) + +type projFetcher interface { + FetchProject(ctx context.Context, oid string, env string) (platform.Project, error) +} +type Service struct { + proj projFetcher + Store +} + +type Recipient struct { + PublicKey string `json:"public_key"` +} + +func (s Service) getStorageClient(ctx context.Context, proj platform.Project) (cloud.StorageClient, error) { + cfg := &cloud.Config{OrgID: proj.OrgID, ProjectID: proj.ID, Credentials: []byte(proj.Credentials), Platform: cloud.Platform(proj.Platform)} + if proj.Region.Valid { + cfg.Region = proj.Region.String + } + sc, err := cloud.NewStorageClient(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("failed to create storage client: %w", err) + } + return sc, nil +} + +func (s Service) ListRecipients(ctx context.Context, env string) ([]Recipient, error) { + oid, ok := ctx.Value(org.IDKey).(string) + if !ok || oid == "" { + return nil, fmt.Errorf("empty org id: %w", cerr.ErrInvalidOrg) + } + log.Trace().Msgf("fetching user public keys, env: %s org_id: %s", env, oid) + result, err := s.Store.ListUsersPublicKeys(ctx, env, oid) + if err != nil { + return nil, err + } + recps := make([]Recipient, len(result)) + for i, uk := range result { + recps[i].PublicKey = uk.PublicKey + } + return recps, nil +} + +func (s Service) ListSecret(ctx context.Context, req listRequest) ([]Secret, error) { + var secs []Secret + proj, err := s.proj.FetchProject(ctx, req.orgID, string(req.environment)) + if err != nil { + return nil, fmt.Errorf("failed to fetch project: %w", err) + } + log.Trace().Msgf("listing objects for org: %s, project: %s bucket: %s", req.orgID, proj.ID, proj.Bucket) + sc, err := s.getStorageClient(ctx, proj) + if err != nil { + return nil, err + } + objs, err := sc.ListObject(ctx, proj.Bucket, "secrets") + if err != nil { + return nil, err + } + for _, obj := range objs { + if !strings.HasSuffix(obj.Name, ".key") && !strings.HasSuffix(obj.Name, "/") { + secs = append(secs, Secret{ + Name: obj.Name, CreatedAt: obj.CreatedAt, + UpdatedAt: obj.UpdatedAt, Location: fmt.Sprintf("%s/%s", obj.Bucket, obj.Name), + }) + } + } + return secs, nil +} + +func (s Service) UploadSecret(ctx context.Context, req uploadRequest) error { + oid, ok := ctx.Value(org.IDKey).(string) + if !ok || oid == "" { + return cerr.ErrInvalidOrgID + } + proj, err := s.proj.FetchProject(ctx, oid, req.Environment) + if err != nil { + return fmt.Errorf("failed to fetch project: %w", err) + } + sc, err := s.getStorageClient(ctx, proj) + if err != nil { + return err + } + name := fmt.Sprintf("secrets/%s", req.Name) + err = sc.WriteToObject(ctx, proj.Bucket, name, req.decodedData) + if err != nil { + return fmt.Errorf("failed to write to gcs: %w", err) + } + sec := Secret{ID: uuid.New().String(), Name: req.Name, ProjectID: proj.ID, Location: name, CreatedAt: time.Now().UTC()} + if err := s.Store.SaveSecret(ctx, sec); err != nil { + return err + } + return nil +} + +func (s Service) FetchSecret(ctx context.Context, req fetchRequest) (string, error) { + proj, err := s.proj.FetchProject(ctx, req.orgID, string(req.Environment)) + if err != nil { + return "", fmt.Errorf("failed to fetch project: %w", err) + } + //sec, err := s.Store.fetchSecret(ctx, req.Name, proj.ID) + //if err != nil { + // return "", err + //} + sc, err := s.getStorageClient(ctx, proj) + if err != nil { + return "", err + } + location := fmt.Sprintf("secrets/%s", req.Name) + data, err := sc.ReadObject(ctx, proj.Bucket, location) + if err != nil { + return "", fmt.Errorf("error reading object: %w", err) + } + return base64.StdEncoding.EncodeToString(data), nil +} + +func NewService(st Store, pj projFetcher) Service { + return Service{Store: st, proj: pj} +} diff --git a/server/secrets/store.go b/server/secrets/store.go new file mode 100644 index 0000000..4eae335 --- /dev/null +++ b/server/secrets/store.go @@ -0,0 +1,87 @@ +package secrets + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/scalescape/dolores/server/cerr" +) + +type Store struct { + db *sqlx.DB + saltKey string +} + +type Secret struct { + ID string `db:"id" json:"id,omitempty"` + ProjectID string `db:"project_id" json:"project_id,omitempty"` + Name string `db:"name" json:"name"` + Location string `db:"location" json:"location"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type Key struct { + UserID string `db:"user_id"` + ProjectID string `db:"project_id"` + Environment string `db:"environment"` + PublicKey string `db:"public_key"` + PrivateKey sql.NullString `db:"private_key"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (s Store) ListUsersPublicKeys(ctx context.Context, env, orgID string) ([]Key, error) { + query := `select user_id, public_key from user_keys + where user_id in ( + select id from users where org_id = ? + ) and + environment = ?` + query = s.db.Rebind(query) + var result []Key + err := s.db.SelectContext(ctx, &result, query, orgID, env) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("error finding user_keys for org %s: %w", orgID, err) + } else if err != nil { + return nil, fmt.Errorf("error fetching user_keys for org %s: %w", orgID, err) + } + return result, nil +} + +func (s Store) SaveSecret(ctx context.Context, sec Secret) error { + query := `INSERT into secrets + (id, project_id, location, name, created_at, updated_at) values + (:id, :project_id, :location, :name, :created_at, now()) + ON CONFLICT (name) + DO UPDATE SET updated_at = now() + ` + query = s.db.Rebind(query) + if _, err := s.db.NamedExecContext(ctx, query, sec); err != nil { + return err + } + return nil +} + +func (s Store) fetchSecret(ctx context.Context, name, pid string) (Secret, error) { + query := `select * from secrets where + name = ? and + project_id = ? + ` + query = s.db.Rebind(query) + var result Secret + err := s.db.GetContext(ctx, &result, query, name, pid) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return Secret{}, fmt.Errorf("error finding secret for %s: %w", name, cerr.ErrNoSecretFound) + } else if err != nil { + return Secret{}, fmt.Errorf("error fetching secret for %s: %w", name, cerr.ErrNoSecretFound) + } + return result, nil +} + +func NewStore(db *sqlx.DB, key string) Store { + return Store{db, key} +} diff --git a/server/secrets/upload.go b/server/secrets/upload.go new file mode 100644 index 0000000..5d6d9ad --- /dev/null +++ b/server/secrets/upload.go @@ -0,0 +1,58 @@ +package secrets + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/scalescape/dolores/server/cerr" + "github.com/scalescape/dolores/server/lib" +) + +type uploadRequest struct { + Environment string `json:"environment"` + Data string `json:"data"` + Name string `json:"name"` + decodedData []byte `json:"-"` +} + +func (r *uploadRequest) Valid() error { + if r.Environment != "staging" && r.Environment != "production" { + return cerr.ErrInvalidEnvironment + } + var err error + r.decodedData, err = base64.StdEncoding.DecodeString(r.Data) + if err != nil { + return err + } + if r.Name == "" { + return cerr.ErrInvalidSecretRequest + } + return nil +} + +type uploadResponse struct{} + +func Upload(svc Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req := &uploadRequest{} + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "invalid request") + return + } + if err := req.Valid(); err != nil { + lib.WriteError(w, http.StatusBadRequest, err, "invalid request") + return + } + err := svc.UploadSecret(r.Context(), *req) + if err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to upload secret") + return + } + var resp uploadResponse + if err := json.NewEncoder(w).Encode(resp); err != nil { + lib.WriteError(w, http.StatusInternalServerError, err, "failed to encode result") + return + } + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..fb85edb --- /dev/null +++ b/server/server.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "runtime/debug" + "syscall" + "time" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" + "github.com/scalescape/dolores/config" + "github.com/scalescape/dolores/server/platform" + "github.com/scalescape/dolores/server/secrets" +) + +type Application struct { + router *mux.Router +} + +func server(appCfg config.Application) (*Application, error) { + m := mux.NewRouter() + m.Use(mux.CORSMethodMiddleware(m)) + + aesKey := appCfg.DB.AesKey + db, err := NewDB(appCfg.DB) + if err != nil { + return nil, fmt.Errorf("error creating db conn: %w", err) + } + plStore := platform.NewStore(db, aesKey) + plService := platform.NewService(appCfg.DefaultManagedZone, plStore) + + secService := secrets.NewService(secrets.NewStore(db, aesKey), plService) + m.Handle("/secrets/recipients", secrets.ListRecipients(secService)).Methods(http.MethodGet, http.MethodOptions) + m.Handle("/environment/{env}/secrets", secrets.List(secService)).Methods(http.MethodGet, http.MethodOptions) + m.Handle("/secrets", secrets.Upload(secService)).Methods(http.MethodPut, http.MethodOptions) + m.Handle("/secrets", secrets.Fetch(secService)).Methods(http.MethodGet, http.MethodOptions) + + return &Application{router: m}, nil +} + +func NewDB(cfg config.Database) (*sqlx.DB, error) { + var err error + db, err := sqlx.Open(cfg.Driver, cfg.URL()) + if err != nil { + return nil, fmt.Errorf("error opening conn to db: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("error pinging db: %w", err) + } + + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetConnMaxLifetime(cfg.MaxConnLifetime()) + return db, nil +} + +func main() { + defer func() { + if err := recover(); err != nil { + log.Fatal().Msgf("error: %s", debug.Stack()) + } + }() + + appCfg := config.MustLoadServer() + app, err := server(appCfg) + if err != nil { + log.Error().Msgf("[Main] error creating server: %v", err) + return + } + addr := appCfg.Address() + log.Info().Msgf("[Main] listening on address %s", addr) + server := &http.Server{ + ReadTimeout: 2 * time.Second, + Addr: addr, + Handler: handlers.LoggingHandler(os.Stdout, app.router), + } + go func(server *http.Server) { + err = server.ListenAndServe() + if err != nil { + log.Fatal().Msgf("[Main] error listening for rerquests on port: %s err: %v\n", addr, err) + } + }(server) + <-watchSignal() + // stop HTTP server + ctx, canc := context.WithTimeout(context.Background(), 5*time.Second) + defer canc() + if err := server.Shutdown(ctx); err != nil { + log.Error().Msgf("[Main] error shutting down server\n") + } +} + +func watchSignal() chan os.Signal { + stop := make(chan os.Signal, 2) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + return stop +}