diff --git a/docs/configuration.md b/docs/configuration.md index 672cc52..f7661ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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) diff --git a/internal/config/file.go b/internal/config/file.go index a3de804..2342b37 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -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) @@ -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) diff --git a/internal/config/file_test.go b/internal/config/file_test.go index 3f7deb4..7cd8db8 100644 --- a/internal/config/file_test.go +++ b/internal/config/file_test.go @@ -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: ` @@ -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) + } + }) +} diff --git a/main.go b/main.go index 0782fad..4728bc0 100644 --- a/main.go +++ b/main.go @@ -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) } }