Skip to content
Merged
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
28 changes: 27 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,9 +470,35 @@ timeout = 120
redirects = 0
```

### Wildcard Subdomain Matching

You can use `[*.domain.com]` syntax to match any subdomain of a domain:

```ini
# Match any subdomain of example.com
[*.example.com]
header = X-API-Key: shared-key

# Match any subdomain of api.example.com (more specific)
[*.api.example.com]
header = X-API-Key: api-specific-key

# Exact match always takes priority
[admin.example.com]
header = X-API-Key: admin-key
```

**Matching rules:**

- `*.example.com` matches `api.example.com`, `a.b.example.com`, etc.
- `*.example.com` does **not** match `example.com` itself
- Exact matches always take priority over wildcard matches
- When multiple wildcards match, the most specific (longest suffix) wins
- Only one host config section is applied per request (no merging across sections)

### Host Section Rules

- Section names should be the exact hostname (without protocol or path)
- Section names should be the exact hostname (without protocol or path), or a wildcard pattern like `*.domain.com`
- Host-specific settings override global settings
- Command-line flags override both global and host-specific settings
- Multiple headers and query parameters are merged (host-specific first, then global)
Expand Down
35 changes: 35 additions & 0 deletions internal/config/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ func parseFile(path, s string) (*File, error) {
return nil, newFileError(path, num, errors.New("hostname cannot be empty"))
}

if strings.Contains(hostStr, "*") {
if !strings.HasPrefix(hostStr, "*.") || len(hostStr) < 3 || strings.Contains(hostStr[2:], "*") {
err := fmt.Errorf("invalid wildcard hostname '%s': must be in the format '*.domain'", hostStr)
return nil, newFileError(path, num, err)
}
}

config = &Config{isFile: true}
if f.Hosts == nil {
f.Hosts = make(map[string]*Config)
Expand Down Expand Up @@ -175,6 +182,34 @@ func (err fileError) Error() string {
return fmt.Sprintf("config file '%s': line %d: %s", err.file, err.line, err.err.Error())
}

// HostConfig returns the Config for the given hostname, using exact match
// first, then falling back to the most-specific wildcard match.
func (f *File) HostConfig(hostname string) *Config {
if hostname == "" {
return nil
}

// Exact match first.
if cfg, ok := f.Hosts[hostname]; ok {
return cfg
}

// Wildcard: find longest (most specific) suffix match.
var best *Config
var bestLen int
for key, cfg := range f.Hosts {
if !strings.HasPrefix(key, "*.") {
continue
}
suffix := key[1:] // e.g. ".example.com"
if strings.HasSuffix(hostname, suffix) && len(suffix) > bestLen {
bestLen = len(suffix)
best = cfg
}
}
return best
}

func (err fileError) PrintTo(p *core.Printer) {
p.WriteString("config file '")
p.Set(core.Dim)
Expand Down
110 changes: 110 additions & 0 deletions internal/config/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,41 @@ func TestParseFile(t *testing.T) {
expFile *File
expErr string
}{
{
name: "valid wildcard section",
config: `[*.example.com]
insecure = true`,
expFile: &File{
Global: &Config{isFile: true},
Hosts: map[string]*Config{
"*.example.com": {
isFile: true,
Insecure: core.PointerTo(true),
},
},
Path: "test/config",
},
},
{
name: "invalid wildcard missing dot",
config: `[*example.com]`,
expErr: "invalid wildcard hostname '*example.com': must be in the format '*.domain'",
},
{
name: "invalid wildcard only star dot",
config: `[*.]`,
expErr: "invalid wildcard hostname '*.': must be in the format '*.domain'",
},
{
name: "invalid wildcard double star",
config: `[*.*.com]`,
expErr: "invalid wildcard hostname '*.*.com': must be in the format '*.domain'",
},
{
name: "invalid wildcard star in middle",
config: `[example.*.com]`,
expErr: "invalid wildcard hostname 'example.*.com': must be in the format '*.domain'",
},
{
name: "successful parse",
config: `
Expand Down Expand Up @@ -90,3 +125,78 @@ func TestParseFile(t *testing.T) {
})
}
}

func TestFileHostConfig(t *testing.T) {
exactCfg := &Config{isFile: true, Insecure: core.PointerTo(true)}
wildcardCfg := &Config{isFile: true, Insecure: core.PointerTo(false)}
specificWildcardCfg := &Config{isFile: true, NoPager: core.PointerTo(true)}

f := &File{
Global: &Config{isFile: true},
Hosts: map[string]*Config{
"api.example.com": exactCfg,
"*.example.com": wildcardCfg,
"*.api.example.com": specificWildcardCfg,
},
}

tests := []struct {
name string
hostname string
expected *Config
}{
{
name: "exact match",
hostname: "api.example.com",
expected: exactCfg,
},
{
name: "wildcard match",
hostname: "www.example.com",
expected: wildcardCfg,
},
{
name: "wildcard does not match base domain",
hostname: "example.com",
expected: nil,
},
{
name: "deeply nested subdomain matches wildcard",
hostname: "a.b.example.com",
expected: wildcardCfg,
},
{
name: "most specific wildcard wins",
hostname: "v1.api.example.com",
expected: specificWildcardCfg,
},
{
name: "no match",
hostname: "other.com",
expected: nil,
},
{
name: "empty hostname",
hostname: "",
expected: nil,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := f.HostConfig(test.hostname)
if got != test.expected {
t.Fatalf("HostConfig(%q) = %v, want %v", test.hostname, got, test.expected)
}
})
}

// Test with nil Hosts map.
t.Run("nil hosts map", func(t *testing.T) {
nilFile := &File{Global: &Config{isFile: true}}
got := nilFile.HostConfig("example.com")
if got != nil {
t.Fatalf("HostConfig with nil Hosts = %v, want nil", got)
}
})
}
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,8 @@ func parseConfigFile(app *cli.App, p *core.Printer) error {
}

if app.URL != nil {
hostCfg, ok := file.Hosts[app.URL.Hostname()]
if ok {
hostname := app.URL.Hostname()
if hostCfg := file.HostConfig(hostname); hostCfg != nil {
app.Cfg.Merge(hostCfg)
}
}
Expand Down