diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a5b00673..17bd9737 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -60,16 +60,6 @@ jobs: chmod +x /usr/local/bin/roxie roxie version - - name: Install roxctl - env: - ROXCTL_VERSION: "4.10.0" - ROXCTL_SHA256: "5db647b14569465866c0162522e83393ebf02f671f4556b1b3ed551b9f8433bc" - run: | - curl -fsSLo /usr/local/bin/roxctl \ - "https://mirror.openshift.com/pub/rhacs/assets/${ROXCTL_VERSION}/bin/Linux/roxctl" - echo "${ROXCTL_SHA256} /usr/local/bin/roxctl" | sha256sum -c - - chmod +x /usr/local/bin/roxctl - roxctl version - name: Authenticate to GCloud if: inputs.cluster-type == 'gke' diff --git a/README.md b/README.md index 7d773e57..ca5184c8 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,6 @@ the cluster to succeed. Prerequisites: - `kubectl` configured to point at your target cluster -- `roxctl` CLI is installed - `roxie` CLI is installed Deploy using: diff --git a/go.mod b/go.mod index 9652942f..492c1961 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/fatih/color v1.19.0 github.com/google/go-containerregistry v0.21.7 github.com/moby/moby/client v0.5.0 + github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -44,7 +45,6 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index f2bb9e9e..68257c29 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -2,43 +2,239 @@ package deployer import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" "errors" "fmt" + "io" + "net/http" + "os" + "slices" "strings" + "time" + "github.com/stackrox/roxie/internal/errorhelpers" "github.com/stackrox/roxie/internal/k8s" ) -// generateCRS generates the Central Resource Secret using roxctl +const ( + ServiceCACommonName string = `StackRox Certificate Authority` + CentralCommonName string = `CENTRAL_SERVICE: Central` +) + +type crsGenRequest struct { + Name string `json:"name"` +} + +type crsGenResponse struct { + CRS []byte `json:"crs"` +} + +// generateCRS generates a Cluster Registration Secret via Central's REST API. func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, error) { - crsName := fmt.Sprintf("%s-crs", clusterName) - d.logger.Infof("Generating CRS named %q with roxctl...", crsName) - - result, err := d.runRoxctl(ctx, RoxctlOptions{ - Args: []string{ - "-e", d.centralEndpoint, - "central", - "crs", - "generate", - crsName, - "--output=-", // Output to stdout - }, - UseAuthentication: true, - MaxAttempts: 5, - RetryDelay: 10, - }) + const maxAttempts = 5 + const baseRetryDelay = 10 + retryableSubstrings := []string{ + "connection refused", + "connection reset", + "connection timed out", + "timed out", + "timeout", + "network is unreachable", + "temporary failure in name resolution", + "no route to host", + "tls handshake timeout", + "eof", + "bad gateway", + "service unavailable", + "context deadline exceeded", + "no such host", + } + + client, err := d.centralHTTPClient() if err != nil { - return "", fmt.Errorf("failed to generate CRS: %w", err) + return "", fmt.Errorf("failed to create HTTP client: %w", err) + } + + url := fmt.Sprintf("https://%s/v1/cluster-init/crs", d.centralEndpoint) + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + if attempt > 1 { + waitTime := time.Duration(attempt*baseRetryDelay) * time.Second + d.logger.Infof("Retrying CRS generation (attempt %d/%d) after %v...", attempt, maxAttempts, waitTime) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(waitTime): + } + } + + // Include attempt in name for uniqueness, in case a failure is between addition to DB and reading response. + crsName := fmt.Sprintf("%s-crs-%d", clusterName, attempt) + d.logger.Infof("Generating CRS named %q via Central API...", crsName) + + reqBody, err := json.Marshal(crsGenRequest{Name: crsName}) + if err != nil { + return "", fmt.Errorf("failed to marshal CRS request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(reqBody))) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, d.centralPassword) + + resp, err := client.Do(req) + if err != nil { + lastErr = err + errLower := strings.ToLower(err.Error()) + if attempt < maxAttempts && slices.ContainsFunc(retryableSubstrings, func(sub string) bool { + return strings.Contains(errLower, sub) + }) { + d.logger.Warningf("Transient error generating CRS: %v", err) + continue + } + return "", fmt.Errorf("failed to generate CRS: %w", err) + } + + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if readErr != nil { + return "", fmt.Errorf("failed to read CRS response body: %w", readErr) + } + if closeErr != nil { + return "", fmt.Errorf("failed to close CRS response body: %w", closeErr) + } + + if resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusServiceUnavailable { + lastErr = fmt.Errorf("server returned %s", resp.Status) + if attempt < maxAttempts { + d.logger.Warningf("Transient HTTP error generating CRS: %s", resp.Status) + continue + } + return "", lastErr + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("CRS generation failed with status %s: %s", resp.Status, string(body)) + } + + var crsResp crsGenResponse + if err := json.Unmarshal(body, &crsResp); err != nil { + return "", fmt.Errorf("failed to parse CRS response: %w", err) + } + + crsContent := strings.TrimSpace(string(crsResp.CRS)) + if crsContent == "" { + return "", errors.New("CRS content is empty") + } + + d.logger.Success("✓ CRS generated") + return crsContent, nil } - crsContent := strings.TrimSpace(result.Stdout) - if crsContent == "" { - return "", errors.New("CRS content is empty") + return "", fmt.Errorf("CRS generation failed after %d attempts: %w", maxAttempts, lastErr) +} + +// centralHTTPClient returns an HTTP client configured for talking to Central. +func (d *Deployer) centralHTTPClient() (*http.Client, error) { + tlsConfig := &tls.Config{} + + if d.roxCACertFile != "" { + pemData, err := os.ReadFile(d.roxCACertFile) + if err != nil { + return nil, fmt.Errorf("reading CA cert file %s: %w", d.roxCACertFile, err) + } + pool := x509.NewCertPool() + var caCertsAdded int + for block, rest := pem.Decode(pemData); block != nil; block, rest = pem.Decode(rest) { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing CA certificate from %s: %w", d.roxCACertFile, err) + } + pool.AddCert(cert) + caCertsAdded++ + } + d.logger.Infof("Loaded %d CA certificate(s) from %q", caCertsAdded, d.roxCACertFile) + tlsConfig.RootCAs = pool + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyPeerCertificate = verifyFunc(tlsConfig) } - d.logger.Success("✓ CRS generated") - return crsContent, nil + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil +} + +// logic borrowed from VerifyPeerCertFunc in tlscheck package and serviceCertFallbackVerifier in stackrox/stackrox codebase. +func verifyFunc(conf *tls.Config) func([][]byte, [][]*x509.Certificate) error { + return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + + if len(rawCerts) == 0 { + return errors.New("remote peer presented no certificates") + } + + certs := make([]*x509.Certificate, 0, len(rawCerts)) + for _, rawCert := range rawCerts { + cert, err := x509.ParseCertificate(rawCert) + if err != nil { + return fmt.Errorf("failed to parse peer certificate: %w", err) + } + certs = append(certs, cert) + } + + leaf := certs[0] + intermediates := x509.NewCertPool() + for _, cert := range certs[1:] { + intermediates.AddCert(cert) + } + + systemVerifyOpts := x509.VerifyOptions{ + DNSName: conf.ServerName, + Intermediates: intermediates, + Roots: conf.RootCAs, + } + + _, systemVerifyErr := leaf.Verify(systemVerifyOpts) + if systemVerifyErr == nil || !isACentralCert(leaf) { + return systemVerifyErr + } + + verifyErrs := errorhelpers.NewErrorList("verifying central certificate") + verifyErrs.AddError(systemVerifyErr) + + serviceVerifyOpts := x509.VerifyOptions{ + DNSName: "central.stackrox", + Intermediates: intermediates, + Roots: conf.RootCAs, + } + + _, serviceVerifyErr := leaf.Verify(serviceVerifyOpts) + if serviceVerifyErr == nil { + return nil + } + verifyErrs.AddError(serviceVerifyErr) + return verifyErrs.ToError() + } +} + +// isACentralCert returns true if the cert's issuer and subject CNs claim look like central's. +func isACentralCert(cert *x509.Certificate) bool { + if cert.Issuer.CommonName != ServiceCACommonName { + return false + } + if cert.Subject.CommonName == CentralCommonName { + return true + } + return false } // applyCRS applies the CRS content to the sensor namespace diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 9cc3d8b7..77a1b93e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -546,7 +546,7 @@ func (d *Deployer) waitForNamespaceDeletion(namespace string) error { // checkRequiredTools verifies that required CLI tools are available func checkRequiredTools() error { - requiredTools := []string{"kubectl", "roxctl"} + requiredTools := []string{"kubectl"} var missing []string for _, tool := range requiredTools { diff --git a/internal/deployer/roxctl.go b/internal/deployer/roxctl.go deleted file mode 100644 index 54390065..00000000 --- a/internal/deployer/roxctl.go +++ /dev/null @@ -1,114 +0,0 @@ -package deployer - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "strings" - "time" -) - -// RoxctlOptions contains options for running roxctl commands -type RoxctlOptions struct { - Args []string // Command arguments (e.g., ["central", "crs", "generate", "cluster-name"]) - Env map[string]string // Additional environment variables - UseAuthentication bool // Whether to set ROX_ADMIN_PASSWORD and ROX_CA_CERT_FILE - MaxAttempts int // Maximum number of retry attempts (default: 5) - RetryDelay int // Base retry delay in seconds (default: 10, multiplied by attempt number) -} - -// RoxctlResult contains the result of a roxctl command execution -type RoxctlResult struct { - Stdout string - Stderr string -} - -// runRoxctl executes a roxctl command with automatic retries on transient errors -func (d *Deployer) runRoxctl(ctx context.Context, opts RoxctlOptions) (*RoxctlResult, error) { - if opts.MaxAttempts <= 0 { - opts.MaxAttempts = 5 - } - if opts.RetryDelay <= 0 { - opts.RetryDelay = 10 - } - - // List of transient/retryable errors - retryableErrors := []string{ - "connection refused", - "connection reset", - "connection timed out", - "timed out", - "timeout", - "network is unreachable", - "temporary failure in name resolution", - "no route to host", - "tls handshake timeout", - "eof", - "bad gateway", - "service unavailable", - "context deadline exceeded", - "no such host", - } - - var lastStderr string - var lastErr error - - for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { - if attempt > 1 { - waitTime := time.Duration(attempt*opts.RetryDelay) * time.Second - d.logger.Infof("Retrying roxctl command (attempt %d/%d) after %v...", attempt, opts.MaxAttempts, waitTime) - time.Sleep(waitTime) - } - - cmd := exec.CommandContext(ctx, "roxctl", opts.Args...) - - cmd.Env = os.Environ() - - if opts.UseAuthentication { - if d.centralPassword != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("ROX_ADMIN_PASSWORD=%s", d.centralPassword)) - } - if d.roxCACertFile != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("ROX_CA_CERT_FILE=%s", d.roxCACertFile)) - } - } - - for k, v := range opts.Env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - lastStderr = stderr.String() - lastErr = err - - if err == nil { - return &RoxctlResult{ - Stdout: stdout.String(), - Stderr: lastStderr, - }, nil - } - - isRetryable := false - for _, retryErr := range retryableErrors { - if strings.Contains(strings.ToLower(lastStderr), strings.ToLower(retryErr)) { - isRetryable = true - break - } - } - - if !isRetryable || attempt == opts.MaxAttempts { - d.logger.Errorf("roxctl error: %s", lastStderr) - return nil, fmt.Errorf("roxctl command failed: %w", err) - } - - d.logger.Warningf("Transient error in roxctl command: %s", lastStderr) - } - - return nil, fmt.Errorf("roxctl command failed after %d attempts: %w", opts.MaxAttempts, lastErr) -} diff --git a/internal/errorhelpers/format.go b/internal/errorhelpers/format.go new file mode 100644 index 00000000..f5ed0366 --- /dev/null +++ b/internal/errorhelpers/format.go @@ -0,0 +1,120 @@ +// Package errorhelpers was copied fromhttps://github.com/stackrox/stackrox/tree/master/pkg/errorhelpers +package errorhelpers + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// ErrorList is a wrapper around many errors +type ErrorList struct { + start string + errors []error +} + +// NewErrorList returns a new ErrorList +func NewErrorList(start string) *ErrorList { + return &ErrorList{ + start: start, + } +} + +// NewErrorListWithErrors returns a new ErrorList with the given errors. +func NewErrorListWithErrors(start string, errors []error) *ErrorList { + errorList := NewErrorList(start) + for _, err := range errors { + errorList.AddError(err) + } + return errorList +} + +// AddError adds the passed error to the list of errors if it is not nil +func (e *ErrorList) AddError(err error) { + if err == nil { + return + } + e.errors = append(e.errors, err) +} + +// AddErrors adds the non-nil errors in the given slice to the list of errors. +func (e *ErrorList) AddErrors(errs ...error) { + for _, err := range errs { + if err == nil { + continue + } + e.errors = append(e.errors, err) + } +} + +// AddWrap is a convenient wrapper around `AddError(errors.Wrap(err, msg))`. +func (e *ErrorList) AddWrap(err error, msg string) { + e.AddError(errors.Wrap(err, msg)) +} + +// AddWrapf is a convenient wrapper around `AddError(errors.Wrapf(err, format, args...))`. +func (e *ErrorList) AddWrapf(err error, format string, args ...interface{}) { + e.AddError(errors.Wrapf(err, format, args...)) +} + +// AddString adds a string based error to the list +func (e *ErrorList) AddString(err string) { + e.errors = append(e.errors, errors.New(err)) +} + +// AddStringf adds a templated string +func (e *ErrorList) AddStringf(t string, args ...interface{}) { + e.errors = append(e.errors, errors.Errorf(t, args...)) +} + +// AddStrings adds multiple string based errors to the list. +func (e *ErrorList) AddStrings(errs ...string) { + for _, err := range errs { + e.errors = append(e.errors, errors.New(err)) + } +} + +// ToError returns an error if there were errors added or nil +func (e *ErrorList) ToError() error { + if len(e.errors) == 0 { + return nil + } + return e +} + +// Error implements the error interface +func (e *ErrorList) Error() string { + return e.String() +} + +// String converts the list to a string, returning empty if no errors were added. +func (e *ErrorList) String() string { + switch len(e.errors) { + case 0: + return "" + case 1: + return fmt.Sprintf("%s error: %s", e.start, e.errors[0]) + default: + return fmt.Sprintf("%s errors: [%s]", e.start, strings.Join(e.ErrorStrings(), ", ")) + } +} + +// ErrorStrings returns all the error strings in this ErrorList as a slice, ignoring the start string. +func (e *ErrorList) ErrorStrings() []string { + errors := make([]string, 0, len(e.errors)) + for _, err := range e.errors { + errors = append(errors, err.Error()) + } + return errors +} + +// Errors returns the underlying errors in the error list +func (e *ErrorList) Errors() []error { + return e.errors +} + +// Empty returns whether the list of error strings is empty. +func (e *ErrorList) Empty() bool { + return len(e.errors) == 0 +}