diff --git a/README.md b/README.md index 540154a..57dc1c3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Useful flags: - `-key-id KEY`: uses explicit S3 access key credentials. - `-application-key SECRET`: uses explicit S3 secret or application key credentials. - `-endpoint-url https://s3.example.com`: uses a custom S3-compatible endpoint. +- `-insecure-skip-verify`: skips TLS certificate verification for the S3 server. Use only with trusted endpoints. - `-http-timeout 30s`: sets a timeout for AWS HTTP requests. Flags override values loaded from `-config`. @@ -45,6 +46,7 @@ Example config: "key_id": "REPLACE_WITH_KEY_ID", "application_key": "REPLACE_WITH_APPLICATION_KEY", "endpoint_url": "https://s3.us-east-1.amazonaws.com", + "insecure_skip_verify": false, "interval": "1m", "http_timeout": "30s", "once": false, diff --git a/cmd/s3watch/main.go b/cmd/s3watch/main.go index f22937e..e467362 100644 --- a/cmd/s3watch/main.go +++ b/cmd/s3watch/main.go @@ -3,6 +3,7 @@ package main import ( "context" + "crypto/tls" "encoding/json" "flag" "fmt" @@ -44,7 +45,7 @@ func run() error { defer stop() awsOptions := []func(*config.LoadOptions) error{ - config.WithHTTPClient(&http.Client{Timeout: cfg.httpTimeout}), + config.WithHTTPClient(newHTTPClient(cfg)), } if cfg.region != "" { awsOptions = append(awsOptions, config.WithRegion(cfg.region)) @@ -84,18 +85,19 @@ func run() error { } type cliConfig struct { - bucket string - prefix string - dir string - hook string - region string - keyID string - applicationKey string - endpointURL string - interval time.Duration - httpTimeout time.Duration - once bool - prune bool + bucket string + prefix string + dir string + hook string + region string + keyID string + applicationKey string + endpointURL string + insecureSkipVerify bool + interval time.Duration + httpTimeout time.Duration + once bool + prune bool } func (c cliConfig) validate() error { @@ -171,6 +173,7 @@ func newFlagSet(name string, cfg *cliConfig, configPath *string) *flag.FlagSet { fs.StringVar(&cfg.keyID, "key-id", cfg.keyID, "S3 access key ID override") fs.StringVar(&cfg.applicationKey, "application-key", cfg.applicationKey, "S3 secret/application key override") fs.StringVar(&cfg.endpointURL, "endpoint-url", cfg.endpointURL, "S3 endpoint URL override") + fs.BoolVar(&cfg.insecureSkipVerify, "insecure-skip-verify", cfg.insecureSkipVerify, "skip verification of the S3 server TLS certificate") fs.DurationVar(&cfg.httpTimeout, "http-timeout", cfg.httpTimeout, "timeout for AWS HTTP requests") return fs } @@ -213,6 +216,9 @@ func loadConfigFile(path string, cfg *cliConfig) error { if disk.EndpointURL != nil { cfg.endpointURL = *disk.EndpointURL } + if disk.InsecureSkipVerify != nil { + cfg.insecureSkipVerify = *disk.InsecureSkipVerify + } if disk.Interval != nil { duration, err := time.ParseDuration(*disk.Interval) if err != nil { @@ -238,16 +244,29 @@ func loadConfigFile(path string, cfg *cliConfig) error { } type configFile struct { - Bucket *string `json:"bucket"` - Prefix *string `json:"prefix"` - Dir *string `json:"dir"` - Hook *string `json:"hook"` - Region *string `json:"region"` - KeyID *string `json:"key_id"` - ApplicationKey *string `json:"application_key"` - EndpointURL *string `json:"endpoint_url"` - Interval *string `json:"interval"` - HTTPTimeout *string `json:"http_timeout"` - Once *bool `json:"once"` - Prune *bool `json:"prune"` + Bucket *string `json:"bucket"` + Prefix *string `json:"prefix"` + Dir *string `json:"dir"` + Hook *string `json:"hook"` + Region *string `json:"region"` + KeyID *string `json:"key_id"` + ApplicationKey *string `json:"application_key"` + EndpointURL *string `json:"endpoint_url"` + InsecureSkipVerify *bool `json:"insecure_skip_verify"` + Interval *string `json:"interval"` + HTTPTimeout *string `json:"http_timeout"` + Once *bool `json:"once"` + Prune *bool `json:"prune"` +} + +func newHTTPClient(cfg cliConfig) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + if cfg.insecureSkipVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // Explicit opt-in for self-signed S3 endpoints. + } + + return &http.Client{ + Transport: transport, + Timeout: cfg.httpTimeout, + } } diff --git a/cmd/s3watch/main_test.go b/cmd/s3watch/main_test.go index bd31000..564d1af 100644 --- a/cmd/s3watch/main_test.go +++ b/cmd/s3watch/main_test.go @@ -1,6 +1,9 @@ package main import ( + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -17,6 +20,7 @@ func TestParseConfigLoadsJSONConfig(t *testing.T) { "key_id": "key-id-from-config", "application_key": "application-key-from-config", "endpoint_url": "https://s3.example.com", + "insecure_skip_verify": true, "interval": "45s", "http_timeout": "10s", "once": true, @@ -52,6 +56,9 @@ func TestParseConfigLoadsJSONConfig(t *testing.T) { if cfg.endpointURL != "https://s3.example.com" { t.Fatalf("endpointURL = %q, want https://s3.example.com", cfg.endpointURL) } + if !cfg.insecureSkipVerify { + t.Fatal("insecureSkipVerify = false, want true") + } if cfg.interval != 45*time.Second { t.Fatalf("interval = %s, want 45s", cfg.interval) } @@ -71,6 +78,7 @@ func TestParseConfigAllowsFlagOverrides(t *testing.T) { "bucket": "config-bucket", "dir": "/from/config", "interval": "1m", + "insecure_skip_verify": true, "prune": true }`) @@ -82,6 +90,7 @@ func TestParseConfigAllowsFlagOverrides(t *testing.T) { "-key-id", "flag-key-id", "-application-key", "flag-application-key", "-endpoint-url", "https://s3.flag.example.com", + "-insecure-skip-verify=false", "-prune=false", }) if err != nil { @@ -106,11 +115,58 @@ func TestParseConfigAllowsFlagOverrides(t *testing.T) { if cfg.endpointURL != "https://s3.flag.example.com" { t.Fatalf("endpointURL = %q, want https://s3.flag.example.com", cfg.endpointURL) } + if cfg.insecureSkipVerify { + t.Fatal("insecureSkipVerify = true, want false") + } if cfg.prune { t.Fatal("prune = true, want false") } } +func TestHTTPClientCertificateVerification(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := io.WriteString(w, "ok") + if err != nil { + t.Errorf("writing response: %v", err) + } + })) + defer server.Close() + + tests := []struct { + name string + insecureSkipVerify bool + wantErr bool + }{ + {name: "verify certificate", wantErr: true}, + {name: "skip certificate verification", insecureSkipVerify: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := defaultConfig() + cfg.insecureSkipVerify = tt.insecureSkipVerify + client := newHTTPClient(cfg) + + response, err := client.Get(server.URL) + if tt.wantErr { + if err == nil { + if closeErr := response.Body.Close(); closeErr != nil { + t.Errorf("closing response body: %v", closeErr) + } + t.Fatal("Get() error = nil, want certificate verification error") + } + return + } + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if err := response.Body.Close(); err != nil { + t.Fatalf("closing response body: %v", err) + } + }) + } +} + func TestConfigValidateRequiresCompleteStaticCredentials(t *testing.T) { tests := []struct { name string diff --git a/examples/s3watch.json b/examples/s3watch.json index 8173afd..c86df7c 100644 --- a/examples/s3watch.json +++ b/examples/s3watch.json @@ -7,6 +7,7 @@ "key_id": "REPLACE_WITH_KEY_ID", "application_key": "REPLACE_WITH_APPLICATION_KEY", "endpoint_url": "https://s3.us-east-1.amazonaws.com", + "insecure_skip_verify": false, "interval": "1m", "http_timeout": "30s", "once": false,