diff --git a/internal/app/ptah-agent/encryption.go b/internal/app/ptah-agent/encryption.go new file mode 100644 index 0000000..e8404b8 --- /dev/null +++ b/internal/app/ptah-agent/encryption.go @@ -0,0 +1,143 @@ +package ptah_agent + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + + "github.com/pkg/errors" + + "github.com/docker/docker/api/types/swarm" + t "github.com/ptah-sh/ptah-agent/internal/pkg/ptah-client" +) + +type EncryptionKeyPair struct { + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` +} + +// getEncryptionKey checks for existing key or generates a new one +func (e *taskExecutor) getEncryptionKey(ctx context.Context) (*EncryptionKeyPair, error) { + existingConfig, err := e.getConfigByName(ctx, "ptah_encryption_key") + if err != nil && !errors.Is(err, ErrConfigNotFound) { + return nil, fmt.Errorf("failed to check for existing encryption key: %v", err) + } + + if existingConfig != nil { + var keyPair EncryptionKeyPair + + err = json.Unmarshal(existingConfig.Spec.Data, &keyPair) + + if err != nil { + return nil, fmt.Errorf("failed to unmarshal existing encryption key: %v", err) + } + + return &keyPair, nil + } + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key pair: %v", err) + } + + privateKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + } + privateKeyStr := string(pem.EncodeToMemory(privateKeyPEM)) + + publicKey, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %v", err) + } + + publicKeyPEM := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKey, + } + publicKeyStr := string(pem.EncodeToMemory(publicKeyPEM)) + + keyPair := &EncryptionKeyPair{ + PrivateKey: privateKeyStr, + PublicKey: publicKeyStr, + } + + keyPairJSON, err := json.Marshal(keyPair) + if err != nil { + return nil, fmt.Errorf("failed to marshal encryption key pair: %v", err) + } + + _, err = e.createDockerConfig(ctx, &t.CreateConfigReq{ + SwarmConfigSpec: swarm.ConfigSpec{ + Annotations: swarm.Annotations{ + Name: "ptah_encryption_key", + Labels: map[string]string{}, + }, + Data: keyPairJSON, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to save encryption key to Docker config: %v", err) + } + + return keyPair, nil +} + +func (e *taskExecutor) decryptValue(ctx context.Context, encryptedValue string) (string, error) { + keyPair, err := e.getEncryptionKey(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to get encryption key") + } + + block, _ := pem.Decode([]byte(keyPair.PrivateKey)) + if block == nil { + return "", errors.New("failed to parse PEM block containing the private key") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", errors.Wrap(err, "failed to parse private key") + } + + encryptedBytes, err := base64.StdEncoding.DecodeString(encryptedValue) + if err != nil { + return "", errors.Wrap(err, "failed to decode encrypted value") + } + + decryptedBytes, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, encryptedBytes, []byte("")) + if err != nil { + return "", errors.Wrap(err, "failed to decrypt value") + } + + return string(decryptedBytes), nil +} + +func (e *taskExecutor) encryptValue(ctx context.Context, value string) (string, error) { + keyPair, err := e.getEncryptionKey(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to get encryption key") + } + + block, _ := pem.Decode([]byte(keyPair.PublicKey)) + if block == nil { + return "", errors.New("failed to parse PEM block containing the public key") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", errors.Wrap(err, "failed to parse public key") + } + + encryptedBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey.(*rsa.PublicKey), []byte(value), []byte("")) + if err != nil { + return "", errors.Wrap(err, "failed to encrypt value") + } + + return base64.StdEncoding.EncodeToString(encryptedBytes), nil +} diff --git a/internal/app/ptah-agent/ptah_client.go b/internal/app/ptah-agent/ptah_client.go index 33ea4b0..a4117bc 100644 --- a/internal/app/ptah-agent/ptah_client.go +++ b/internal/app/ptah-agent/ptah_client.go @@ -17,11 +17,12 @@ import ( ) type Agent struct { - Version string - ptah *ptahClient.Client - rootDir string - docker *dockerClient.Client - caddy *caddyClient.Client + Version string + ptah *ptahClient.Client + rootDir string + docker *dockerClient.Client + caddy *caddyClient.Client + executor *taskExecutor } func New(version string, baseUrl string, ptahToken string, rootDir string) (*Agent, error) { @@ -34,13 +35,27 @@ func New(version string, baseUrl string, ptahToken string, rootDir string) (*Age ctx := context.Background() docker.NegotiateAPIVersion(ctx) - return &Agent{ + caddy := caddyClient.New("http://127.0.0.1:2019", http.DefaultClient) + + // TODO: refactor to avoid duplication and circular dependency? + agent := &Agent{ Version: version, ptah: ptahClient.New(baseUrl, ptahToken), rootDir: rootDir, - caddy: caddyClient.New("http://127.0.0.1:2019", http.DefaultClient), + caddy: caddy, docker: docker, - }, nil + executor: &taskExecutor{ + docker: docker, + caddy: caddy, + rootDir: rootDir, + // TODO: use channel instead? + stopAgentFlag: false, + }, + } + + agent.executor.agent = agent + + return agent, nil } func (a *Agent) sendStartedEvent(ctx context.Context) (*ptahClient.StartedRes, error) { @@ -91,13 +106,30 @@ func (a *Agent) sendStartedEvent(ctx context.Context) (*ptahClient.StartedRes, e }) } + workerJoinToken, err := a.executor.encryptValue(ctx, swarm.JoinTokens.Worker) + if err != nil { + return nil, err + } + + managerJoinToken, err := a.executor.encryptValue(ctx, swarm.JoinTokens.Manager) + if err != nil { + return nil, err + } + startedReq.SwarmData = &ptahClient.SwarmData{ JoinTokens: ptahClient.JoinTokens{ - Worker: swarm.JoinTokens.Worker, - Manager: swarm.JoinTokens.Manager, + Worker: workerJoinToken, + Manager: managerJoinToken, }, ManagerNodes: managerNodes, } + + encryptionKey, err := a.executor.getEncryptionKey(ctx) + if err != nil { + return nil, err + } + + startedReq.SwarmData.EncryptionKey = encryptionKey.PublicKey } log.Println("sending started event, base url", a.ptah.BaseUrl) @@ -115,29 +147,30 @@ func (a *Agent) Start(ctx context.Context) error { return err } - executor := &taskExecutor{ - docker: a.docker, - caddy: a.caddy, - rootDir: a.rootDir, - // TODO: use channel instead? - stopAgentFlag: false, - agent: a, - } - log.Println("connected to server, poll interval", settings.Settings.PollInterval) + consecutiveFailures := 0 + maxConsecutiveFailures := 5 + for { taskID, task, err := a.getNextTask(ctx) if err != nil { log.Println("can't get the next task", err) + consecutiveFailures++ - if taskID != 0 { + if taskID == 0 { + if consecutiveFailures >= maxConsecutiveFailures { + return fmt.Errorf("shutting down due to %d consecutive failures to get next task", maxConsecutiveFailures) + } + } else { if err = a.ptah.FailTask(ctx, taskID, &ptahClient.TaskError{ Message: err.Error(), }); err != nil { log.Println("can't fail task", err) } } + } else { + consecutiveFailures = 0 } if task == nil { @@ -146,7 +179,7 @@ func (a *Agent) Start(ctx context.Context) error { continue } - result, err := executor.executeTask(ctx, task) + result, err := a.executor.executeTask(ctx, task) // TODO: store the result to re-send it once connection to the ptah server is restored if err == nil { if err = a.ptah.CompleteTask(ctx, taskID, result); err != nil { @@ -160,7 +193,7 @@ func (a *Agent) Start(ctx context.Context) error { } } - if executor.stopAgentFlag { + if a.executor.stopAgentFlag { log.Println("received stop signal, shutting down gracefully") break @@ -191,7 +224,7 @@ func (a *Agent) getNextTask(ctx context.Context) (taskId int, task interface{}, func (a *Agent) ExecTasks(ctx context.Context, jsonFilePath string) error { // Docker client should already be initialized and version negotiated in New() if a.docker == nil { - return fmt.Errorf("Docker client not initialized") + return fmt.Errorf("docker client not initialized") } // Read the JSON file diff --git a/internal/app/ptah-agent/registry_auth.go b/internal/app/ptah-agent/registry_auth.go index b66732f..c488f35 100644 --- a/internal/app/ptah-agent/registry_auth.go +++ b/internal/app/ptah-agent/registry_auth.go @@ -3,31 +3,18 @@ package ptah_agent import ( "context" "encoding/json" + "github.com/docker/docker/api/types/registry" + "github.com/pkg/errors" t "github.com/ptah-sh/ptah-agent/internal/pkg/ptah-client" ) func (e *taskExecutor) createRegistryAuth(ctx context.Context, req *t.CreateRegistryAuthReq) (*t.CreateRegistryAuthRes, error) { - if req.PrevConfigName != "" { - prev, _, err := e.docker.ConfigInspectWithRaw(ctx, req.PrevConfigName) - if err != nil { - return nil, err - } - - var authConfig registry.AuthConfig - err = json.Unmarshal(prev.Spec.Data, &authConfig) - if err != nil { - return nil, err - } - - if req.AuthConfigSpec.Username == "" { - req.AuthConfigSpec.Username = authConfig.Username - } - - if req.AuthConfigSpec.Password == "" { - req.AuthConfigSpec.Password = authConfig.Password - } + decryptedPassword, err := e.decryptValue(ctx, req.AuthConfigSpec.Password) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt password") } + req.AuthConfigSpec.Password = decryptedPassword data, err := json.Marshal(req.AuthConfigSpec) if err != nil { diff --git a/internal/app/ptah-agent/s3.go b/internal/app/ptah-agent/s3.go index 089c51e..0339d3f 100644 --- a/internal/app/ptah-agent/s3.go +++ b/internal/app/ptah-agent/s3.go @@ -21,36 +21,24 @@ import ( func (e *taskExecutor) createS3Storage(ctx context.Context, req *t.CreateS3StorageReq) (*t.CreateS3StorageRes, error) { var res t.CreateS3StorageRes - if req.S3StorageSpec.AccessKey == "" || req.S3StorageSpec.SecretKey == "" { - if req.PrevConfigName == "" { - return nil, fmt.Errorf("create s3 storage: prev config name is empty - empty credentials") - } - - prev, err := e.getConfigByName(ctx, req.PrevConfigName) - if err != nil { - return nil, err - } - - var prevSpec t.S3StorageSpec - err = json.Unmarshal(prev.Spec.Data, &prevSpec) - if err != nil { - return nil, fmt.Errorf("create s3 storage: unmarshal prev config: %w", err) - } - - req.S3StorageSpec.AccessKey = prevSpec.AccessKey - req.S3StorageSpec.SecretKey = prevSpec.SecretKey + decryptedSecretKey, err := e.decryptValue(ctx, req.S3StorageSpec.SecretKey) + if err != nil { + return nil, fmt.Errorf("create s3 storage: decrypt secret key: %w", err) } - data, err := json.Marshal(req.S3StorageSpec) + decryptedSpec := req.S3StorageSpec + decryptedSpec.SecretKey = decryptedSecretKey + + data, err := json.Marshal(decryptedSpec) if err != nil { - return nil, err + return nil, fmt.Errorf("create s3 storage: marshal spec: %w", err) } req.SwarmConfigSpec.Data = data config, err := e.docker.ConfigCreate(ctx, req.SwarmConfigSpec) if err != nil { - return nil, err + return nil, fmt.Errorf("create s3 storage: create config: %w", err) } res.Docker.ID = config.ID diff --git a/internal/app/ptah-agent/secret.go b/internal/app/ptah-agent/secret.go index f2e72cb..8c1348d 100644 --- a/internal/app/ptah-agent/secret.go +++ b/internal/app/ptah-agent/secret.go @@ -2,6 +2,7 @@ package ptah_agent import ( "context" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" @@ -12,7 +13,15 @@ import ( func (e *taskExecutor) createDockerSecret(ctx context.Context, req *t.CreateSecretReq) (*t.CreateSecretRes, error) { var res t.CreateSecretRes - response, err := e.docker.SecretCreate(ctx, req.SwarmSecretSpec) + decryptedData, err := e.decryptValue(ctx, string(req.SwarmSecretSpec.Data)) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt secret data") + } + + decryptedSpec := req.SwarmSecretSpec + decryptedSpec.Data = []byte(decryptedData) + + response, err := e.docker.SecretCreate(ctx, decryptedSpec) if err != nil { return nil, err } diff --git a/internal/app/ptah-agent/service.go b/internal/app/ptah-agent/service.go index 343eb1e..b4706d5 100644 --- a/internal/app/ptah-agent/service.go +++ b/internal/app/ptah-agent/service.go @@ -5,10 +5,11 @@ import ( "fmt" "strings" + "github.com/pkg/errors" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" - "github.com/pkg/errors" t "github.com/ptah-sh/ptah-agent/internal/pkg/ptah-client" ) @@ -16,7 +17,7 @@ func (e *taskExecutor) launchDockerService(ctx context.Context, req *t.LaunchSer var res t.LaunchServiceRes existingService, err := e.getServiceByName(ctx, req.SwarmServiceSpec.Name) - if err != nil && err != ErrServiceNotFound { + if err != nil && !errors.Is(err, ErrServiceNotFound) { return nil, fmt.Errorf("launch docker service: %w", err) } @@ -85,9 +86,13 @@ func (e *taskExecutor) updateDockerService(ctx context.Context, req *t.UpdateSer func (e *taskExecutor) prepareServicePayload(ctx context.Context, servicePayload t.ServicePayload, secretVars t.SecretVars) (*swarm.ServiceSpec, error) { spec := servicePayload.SwarmServiceSpec - for key, value := range secretVars.Values { - // We will be decoding the secret values here - spec.TaskTemplate.ContainerSpec.Env = append(spec.TaskTemplate.ContainerSpec.Env, fmt.Sprintf("%s=%s", key, value)) + for key, encryptedValue := range secretVars { + decryptedValue, err := e.decryptValue(ctx, encryptedValue) + if err != nil { + return nil, errors.Wrapf(err, "failed to decrypt value for %s", key) + } + + spec.TaskTemplate.ContainerSpec.Env = append(spec.TaskTemplate.ContainerSpec.Env, fmt.Sprintf("%s=%s", key, decryptedValue)) } for _, config := range spec.TaskTemplate.ContainerSpec.Configs { @@ -99,6 +104,15 @@ func (e *taskExecutor) prepareServicePayload(ctx context.Context, servicePayload config.ConfigID = cfg.ID } + for _, secret := range spec.TaskTemplate.ContainerSpec.Secrets { + foundSecret, err := e.getSecretByName(ctx, secret.SecretName) + if err != nil { + return nil, errors.Wrapf(err, "get secret by name %s", secret.SecretName) + } + + secret.SecretID = foundSecret.ID + } + if servicePayload.ReleaseCommand.Command != "" { image, _, err := e.docker.ImageInspectWithRaw(ctx, spec.TaskTemplate.ContainerSpec.Image) if err != nil { @@ -151,15 +165,6 @@ func (e *taskExecutor) prepareServicePayload(ctx context.Context, servicePayload } } - for _, secret := range spec.TaskTemplate.ContainerSpec.Secrets { - foundSecret, err := e.getSecretByName(ctx, secret.SecretName) - if err != nil { - return nil, errors.Wrapf(err, "get secret by name %s", secret.SecretName) - } - - secret.SecretID = foundSecret.ID - } - return &spec, nil } diff --git a/internal/pkg/ptah-client/events.go b/internal/pkg/ptah-client/events.go index 5c3d63d..c80e1eb 100644 --- a/internal/pkg/ptah-client/events.go +++ b/internal/pkg/ptah-client/events.go @@ -15,8 +15,9 @@ type ManagerNode struct { } type SwarmData struct { - JoinTokens JoinTokens `json:"joinTokens"` - ManagerNodes []ManagerNode `json:"managerNodes"` + JoinTokens JoinTokens `json:"joinTokens"` + ManagerNodes []ManagerNode `json:"managerNodes"` + EncryptionKey string `json:"encryptionKey"` } type NodeData struct { diff --git a/internal/pkg/ptah-client/task_types.go b/internal/pkg/ptah-client/task_types.go index 60d652b..c8f139b 100644 --- a/internal/pkg/ptah-client/task_types.go +++ b/internal/pkg/ptah-client/task_types.go @@ -67,11 +67,7 @@ type ServicePayload struct { SwarmServiceSpec swarm.ServiceSpec } -type SecretVars struct { - ConfigName string - ConfigLabels map[string]string - Values map[string]string -} +type SecretVars map[string]string type CreateServiceReq struct { ServicePayload @@ -134,7 +130,6 @@ type ConfirmAgentUpgradeRes struct { } type CreateRegistryAuthReq struct { - PrevConfigName string AuthConfigSpec registry.AuthConfig SwarmConfigSpec swarm.ConfigSpec } @@ -171,7 +166,6 @@ type S3StorageSpec struct { } type CreateS3StorageReq struct { - PrevConfigName string S3StorageSpec S3StorageSpec SwarmConfigSpec swarm.ConfigSpec }