diff --git a/README.md b/README.md index 5ab54db..5b07c46 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,8 @@ even when the same bearer-authenticated request works with curl. ```yaml # MeshMapper integrations (optional, no API key). Import transport scopes and # IATA border outlines from MeshMapper instead of maintaining scopes: and -# iatas.*.borderFile by hand. Scope sources must be in a configured region; -# zones covers every known IATA. See config.yaml.example for limits and behaviour. +# iatas.*.borderFile by hand. Neither needs the IATAs to be in a configured +# region. See config.yaml.example for limits and behaviour. #meshmapper: # scopes: # enabled: true diff --git a/config.yaml.example b/config.yaml.example index 96967a1..39160c2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -43,10 +43,10 @@ ratelimit: # MeshMapper integrations (optional, no API key). Import transport scopes and # IATA border outlines from MeshMapper instead of maintaining scopes: and -# iatas.*.borderFile by hand. Both need the IATAs to be in a configured region below. +# iatas.*.borderFile by hand. # # Scope-name discovery. Manual scopes remain authoritative. -# Sources must belong to a configured region's IATAs. +# Sources are keyed by IATA code; the IATA need not be in a configured region. # Group endpoints are not supported: their names cannot be attributed to each IATA. # At most 16 sources, 64 names per source, 64 KiB per response; no page scraping. # Catalogues and ETags persist in PostgreSQL. Failed refreshes retain known names. diff --git a/internal/config/meshmapper.go b/internal/config/meshmapper.go index 0d80c6a..e0bc107 100644 --- a/internal/config/meshmapper.go +++ b/internal/config/meshmapper.go @@ -45,7 +45,10 @@ func (c MeshMapperScopesConfig) Interval() time.Duration { return c.RefreshInterval.Duration } -var meshMapperHost = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.meshmapper\.net$`) +var ( + meshMapperHost = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.meshmapper\.net$`) + iataCode = regexp.MustCompile(`^[A-Z]{3}$`) +) func (c *Config) validateMeshMapper() error { if z := c.MeshMapper.Zones; z.Enabled { @@ -64,15 +67,10 @@ func (c *Config) validateMeshMapper() error { if s.Interval() < 5*time.Minute || s.Interval() > 24*time.Hour { return fmt.Errorf("meshmapper.scopes.refresh_interval must be between 5m and 24h") } - configured := map[string]bool{} - for _, region := range c.Regions { - for _, iata := range region.IATAs { - configured[iata] = true - } - } for iata, endpoint := range s.Sources { - if !configured[iata] { - return fmt.Errorf("meshmapper.scopes source %q must belong to a configured region", iata) + // Catalogues must name the source IATA exactly, so reject keys that could never match. + if !iataCode.MatchString(iata) { + return fmt.Errorf("meshmapper.scopes source %q must be a three-letter uppercase IATA code", iata) } u, err := url.Parse(endpoint) if err != nil || u.Scheme != "https" || u.User != nil || !meshMapperHost.MatchString(u.Host) || diff --git a/internal/config/meshmapper_test.go b/internal/config/meshmapper_test.go index 13d2720..cbaf0b3 100644 --- a/internal/config/meshmapper_test.go +++ b/internal/config/meshmapper_test.go @@ -18,7 +18,10 @@ func TestMeshMapperConfig(t *testing.T) { bad bool }{ {"valid", valid, false}, - {"unknown region", strings.Replace(valid, "iatas: [YOW]", "iatas: [YVR]", 1), true}, + {"outside any region", strings.Replace(valid, "iatas: [YOW]", "iatas: [YVR]", 1), false}, + {"no regions", valid[strings.Index(valid, "meshmapper:"):], false}, + {"lowercase IATA", strings.Replace(valid, " YOW:", " yow:", 1), true}, + {"long IATA", strings.Replace(valid, " YOW:", " YOWX:", 1), true}, {"http", strings.Replace(valid, "https:", "http:", 1), true}, {"private host", strings.Replace(valid, "yow.meshmapper.net", "127.0.0.1", 1), true}, {"credentials", strings.Replace(valid, "https://", "https://secret@", 1), true},