Skip to content

Commit e5e7b1d

Browse files
authored
Merge pull request #191 from robmorgan/fix/s3-virtual-host-dns
fix(s3): use nip.io for virtual-hosted style DNS resolution
2 parents 9de42b2 + 6f2de81 commit e5e7b1d

5 files changed

Lines changed: 147 additions & 38 deletions

File tree

internal/emulator/core/s3_utils.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ type S3HostInfo struct {
1212
// It handles the following patterns:
1313
// - bucket-name.s3.infraspec.sh (virtual-hosted, bucket = "bucket-name")
1414
// - bucket-name.s3.localhost (virtual-hosted, bucket = "bucket-name")
15+
// - bucket-name.s3.127.0.0.1.nip.io (virtual-hosted with nip.io, bucket = "bucket-name")
1516
// - bucket-name.localhost (legacy virtual-hosted, bucket = "bucket-name")
1617
// - s3.infraspec.sh (path-style base S3 endpoint)
1718
// - s3.localhost (path-style base S3 endpoint)
19+
// - s3.127.0.0.1.nip.io (path-style with nip.io)
1820
//
1921
// The host parameter should be the value of the Host header (with or without port).
2022
func ParseS3Host(host string) S3HostInfo {
@@ -30,6 +32,26 @@ func ParseS3Host(host string) S3HostInfo {
3032
return S3HostInfo{}
3133
}
3234

35+
// Check for nip.io/sslip.io DNS service patterns first
36+
// Pattern: bucket-name.s3.127.0.0.1.nip.io (virtual-hosted with nip.io)
37+
// Pattern: s3.127.0.0.1.nip.io (path-style with nip.io)
38+
if isNipIOHost(parts) {
39+
// Virtual-hosted: bucket-name.s3.IP.nip.io (parts[0]=bucket, parts[1]=s3)
40+
if len(parts) >= 5 && parts[1] == "s3" {
41+
return S3HostInfo{
42+
IsVirtualHosted: true,
43+
BucketName: parts[0],
44+
}
45+
}
46+
// Path-style: s3.IP.nip.io (parts[0]=s3)
47+
if parts[0] == "s3" {
48+
return S3HostInfo{
49+
IsVirtualHosted: false,
50+
BucketName: "",
51+
}
52+
}
53+
}
54+
3355
// Pattern: bucket-name.s3.infraspec.sh or bucket-name.s3.localhost
3456
// This is virtual-hosted style where bucket name is the first subdomain
3557
if len(parts) >= 3 && parts[1] == "s3" {
@@ -60,6 +82,17 @@ func ParseS3Host(host string) S3HostInfo {
6082
return S3HostInfo{}
6183
}
6284

85+
// isNipIOHost checks if the host parts indicate a nip.io or sslip.io DNS service.
86+
// These services provide wildcard DNS for IP addresses (e.g., bucket.s3.127.0.0.1.nip.io → 127.0.0.1)
87+
func isNipIOHost(parts []string) bool {
88+
if len(parts) < 2 {
89+
return false
90+
}
91+
lastPart := parts[len(parts)-1]
92+
secondLast := parts[len(parts)-2]
93+
return lastPart == "io" && (secondLast == "nip" || secondLast == "sslip")
94+
}
95+
6396
// IsS3VirtualHostedRequest checks if the given host represents an S3 virtual-hosted style request.
6497
// This includes patterns like:
6598
// - bucket-name.s3.infraspec.sh
@@ -75,9 +108,11 @@ func IsS3VirtualHostedRequest(host string) bool {
75108
// This includes patterns like:
76109
// - bucket-name.s3.infraspec.sh (virtual-hosted)
77110
// - bucket-name.s3.localhost (virtual-hosted)
111+
// - bucket-name.s3.127.0.0.1.nip.io (virtual-hosted with nip.io)
78112
// - bucket-name.localhost (legacy virtual-hosted)
79113
// - s3.infraspec.sh (path-style)
80114
// - s3.localhost (path-style)
115+
// - s3.127.0.0.1.nip.io (path-style with nip.io)
81116
//
82117
// The host parameter should be the value of the Host header (with or without port).
83118
func IsS3Request(host string) bool {
@@ -93,6 +128,14 @@ func IsS3Request(host string) bool {
93128
return false
94129
}
95130

131+
// Check for nip.io/sslip.io patterns: *.s3.IP.nip.io or s3.IP.nip.io
132+
if isNipIOHost(parts) {
133+
// Look for "s3" in the first or second position
134+
if parts[0] == "s3" || (len(parts) >= 2 && parts[1] == "s3") {
135+
return true
136+
}
137+
}
138+
96139
// Pattern: bucket-name.s3.something or s3.something
97140
if len(parts) >= 2 && (parts[0] == "s3" || (len(parts) >= 3 && parts[1] == "s3")) {
98141
return true

internal/emulator/core/s3_utils_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,37 @@ func TestParseS3Host(t *testing.T) {
4848
wantVirtual: true,
4949
wantBucketName: "my-test-bucket-123",
5050
},
51+
// nip.io DNS service patterns
52+
{
53+
name: "virtual-hosted with nip.io",
54+
host: "my-bucket.s3.127.0.0.1.nip.io",
55+
wantVirtual: true,
56+
wantBucketName: "my-bucket",
57+
},
58+
{
59+
name: "virtual-hosted with nip.io and port",
60+
host: "my-bucket.s3.127.0.0.1.nip.io:8000",
61+
wantVirtual: true,
62+
wantBucketName: "my-bucket",
63+
},
64+
{
65+
name: "virtual-hosted with sslip.io",
66+
host: "my-bucket.s3.127.0.0.1.sslip.io",
67+
wantVirtual: true,
68+
wantBucketName: "my-bucket",
69+
},
70+
{
71+
name: "path-style with nip.io",
72+
host: "s3.127.0.0.1.nip.io",
73+
wantVirtual: false,
74+
wantBucketName: "",
75+
},
76+
{
77+
name: "path-style with nip.io and port",
78+
host: "s3.127.0.0.1.nip.io:8000",
79+
wantVirtual: false,
80+
wantBucketName: "",
81+
},
5182
// Path-style patterns (not virtual-hosted)
5283
{
5384
name: "path-style s3.infraspec.sh",
@@ -130,8 +161,11 @@ func TestIsS3VirtualHostedRequest(t *testing.T) {
130161
{"virtual-hosted bucket.s3.domain", "my-bucket.s3.infraspec.sh", true},
131162
{"virtual-hosted bucket.s3.localhost", "my-bucket.s3.localhost", true},
132163
{"legacy bucket.localhost", "my-bucket.localhost", true},
164+
{"virtual-hosted nip.io", "my-bucket.s3.127.0.0.1.nip.io", true},
165+
{"virtual-hosted sslip.io", "my-bucket.s3.127.0.0.1.sslip.io", true},
133166
{"path-style s3.domain", "s3.infraspec.sh", false},
134167
{"path-style s3.localhost", "s3.localhost", false},
168+
{"path-style nip.io", "s3.127.0.0.1.nip.io", false},
135169
{"empty", "", false},
136170
{"localhost", "localhost", false},
137171
{"other service", "dynamodb.infraspec.sh", false},
@@ -160,6 +194,12 @@ func TestIsS3Request(t *testing.T) {
160194
{"path-style s3.domain", "s3.infraspec.sh", true},
161195
{"path-style s3.localhost", "s3.localhost", true},
162196
{"path-style with port", "s3.localhost:8000", true},
197+
// nip.io DNS service patterns
198+
{"virtual-hosted nip.io", "my-bucket.s3.127.0.0.1.nip.io", true},
199+
{"virtual-hosted nip.io with port", "my-bucket.s3.127.0.0.1.nip.io:8000", true},
200+
{"virtual-hosted sslip.io", "my-bucket.s3.127.0.0.1.sslip.io", true},
201+
{"path-style nip.io", "s3.127.0.0.1.nip.io", true},
202+
{"path-style nip.io with port", "s3.127.0.0.1.nip.io:8000", true},
163203
// Non-S3 requests
164204
{"empty", "", false},
165205
{"plain localhost", "localhost", false},
@@ -188,8 +228,12 @@ func TestExtractBucketNameFromHost(t *testing.T) {
188228
{"virtual-hosted bucket.s3.localhost", "my-bucket.s3.localhost", "my-bucket"},
189229
{"legacy bucket.localhost", "my-bucket.localhost", "my-bucket"},
190230
{"with port", "my-bucket.s3.localhost:8000", "my-bucket"},
231+
{"virtual-hosted nip.io", "my-bucket.s3.127.0.0.1.nip.io", "my-bucket"},
232+
{"virtual-hosted nip.io with port", "my-bucket.s3.127.0.0.1.nip.io:8000", "my-bucket"},
233+
{"virtual-hosted sslip.io", "my-bucket.s3.127.0.0.1.sslip.io", "my-bucket"},
191234
{"path-style s3.domain", "s3.infraspec.sh", ""},
192235
{"path-style s3.localhost", "s3.localhost", ""},
236+
{"path-style nip.io", "s3.127.0.0.1.nip.io", ""},
193237
{"empty", "", ""},
194238
{"localhost", "localhost", ""},
195239
{"other service", "dynamodb.infraspec.sh", ""},

pkg/awshelpers/endpoints.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package awshelpers
22

33
import (
4+
"fmt"
45
"net/url"
56
"os"
67
"strings"
@@ -38,27 +39,38 @@ func GetVirtualCloudEndpoint(service string) (string, bool) {
3839
// to the base endpoint. For example:
3940
// - Base: "https://infraspec.sh" + Subdomain: "s3" = "https://s3.infraspec.sh"
4041
// - Base: "https://infraspec.sh" + Subdomain: "dynamodb" = "https://dynamodb.infraspec.sh"
41-
// - Base: "http://localhost:8000" + Subdomain: "s3" = "http://localhost:8000" (no subdomain for localhost)
42-
// - Base: "http://127.0.0.1:8000" + Subdomain: "sts" = "http://127.0.0.1:8000" (no subdomain for 127.0.0.1)
42+
// - Base: "http://localhost:8000" + Subdomain: "s3" = "http://s3.127.0.0.1.nip.io:8000"
43+
// - Base: "http://127.0.0.1:8000" + Subdomain: "s3" = "http://s3.127.0.0.1.nip.io:8000"
44+
//
45+
// For localhost/127.0.0.1, nip.io is used to enable wildcard DNS resolution for virtual-hosted
46+
// style S3 addressing (e.g., bucket.s3.127.0.0.1.nip.io resolves to 127.0.0.1).
4347
func BuildServiceEndpoint(baseEndpoint, subdomain string) string {
4448
parsedURL, err := url.Parse(baseEndpoint)
4549
if err != nil {
4650
// If parsing fails, return the base endpoint as-is
4751
return baseEndpoint
4852
}
4953

50-
// Extract host and port
5154
host := parsedURL.Hostname()
55+
port := parsedURL.Port()
5256

53-
// For localhost or 127.0.0.1, don't use subdomains as they don't resolve properly
54-
// The emulator handles all services on the same endpoint
57+
// For localhost or 127.0.0.1, use nip.io for wildcard DNS support
58+
// This enables virtual-hosted style S3 addressing (bucket.s3.127.0.0.1.nip.io)
5559
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
56-
return baseEndpoint
60+
// Convert to IP format that nip.io understands
61+
if host == "localhost" || host == "::1" {
62+
host = "127.0.0.1"
63+
}
64+
// Build: subdomain.IP.nip.io (e.g., s3.127.0.0.1.nip.io)
65+
newHost := fmt.Sprintf("%s.%s.nip.io", subdomain, host)
66+
if port != "" {
67+
newHost = newHost + ":" + port
68+
}
69+
parsedURL.Host = newHost
70+
return parsedURL.String()
5771
}
5872

59-
port := parsedURL.Port()
60-
61-
// Build new host with subdomain prefix
73+
// For remote endpoints, add subdomain as prefix
6274
newHost := subdomain + "." + host
6375

6476
if port != "" {

pkg/awshelpers/endpoints_test.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ func TestGetVirtualCloudEndpoint(t *testing.T) {
3333
expectedOk: true,
3434
},
3535
{
36-
name: "returns localhost endpoint without subdomain",
36+
name: "builds nip.io endpoint for localhost",
3737
service: "rds",
3838
awsEndpointURL: "http://localhost:8000",
39-
expectedEndpoint: "http://localhost:8000",
39+
expectedEndpoint: "http://rds.127.0.0.1.nip.io:8000",
4040
expectedOk: true,
4141
},
4242
{
43-
name: "returns 127.0.0.1 endpoint without subdomain",
43+
name: "builds nip.io endpoint for 127.0.0.1",
4444
service: "s3",
4545
awsEndpointURL: "http://127.0.0.1:9000",
46-
expectedEndpoint: "http://127.0.0.1:9000",
46+
expectedEndpoint: "http://s3.127.0.0.1.nip.io:9000",
4747
expectedOk: true,
4848
},
4949
{
@@ -110,22 +110,28 @@ func TestBuildServiceEndpoint(t *testing.T) {
110110
expected: "https://dynamodb.example.com",
111111
},
112112
{
113-
name: "no subdomain for localhost",
113+
name: "builds nip.io for localhost",
114114
baseEndpoint: "http://localhost:8000",
115115
subdomain: "rds",
116-
expected: "http://localhost:8000",
116+
expected: "http://rds.127.0.0.1.nip.io:8000",
117117
},
118118
{
119-
name: "no subdomain for 127.0.0.1",
119+
name: "builds nip.io for 127.0.0.1",
120120
baseEndpoint: "http://127.0.0.1:8000",
121121
subdomain: "s3",
122-
expected: "http://127.0.0.1:8000",
122+
expected: "http://s3.127.0.0.1.nip.io:8000",
123123
},
124124
{
125-
name: "no subdomain for ::1",
125+
name: "builds nip.io for ::1",
126126
baseEndpoint: "http://[::1]:8000",
127127
subdomain: "sts",
128-
expected: "http://[::1]:8000",
128+
expected: "http://sts.127.0.0.1.nip.io:8000",
129+
},
130+
{
131+
name: "builds nip.io for localhost without port",
132+
baseEndpoint: "http://localhost",
133+
subdomain: "ec2",
134+
expected: "http://ec2.127.0.0.1.nip.io",
129135
},
130136
{
131137
name: "handles custom domain with port",

pkg/steps/terraform/terraform.go

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,9 @@ func newTerraformOutputContainsStep(ctx context.Context, outputName, expectedVal
195195
// This configures Terraform/OpenTofu to use the embedded emulator instead of real AWS.
196196
//
197197
// The function sets service-specific AWS_ENDPOINT_URL_* environment variables that are
198-
// automatically recognized by the AWS provider. All services use the same localhost endpoint.
198+
// automatically recognized by the AWS provider. For localhost endpoints, nip.io is used
199+
// to enable wildcard DNS resolution for services like S3 Control that use account-ID-prefixed
200+
// hostnames (e.g., 123456789012.s3-control.127.0.0.1.nip.io resolves to 127.0.0.1).
199201
// See: https://search.opentofu.org/provider/opentofu/aws/v6.1.0/docs/guides/custom-service-endpoints
200202
func configureVirtualCloudEndpoints(options *iacprovisioner.Options, workingDir string) error {
201203
// Check if AWS_ENDPOINT_URL is set (indicates embedded emulator mode)
@@ -207,27 +209,29 @@ func configureVirtualCloudEndpoints(options *iacprovisioner.Options, workingDir
207209

208210
config.Logging.Logger.Infof("Configuring embedded emulator endpoints for Terraform/OpenTofu")
209211

210-
// List of AWS services to configure endpoints for
211-
// All services use the same localhost endpoint in embedded mode
212-
services := []string{
213-
"DYNAMODB",
214-
"STS",
215-
"RDS",
216-
"S3",
217-
"S3_CONTROL",
218-
"EC2",
219-
"SSM",
220-
"APPLICATION_AUTO_SCALING",
221-
"IAM",
222-
"SQS",
223-
"LAMBDA",
212+
// Map of AWS SDK service identifiers to subdomain names
213+
// For localhost endpoints, BuildServiceEndpoint uses nip.io for wildcard DNS support
214+
serviceMap := map[string]string{
215+
"DYNAMODB": "dynamodb",
216+
"STS": "sts",
217+
"RDS": "rds",
218+
"S3": "s3",
219+
"S3_CONTROL": "s3-control",
220+
"EC2": "ec2",
221+
"SSM": "ssm",
222+
"APPLICATION_AUTO_SCALING": "autoscaling",
223+
"IAM": "iam",
224+
"SQS": "sqs",
225+
"LAMBDA": "lambda",
224226
}
225227

226228
// Set service-specific endpoint environment variables
227-
for _, svc := range services {
228-
envVar := fmt.Sprintf("AWS_ENDPOINT_URL_%s", svc)
229-
options.EnvVars[envVar] = endpoint
230-
config.Logging.Logger.Debugf("Setting %s=%s", envVar, endpoint)
229+
for envVarSuffix, subdomain := range serviceMap {
230+
envVar := fmt.Sprintf("AWS_ENDPOINT_URL_%s", envVarSuffix)
231+
// Build service-specific endpoint (uses nip.io for localhost to enable wildcard DNS)
232+
serviceEndpoint := awshelpers.BuildServiceEndpoint(endpoint, subdomain)
233+
options.EnvVars[envVar] = serviceEndpoint
234+
config.Logging.Logger.Debugf("Setting %s=%s", envVar, serviceEndpoint)
231235
}
232236

233237
// Set dummy credentials for embedded emulator

0 commit comments

Comments
 (0)