Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions cmd/stui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

tea "github.com/charmbracelet/bubbletea"
"github.com/natevick/stui/internal/providers"
"github.com/natevick/stui/internal/security"
"github.com/natevick/stui/internal/tui"
)
Expand All @@ -16,9 +17,12 @@ var (

func main() {
// Parse flags
profile := flag.String("profile", os.Getenv("AWS_PROFILE"), "AWS profile to use (can also use AWS_PROFILE env var)")
profile := flag.String("profile", os.Getenv("AWS_PROFILE"), "Profile (aws/stui) or alias (minio) name to use (also AWS_PROFILE env var)")
alias := flag.String("alias", "", "Alias name (synonym of --profile, matching mc terminology)")
provider := flag.String("provider", "", "Force a provider: aws|minio|stui (skips cross-provider matching)")
region := flag.String("region", os.Getenv("AWS_REGION"), "AWS region (can also use AWS_REGION env var)")
bucket := flag.String("bucket", "", "Start directly in this S3 bucket")
endpoint := flag.String("endpoint-url", "", "Custom S3 endpoint URL (for MinIO, SeaweedFS, Ceph, etc.; overrides resolved)")
demo := flag.Bool("demo", false, "Run with mock data (no AWS credentials needed)")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Parse()
Expand All @@ -28,9 +32,32 @@ func main() {
os.Exit(0)
}

// --profile and --alias are synonyms (one names the same target). Reject a
// conflicting pair rather than silently picking one.
name := *profile
if *alias != "" {
if name != "" && name != *alias {
fmt.Fprintf(os.Stderr, "--profile and --alias are synonyms; specify only one\n")
os.Exit(1)
}
name = *alias
}

if *provider != "" && !providers.Valid(*provider) {
fmt.Fprintf(os.Stderr, "Invalid provider %q (valid: aws, minio, stui)\n", *provider)
os.Exit(1)
}

// --provider only takes effect together with a name; on its own the picker
// runs and would ignore it. Fail loudly instead of silently dropping it.
if *provider != "" && name == "" {
fmt.Fprintf(os.Stderr, "--provider requires --profile/--alias\n")
os.Exit(1)
}

// Validate inputs
if err := security.ValidProfileName(*profile); err != nil {
fmt.Fprintf(os.Stderr, "Invalid profile: %v\n", err)
if err := security.ValidProfileName(name); err != nil {
fmt.Fprintf(os.Stderr, "Invalid profile/alias: %v\n", err)
os.Exit(1)
}
if err := security.ValidBucketName(*bucket); err != nil {
Expand All @@ -40,9 +67,11 @@ func main() {

// Create TUI model
cfg := tui.Config{
Profile: *profile,
Profile: name,
Provider: *provider,
Region: *region,
Bucket: *bucket,
Endpoint: *endpoint,
DemoMode: *demo,
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.6
require (
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/charmbracelet/bubbles v0.21.1
Expand All @@ -17,7 +18,6 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
Expand Down
137 changes: 115 additions & 22 deletions internal/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,136 @@ import (

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)

// Client wraps the AWS S3 client with configuration
type Client struct {
S3 *s3.Client
Config aws.Config
Profile string
Region string
S3 *s3.Client
Config aws.Config
Profile string
Region string
Endpoint string

// opts is retained so WithRegion can rebuild an equivalent client
// (preserving endpoint, path-style and static credentials).
opts ClientOptions
}

// NewClient creates a new AWS client with the specified profile
// Supports SSO profiles - user must run `aws sso login --profile <profile>` first
// ClientOptions configures how the AWS/S3 client is built.
type ClientOptions struct {
Profile string
Region string
Endpoint string // custom S3-compatible endpoint URL; "" for real AWS
// PathStyle forces path-style addressing. If nil, it defaults to true
// whenever a custom Endpoint is in effect (required by most S3-compatible
// servers).
PathStyle *bool
// Static credentials. When AccessKeyID and SecretAccessKey are both set,
// they are used directly instead of the AWS profile / default credential
// chain. SessionToken is optional (for temporary credentials).
AccessKeyID string
SecretAccessKey string
SessionToken string
}

// NewClient creates a new AWS client with the specified profile.
// Supports SSO profiles - user must run `aws sso login --profile <profile>` first.
func NewClient(ctx context.Context, profile, region string) (*Client, error) {
var opts []func(*config.LoadOptions) error
return NewClientWithOptions(ctx, ClientOptions{Profile: profile, Region: region})
}

if profile != "" {
opts = append(opts, config.WithSharedConfigProfile(profile))
// NewClientWithOptions creates a new AWS client, optionally pointed at a custom
// S3-compatible endpoint (SeaweedFS, MinIO, Ceph, etc.).
//
// Region and SSO are loaded from the standard AWS config (~/.aws/config,
// ~/.aws/credentials) via the SDK. Credentials come from opts.AccessKeyID /
// opts.SecretAccessKey when both are set (used directly, bypassing any AWS
// profile); otherwise from the named profile / SDK default chain. The custom
// endpoint is supplied by stui's own config (see internal/config), an mc alias
// (see internal/providers), or the --endpoint-url flag; stui never extends the
// AWS config schema itself.
//
// The endpoint actually used is, in order of precedence: opts.Endpoint, then
// anything the SDK itself resolved from standard config or AWS_ENDPOINT_URL[_S3]
// env vars. When any custom endpoint is in effect, path-style addressing is
// enabled unless opts.PathStyle explicitly disables it.
func NewClientWithOptions(ctx context.Context, opts ClientOptions) (*Client, error) {
var loadOpts []func(*config.LoadOptions) error

useStaticCreds := opts.AccessKeyID != "" && opts.SecretAccessKey != ""

// With static credentials the endpoint is self-contained (the "profile" may
// exist only in stui's config, not in ~/.aws), so don't try to load a shared
// AWS profile that might not exist.
if opts.Profile != "" && !useStaticCreds {
loadOpts = append(loadOpts, config.WithSharedConfigProfile(opts.Profile))
}
if opts.Region != "" {
loadOpts = append(loadOpts, config.WithRegion(opts.Region))
}

if region != "" {
opts = append(opts, config.WithRegion(region))
// Use static credentials from stui config when provided, bypassing the AWS
// profile / default credential chain.
if useStaticCreds {
loadOpts = append(loadOpts, config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
opts.AccessKeyID, opts.SecretAccessKey, opts.SessionToken,
),
))
}

cfg, err := config.LoadDefaultConfig(ctx, opts...)
cfg, err := config.LoadDefaultConfig(ctx, loadOpts...)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}

s3Client := s3.NewFromConfig(cfg)
s3Client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if opts.Endpoint != "" {
o.BaseEndpoint = aws.String(opts.Endpoint)
}
})

// Determine the endpoint actually in effect (explicit, or SDK-resolved from
// AWS_ENDPOINT_URL[_S3] env vars).
resolvedEndpoint := opts.Endpoint
if resolvedEndpoint == "" && s3Client.Options().BaseEndpoint != nil {
resolvedEndpoint = *s3Client.Options().BaseEndpoint
}

// Decide on path-style addressing: explicit override, else default to true
// whenever a custom endpoint is in effect.
pathStyle := s3Client.Options().UsePathStyle
if opts.PathStyle != nil {
pathStyle = *opts.PathStyle
} else if resolvedEndpoint != "" {
pathStyle = true
}

if resolvedEndpoint != "" && pathStyle != s3Client.Options().UsePathStyle {
s3Client = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(resolvedEndpoint)
o.UsePathStyle = pathStyle
})
}

return &Client{
S3: s3Client,
Config: cfg,
Profile: profile,
Region: cfg.Region,
S3: s3Client,
Config: cfg,
Profile: opts.Profile,
Region: cfg.Region,
Endpoint: resolvedEndpoint,
opts: opts,
}, nil
}

// WithRegion creates a new client with a different region
// WithRegion creates a new client with a different region, preserving the
// original endpoint, path-style and credential settings.
func (c *Client) WithRegion(ctx context.Context, region string) (*Client, error) {
return NewClient(ctx, c.Profile, region)
opts := c.opts
opts.Region = region
return NewClientWithOptions(ctx, opts)
}

// ProfileInfo contains information about an AWS profile
Expand All @@ -72,6 +160,11 @@ func ListProfiles() ([]ProfileInfo, error) {
configPath := filepath.Join(homeDir, ".aws", "config")
file, err := os.Open(configPath)
if err != nil {
// No AWS config is fine: the user may rely solely on stui's own config
// (e.g. a self-contained MinIO/SeaweedFS endpoint).
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to open AWS config: %w", err)
}
defer file.Close()
Expand All @@ -90,8 +183,8 @@ func ListProfiles() ([]ProfileInfo, error) {

// Check for section header
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
// Save previous profile if it exists and has SSO config
if currentProfile != nil && currentProfile.SSOSession != "" {
// Save previous profile if it exists
if currentProfile != nil {
profiles = append(profiles, *currentProfile)
}

Expand Down Expand Up @@ -133,7 +226,7 @@ func ListProfiles() ([]ProfileInfo, error) {
}

// Don't forget the last profile
if currentProfile != nil && currentProfile.SSOSession != "" {
if currentProfile != nil {
profiles = append(profiles, *currentProfile)
}

Expand Down
114 changes: 114 additions & 0 deletions internal/aws/endpoint_resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package aws

import (
"context"
"os"
"path/filepath"
"testing"
)

func TestEndpointFromFlatProfile(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config")
os.WriteFile(cfgPath, []byte("[profile sw]\nregion = us-east-1\nendpoint_url = http://localhost:8333\n"), 0600)
t.Setenv("AWS_CONFIG_FILE", cfgPath)
t.Setenv("AWS_ACCESS_KEY_ID", "x")
t.Setenv("AWS_SECRET_ACCESS_KEY", "y")

c, err := NewClient(context.Background(), "sw", "")
if err != nil {
t.Fatal(err)
}
if c.Endpoint != "http://localhost:8333" {
t.Fatalf("endpoint not resolved, got %q", c.Endpoint)
}
if !c.S3.Options().UsePathStyle {
t.Fatal("expected path-style addressing for custom endpoint")
}
}

func TestServicesSectionEndpoint(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config")
os.WriteFile(cfgPath, []byte("[profile sw]\nregion = us-east-1\nservices = local\n\n[services local]\ns3 =\n endpoint_url = http://localhost:9000\n"), 0600)
t.Setenv("AWS_CONFIG_FILE", cfgPath)
t.Setenv("AWS_ACCESS_KEY_ID", "x")
t.Setenv("AWS_SECRET_ACCESS_KEY", "y")

c, err := NewClient(context.Background(), "sw", "")
if err != nil {
t.Fatal(err)
}
if c.Endpoint != "http://localhost:9000" {
t.Fatalf("services-section endpoint not resolved, got %q", c.Endpoint)
}
}


func TestStaticCredentialsSkipProfile(t *testing.T) {
// Point AWS config/credentials at empty temp files so the default chain
// has nothing; static creds from ClientOptions must still work.
dir := t.TempDir()
t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config"))
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials"))
t.Setenv("AWS_REGION", "")
t.Setenv("AWS_PROFILE", "")

c, err := NewClientWithOptions(context.Background(), ClientOptions{
Profile: "minio", // does not exist in ~/.aws
Region: "us-east-1",
Endpoint: "http://localhost:9000",
AccessKeyID: "AKIDLOCAL",
SecretAccessKey: "secret123",
})
if err != nil {
t.Fatal(err)
}
if c.Endpoint != "http://localhost:9000" {
t.Fatalf("endpoint wrong: %q", c.Endpoint)
}
if !c.S3.Options().UsePathStyle {
t.Fatal("expected path-style for custom endpoint")
}
creds, err := c.Config.Credentials.Retrieve(context.Background())
if err != nil {
t.Fatalf("retrieve creds: %v", err)
}
if creds.AccessKeyID != "AKIDLOCAL" || creds.SecretAccessKey != "secret123" {
t.Fatalf("static creds not applied: %#v", creds)
}
}

func TestWithRegionPreservesStaticCreds(t *testing.T) {
dir := t.TempDir()
t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config"))
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials"))
t.Setenv("AWS_PROFILE", "")
t.Setenv("AWS_REGION", "")

c, err := NewClientWithOptions(context.Background(), ClientOptions{
Profile: "minio",
Region: "us-east-1",
Endpoint: "http://localhost:9000",
AccessKeyID: "AKIDLOCAL",
SecretAccessKey: "secret123",
})
if err != nil {
t.Fatal(err)
}

c2, err := c.WithRegion(context.Background(), "eu-west-1")
if err != nil {
t.Fatalf("WithRegion: %v", err)
}
if c2.Endpoint != "http://localhost:9000" || !c2.S3.Options().UsePathStyle {
t.Fatalf("WithRegion lost endpoint/path-style: %#v", c2.Endpoint)
}
creds, err := c2.Config.Credentials.Retrieve(context.Background())
if err != nil {
t.Fatalf("retrieve creds after WithRegion: %v", err)
}
if creds.AccessKeyID != "AKIDLOCAL" || creds.SecretAccessKey != "secret123" {
t.Fatalf("WithRegion lost static creds: %#v", creds)
}
}
Loading