diff --git a/go.mod b/go.mod index 8a76623..5bae94a 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/txn2/mcp-s3 go 1.25.0 +toolchain go1.26.4 + require ( github.com/aws/aws-sdk-go-v2 v1.41.9 github.com/aws/aws-sdk-go-v2/config v1.32.20 diff --git a/pkg/client/client.go b/pkg/client/client.go index d42d43b..be94b5c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -185,8 +185,19 @@ func New(ctx context.Context, cfg *Config) (*Client, error) { // Create S3 client s3Client := s3.NewFromConfig(awsCfg, s3Opts...) - // Create presign client - presignClient := s3.NewPresignClient(s3Client) + // Create presign client. When a dedicated presign endpoint is configured, + // sign URLs against a client pointed at that public-facing endpoint so the + // URLs are reachable outside the cluster; data operations keep using + // s3Client (the internal Endpoint). With no presign endpoint the two are the + // same client, preserving prior behavior. + presignSource := s3Client + if cfg.PresignEndpoint != "" { + presignSource = s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.PresignEndpoint) + o.UsePathStyle = cfg.UsePathStyle + }) + } + presignClient := s3.NewPresignClient(presignSource) // Create the streaming/multipart uploader. It shares the same underlying // S3 client so it honors the configured endpoint, credentials, and region. diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 15bf8e2..0466ddb 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + neturl "net/url" "os" "strings" "testing" @@ -720,6 +721,59 @@ func TestClient_PresignGetURL(t *testing.T) { }) } +// TestNew_PresignEndpoint verifies that a configured PresignEndpoint signs +// presigned URLs against the public endpoint while data operations keep using +// Endpoint, and that an empty PresignEndpoint falls back to Endpoint. Presigning +// is a local operation (no network), so a real client built via New is used. +func TestNew_PresignEndpoint(t *testing.T) { + ctx := context.Background() + + cfgWith := func(presign string) *Config { + return &Config{ + Region: "us-east-1", + Endpoint: "http://internal:8333", + PresignEndpoint: presign, + AccessKeyID: "test", + SecretAccessKey: "secret", + UsePathStyle: true, + } + } + presignHost := func(t *testing.T, c *Client) (scheme, host string) { + t.Helper() + got, err := c.PresignGetURL(ctx, "my-bucket", "file.txt", time.Hour) + if err != nil { + t.Fatalf("PresignGetURL: %v", err) + } + u, err := neturl.Parse(got.URL) + if err != nil { + t.Fatalf("parse %q: %v", got.URL, err) + } + return u.Scheme, u.Host + } + + t.Run("signs against the public presign endpoint", func(t *testing.T) { + c, err := New(ctx, cfgWith("https://s3.public.example.com")) + if err != nil { + t.Fatalf("New: %v", err) + } + scheme, host := presignHost(t, c) + if scheme != "https" || host != "s3.public.example.com" { + t.Errorf("presigned URL = %s://%s, want https://s3.public.example.com", scheme, host) + } + }) + + t.Run("empty presign endpoint falls back to the data endpoint", func(t *testing.T) { + c, err := New(ctx, cfgWith("")) + if err != nil { + t.Fatalf("New: %v", err) + } + _, host := presignHost(t, c) + if host != "internal:8333" { + t.Errorf("presigned URL host = %s, want internal:8333", host) + } + }) +} + func TestClient_PresignPutURL(t *testing.T) { t.Run("success", func(t *testing.T) { presignMock := &mockPresignAPI{ diff --git a/pkg/client/config.go b/pkg/client/config.go index cf4d5dd..5cbdf1e 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -22,6 +22,13 @@ type Config struct { // Endpoint is an optional custom endpoint URL for S3-compatible services (SeaweedFS, LocalStack, etc.). Endpoint string + // PresignEndpoint is an optional public-facing endpoint used only when + // signing presigned URLs. When set, presigned URLs are signed against it so + // they are reachable from outside the cluster, while data operations keep + // using Endpoint (typically an internal/cluster address). Empty falls back + // to Endpoint, preserving prior behavior. + PresignEndpoint string + // AccessKeyID is the AWS access key ID. If empty, the SDK credential chain is used. AccessKeyID string @@ -57,6 +64,7 @@ type Config struct { // - AWS_SESSION_TOKEN: Session token (optional) // - AWS_PROFILE: Profile name (optional) // - S3_ENDPOINT: Custom endpoint URL (optional) +// - S3_PRESIGN_ENDPOINT: Public endpoint for presigned URLs (optional) // - S3_USE_PATH_STYLE: Use path-style URLs (default: false) // - S3_TIMEOUT: Operation timeout (default: 30s) // - S3_CONNECTION_NAME: Connection name (optional) @@ -65,6 +73,7 @@ func FromEnv() Config { cfg := Config{ Region: getEnvOrDefault("AWS_REGION", DefaultRegion), Endpoint: getEnvSanitized("S3_ENDPOINT"), + PresignEndpoint: getEnvSanitized("S3_PRESIGN_ENDPOINT"), AccessKeyID: getEnvSanitized("AWS_ACCESS_KEY_ID"), SecretAccessKey: getEnvSanitized("AWS_SECRET_ACCESS_KEY"), SessionToken: getEnvSanitized("AWS_SESSION_TOKEN"), @@ -107,6 +116,7 @@ func (c *Config) Clone() *Config { return &Config{ Region: c.Region, Endpoint: c.Endpoint, + PresignEndpoint: c.PresignEndpoint, AccessKeyID: c.AccessKeyID, SecretAccessKey: c.SecretAccessKey, SessionToken: c.SessionToken,