Files
spinifex/hive/utils/utils.go
T
Julian Sommer c4027a0380 test: add gateway core routing, IAM auth, and handler test coverage
Squash of dev commits: gateway core routing/policy/middleware tests,
IAM auth and handler tests, awserrors tests, respondWithJSON helper,
utils refactor into focused files, and daemon test speedups.
2026-03-05 14:20:00 +10:00

355 lines
8.7 KiB
Go

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: <ActionResponse><ActionResult>content</ActionResult><ResponseMetadata>...</ResponseMetadata></ActionResponse>
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()
}