package utils import ( "bytes" "context" "crypto/rand" "crypto/tls" "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "reflect" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/s3" "github.com/mulgadc/hive/hive/awserrors" "github.com/mulgadc/hive/hive/config" "github.com/pterm/pterm" "golang.org/x/net/http2" ) // GenerateResourceID generates a unique resource ID with the given prefix. // Format: {prefix}-{17 hex chars} using crypto/rand. func GenerateResourceID(prefix string) string { b := make([]byte, 9) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } return prefix + "-" + hex.EncodeToString(b)[:17] } // Convert interface to XML func MarshalToXML(payload any) ([]byte, error) { var buf bytes.Buffer enc := xml.NewEncoder(&buf) if err := xmlutil.BuildXML(payload, enc); err != nil { slog.Error("BuildXML failed", "err", err) return nil, err } if err := enc.Flush(); err != nil { slog.Error("Flush failed", "err", err) return nil, err } return buf.Bytes(), nil } // wrapWithLocation decorates payload with the requested locationName tag. func GenerateXMLPayload(locationName string, payload any) any { t := reflect.StructOf([]reflect.StructField{ { Name: "Value", Type: reflect.TypeOf(payload), Tag: reflect.StructTag(`locationName:"` + locationName + `"`), }, }) v := reflect.New(t).Elem() v.Field(0).Set(reflect.ValueOf(payload)) return v.Interface() } // GenerateIAMXMLPayload wraps IAM output with proper *Result element structure. // IAM responses have format: content... func GenerateIAMXMLPayload(action string, payload any) any { // First wrap with ActionResult (e.g., CreateUserResult) resultName := action + "Result" resultWrapper := reflect.StructOf([]reflect.StructField{ { Name: "Result", Type: reflect.TypeOf(payload), Tag: reflect.StructTag(`locationName:"` + resultName + `"`), }, }) resultV := reflect.New(resultWrapper).Elem() resultV.Field(0).Set(reflect.ValueOf(payload)) // Then wrap with ActionResponse (e.g., CreateUserResponse) responseName := action + "Response" responseWrapper := reflect.StructOf([]reflect.StructField{ { Name: "Response", Type: resultWrapper, Tag: reflect.StructTag(`locationName:"` + responseName + `"`), }, }) responseV := reflect.New(responseWrapper).Elem() responseV.Field(0).Set(resultV) return responseV.Interface() } // Generate JSON Error Payload func GenerateErrorPayload(code string) (jsonResponse []byte) { var responseError ec2.ResponseError responseError.Code = aws.String(code) // Return as JSON, to simulate the NATS response jsonResponse, err := json.Marshal(responseError) if err != nil { slog.Error("GenerateErrorPayload could not marshal JSON payload", "err", err) return nil } return } // Validate the payload is an ec2.ResponseError func ValidateErrorPayload(payload []byte) (responseError ec2.ResponseError, err error) { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() err = decoder.Decode(&responseError) if err == nil && responseError.Code != nil { // Successfully decoded as ResponseError AND has a non-nil Code field // This is a real error response return responseError, errors.New("ResponseError detected") } // Either failed to decode (not an error structure) or Code is nil (empty valid response) return responseError, nil } // Unmarshal payload func UnmarshalJsonPayload(input any, jsonData []byte) []byte { decoder := json.NewDecoder(bytes.NewReader(jsonData)) decoder.DisallowUnknownFields() // input is already a pointer, don't take address again err := decoder.Decode(input) if err != nil { return GenerateErrorPayload(awserrors.ErrorValidationError) } return nil } func MarshalJsonPayload(input any, jsonData []byte) []byte { decoder := json.NewDecoder(bytes.NewReader(jsonData)) decoder.DisallowUnknownFields() // input is already a pointer, don't take address again err := decoder.Decode(input) if err != nil { return GenerateErrorPayload(awserrors.ErrorValidationError) } return nil } // ValidateKeyPairName validates that a key pair name only contains allowed characters: // - Uppercase A-Z // - Lowercase a-z // - Digits 0-9 // - Hyphen (-) // - Underscore (_) // - Period (.) // This prevents path traversal attacks and invalid characters like /etc/passwd, ../../../, etc. // Returns ErrorInvalidKeyPairFormat if validation fails func ValidateKeyPairName(name string) error { if name == "" { return errors.New("key name cannot be empty") } // Check each character is in the allowed set for _, char := range name { valid := (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' if !valid { return errors.New(awserrors.ErrorInvalidKeyPairFormat) } } return nil } func CreateS3Client(cfg *config.Config) *s3.S3 { tr := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, NextProtos: []string{"h2", "http/1.1"}, }, ForceAttemptHTTP2: true, } // CRITICAL: Configure HTTP/2 support with custom TLS config if err := http2.ConfigureTransport(tr); err != nil { slog.Warn("Failed to configure HTTP/2", "error", err) } httpClient := &http.Client{Transport: tr} sess := session.Must(session.NewSession(&aws.Config{ Region: aws.String(cfg.Predastore.Region), Endpoint: aws.String(fmt.Sprintf("https://%s", cfg.Predastore.Host)), Credentials: credentials.NewStaticCredentials(cfg.Predastore.AccessKey, cfg.Predastore.SecretKey, ""), S3ForcePathStyle: aws.Bool(true), DisableSSL: aws.Bool(false), HTTPClient: httpClient, })) return s3.New(sess) } // Download helper func DownloadFileWithProgress(url string, name string, filename string, timeout time.Duration) (err error) { // Context with optional timeout and Ctrl+C cancel ctx, cancel := context.WithCancel(context.Background()) if timeout > 0 { ctx, cancel = context.WithTimeout(ctx, timeout) } defer cancel() intCh := make(chan os.Signal, 1) signal.Notify(intCh, os.Interrupt) go func() { <-intCh cancel() }() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("request error: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("http error: %v", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("unexpected status: %s", resp.Status) } f, err := os.Create(filename) if err != nil { return fmt.Errorf("file create error: %v", err) } defer f.Close() cl := resp.ContentLength // Known size: progress bar with total if cl > 0 { bar, _ := pterm.DefaultProgressbar. WithTitle(fmt.Sprintf("Downloading %s", name)). WithTotal(int(cl)). Start() var written int64 reader := io.TeeReader(resp.Body, progressWriter(func(n int) { written += int64(n) // Update progress bar with the number of bytes read in this chunk bar.Add(n) })) _, err = io.Copy(f, reader) _, _ = bar.Stop() if err != nil { return fmt.Errorf("copy error: %v", err) } pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written))) return } else { // Unknown size: spinner that shows bytes downloaded spin, _ := pterm.DefaultSpinner. WithText("Downloading (size unknown)..."). Start() var written int64 reader := io.TeeReader(resp.Body, progressWriter(func(n int) { written += int64(n) spin.UpdateText(fmt.Sprintf("Downloading %s (%s) ...", name, humanBytes(SafeInt64ToUint64(written)))) })) _, err = io.Copy(f, reader) _ = spin.Stop() if err != nil { return fmt.Errorf("copy error: %v", err) } pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written))) } return nil } // progressWriter turns byte counts into a callback for UI updates. type progressWriter func(n int) func (pw progressWriter) Write(p []byte) (int, error) { pw(len(p)) return len(p), nil } func humanBytes(b uint64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := uint64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPEZY"[exp]) } func dirExists(path string) bool { info, err := os.Stat(path) if os.IsNotExist(err) { return false } if err != nil { return false } return info.IsDir() }