diff --git a/cmd/tegola/cmd/server.go b/cmd/tegola/cmd/server.go index c3b2b8666..cb4a53526 100644 --- a/cmd/tegola/cmd/server.go +++ b/cmd/tegola/cmd/server.go @@ -60,6 +60,10 @@ var serverCmd = &cobra.Command{ server.URIPrefix = string(conf.Webserver.URIPrefix) } + if conf.Webserver.ProxyProtocol != "" { + server.ProxyProtocol = string(conf.Webserver.ProxyProtocol) + } + if conf.Webserver.SSLCert+conf.Webserver.SSLKey != "" { if conf.Webserver.SSLCert == "" { // error diff --git a/config/config.go b/config/config.go index aef1a70fa..96c08d2e3 100644 --- a/config/config.go +++ b/config/config.go @@ -71,12 +71,13 @@ type Config struct { // Webserver represents the config options for the webserver part of Tegola type Webserver struct { - HostName env.String `toml:"hostname"` - Port env.String `toml:"port"` - URIPrefix env.String `toml:"uri_prefix"` - Headers env.Dict `toml:"headers"` - SSLCert env.String `toml:"ssl_cert"` - SSLKey env.String `toml:"ssl_key"` + HostName env.String `toml:"hostname"` + Port env.String `toml:"port"` + URIPrefix env.String `toml:"uri_prefix"` + Headers env.Dict `toml:"headers"` + SSLCert env.String `toml:"ssl_cert"` + SSLKey env.String `toml:"ssl_key"` + ProxyProtocol env.String `toml:"proxy_protocol"` } // ValidateAndRegisterParams ensures configured params don't conflict with existing diff --git a/config/config_test.go b/config/config_test.go index 5bf34ced6..74033257d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -121,6 +121,7 @@ func TestParse(t *testing.T) { hostname = "cdn.tegola.io" port = ":8080" cors_allowed_origin = "tegola.io" + proxy_protocol = "https" [webserver.headers] Access-Control-Allow-Origin = "*" @@ -181,8 +182,9 @@ func TestParse(t *testing.T) { TileBuffer: env.IntPtr(env.Int(12)), LocationName: "", Webserver: config.Webserver{ - HostName: "cdn.tegola.io", - Port: ":8080", + HostName: "cdn.tegola.io", + Port: ":8080", + ProxyProtocol: "https", Headers: env.Dict{ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", @@ -201,7 +203,7 @@ func TestParse(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -345,7 +347,7 @@ func TestParse(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water_0_5", "geometry_fieldname": "geom", @@ -477,6 +479,163 @@ func TestParse(t *testing.T) { expected: config.Config{}, expectedErr: env.ErrEnvVar("I_AM_MISSING"), }, + "4 test empty proxy_protocol": { + config: ` + [webserver] + hostname = "${ENV_TEST_HOST_1}.${ENV_TEST_HOST_2}.${ENV_TEST_HOST_3}" + port = "${ENV_TEST_WEBSERVER_PORT}" + proxy_protocol = "" + + [webserver.headers] + Cache-Control = "${ENV_TEST_WEBSERVER_HEADER_STRING}" + Test = "Test" + # impossible but to test ParseDict + Impossible-Header = {"test" = "${ENV_TEST_WEBSERVER_HEADER_STRING}"} + + [[providers]] + name = "provider1" + type = "postgis" + host = "localhost" + port = 5432 + database = "osm_water" + user = "admin" + password = "" + + [[providers.layers]] + name = "water_0_5" + geometry_fieldname = "geom" + id_fieldname = "gid" + sql = "SELECT gid, ST_AsBinary(geom) AS geom FROM simplified_water_polygons WHERE geom && !BBOX!" + + [[providers.layers]] + name = "water_6_10" + geometry_fieldname = "geom" + id_fieldname = "gid" + sql = "SELECT gid, ST_AsBinary(geom) AS geom FROM simplified_water_polygons WHERE geom && !BBOX!" + + [[maps]] + name = "osm" + attribution = "Test Attribution" + bounds = [-180.0, -85.05112877980659, 180.0, 85.0511287798066] + center = ["${ENV_TEST_CENTER_X}", "${ENV_TEST_CENTER_Y}", "${ENV_TEST_CENTER_Z}"] + + [[maps.layers]] + name = "water" + provider_layer = "${ENV_TEST_PROVIDER_LAYER}" + + [[maps.layers]] + name = "water" + provider_layer = "provider1.water_6_10" + min_zoom = 6 + max_zoom = 10 + + [[maps]] + name = "osm_2" + attribution = "Test Attribution" + bounds = [-180.0, -85.05112877980659, 180.0, 85.0511287798066] + center = [-76.275329586789, 39.153492567373, 8.0] + + [[maps.layers]] + name = "water" + provider_layer = "provider1.water_0_5" + min_zoom = 0 + max_zoom = 5 + + [maps.layers.default_tags] + provider = "${ENV_TEST_MAP_LAYER_DEFAULT_TAG}" + + [[maps.layers]] + name = "water" + provider_layer = "provider1.water_6_10" + min_zoom = 6 + max_zoom = 10`, + expected: config.Config{ + LocationName: "", + Webserver: config.Webserver{ + HostName: ENV_TEST_HOST_CONCAT, + Port: ENV_TEST_WEBSERVER_PORT, + Headers: env.Dict{ + "Cache-Control": ENV_TEST_WEBSERVER_HEADER_STRING, + "Test": "Test", + "Impossible-Header": env.Dict{ + "test": ENV_TEST_WEBSERVER_HEADER_STRING, + }, + }, + }, + Providers: []env.Dict{ + { + "name": "provider1", + "type": "postgis", + "host": "localhost", + "port": int64(5432), + "database": "osm_water", + "user": "admin", + "password": "", + "layers": []map[string]interface{}{ + { + "name": "water_0_5", + "geometry_fieldname": "geom", + "id_fieldname": "gid", + "sql": "SELECT gid, ST_AsBinary(geom) AS geom FROM simplified_water_polygons WHERE geom && !BBOX!", + }, + { + "name": "water_6_10", + "geometry_fieldname": "geom", + "id_fieldname": "gid", + "sql": "SELECT gid, ST_AsBinary(geom) AS geom FROM simplified_water_polygons WHERE geom && !BBOX!", + }, + }, + }, + }, + Maps: []provider.Map{ + { + Name: "osm", + Attribution: "Test Attribution", + Bounds: []env.Float{-180, -85.05112877980659, 180, 85.0511287798066}, + Center: [3]env.Float{ENV_TEST_CENTER_X, ENV_TEST_CENTER_Y, ENV_TEST_CENTER_Z}, + TileBuffer: env.IntPtr(env.Int(64)), + Layers: []provider.MapLayer{ + { + Name: "water", + ProviderLayer: ENV_TEST_PROVIDER_LAYER, + MinZoom: nil, + MaxZoom: nil, + }, + { + Name: "water", + ProviderLayer: "provider1.water_6_10", + MinZoom: env.UintPtr(6), + MaxZoom: env.UintPtr(10), + }, + }, + }, + { + Name: "osm_2", + Attribution: "Test Attribution", + Bounds: []env.Float{-180, -85.05112877980659, 180, 85.0511287798066}, + Center: [3]env.Float{-76.275329586789, 39.153492567373, 8.0}, + TileBuffer: env.IntPtr(env.Int(64)), + Layers: []provider.MapLayer{ + { + Name: "water", + ProviderLayer: "provider1.water_0_5", + MinZoom: env.UintPtr(0), + MaxZoom: env.UintPtr(5), + DefaultTags: env.Dict{ + "provider": ENV_TEST_MAP_LAYER_DEFAULT_TAG, + }, + }, + { + Name: "water", + ProviderLayer: "provider1.water_6_10", + MinZoom: env.UintPtr(6), + MaxZoom: env.UintPtr(10), + }, + }, + }, + }, + }, + }, } for name, tc := range tests { @@ -533,7 +692,7 @@ func TestValidateMutateZoom(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -577,7 +736,7 @@ func TestValidateMutateZoom(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -646,7 +805,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -663,7 +822,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -710,7 +869,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water_0_5", "geometry_fieldname": "geom", @@ -727,7 +886,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water_5_10", "geometry_fieldname": "geom", @@ -780,7 +939,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -797,7 +956,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -863,7 +1022,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -880,7 +1039,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -932,7 +1091,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", @@ -949,7 +1108,7 @@ func TestValidate(t *testing.T) { "database": "osm_water", "user": "admin", "password": "", - "layers": []map[string]interface{}{ + "layers": []map[string]any{ { "name": "water", "geometry_fieldname": "geom", diff --git a/server/server.go b/server/server.go index 1f4e7461d..f39dc0e9b 100644 --- a/server/server.go +++ b/server/server.go @@ -50,6 +50,12 @@ var ( // when the server sits behind a reverse proxy with a prefix (i.e. /tegola) URIPrefix = "/" + // ProxyProtocol is a custom protocol that will be used to generate the URLs + // included in the capabilities endpoint responses. This is useful when he + // server sits behind a reverse proxy + // (See https://github.com/go-spatial/tegola/pull/967) + ProxyProtocol string + // DefaultCORSHeaders define the default CORS response headers added to all requests DefaultCORSHeaders = map[string]string{ "Access-Control-Allow-Origin": "*", @@ -143,6 +149,9 @@ func hostName(r *http.Request) string { // various checks to determine if the request is http or https. the scheme is needed for the TileURLs // r.URL.Scheme can be empty if a relative request is issued from the client. (i.e. GET /foo.html) func scheme(r *http.Request) string { + if ProxyProtocol != "" { + return ProxyProtocol + } if r.Header.Get("X-Forwarded-Proto") != "" { return r.Header.Get("X-Forwarded-Proto") } else if r.TLS != nil { diff --git a/server/server_internal_test.go b/server/server_internal_test.go index 0c198ae62..a4a38ad06 100644 --- a/server/server_internal_test.go +++ b/server/server_internal_test.go @@ -82,13 +82,14 @@ func TestHostName(t *testing.T) { func TestScheme(t *testing.T) { type tcase struct { - request http.Request - expected string + request http.Request + proxyProtocol string + expected string } fn := func(tc tcase) func(t *testing.T) { return func(t *testing.T) { - + ProxyProtocol = tc.proxyProtocol output := scheme(&tc.request) if output != tc.expected { t.Errorf("scheme, expected (%v) got (%v)", tc.expected, output) @@ -97,16 +98,40 @@ func TestScheme(t *testing.T) { } tests := map[string]tcase{ - "http": { + "http no proxyProtocol": { request: http.Request{}, expected: "http", }, + "http with http proxyProtocol": { + request: http.Request{}, + proxyProtocol: "http", + expected: "http", + }, + "http with https proxyProtocol": { + request: http.Request{}, + proxyProtocol: "https", + expected: "https", + }, "https": { request: http.Request{ TLS: &tls.ConnectionState{}, }, expected: "https", }, + "https with http proxyProtocol": { + request: http.Request{ + TLS: &tls.ConnectionState{}, + }, + proxyProtocol: "http", + expected: "http", + }, + "https with empty proxyProtocol": { + request: http.Request{ + TLS: &tls.ConnectionState{}, + }, + proxyProtocol: "", + expected: "https", + }, "x-forwarded-proto": { request: http.Request{ Header: map[string][]string{ @@ -118,20 +143,58 @@ func TestScheme(t *testing.T) { }, expected: "https", }, + "x-forwarded-proto with http proxyProtocol": { + request: http.Request{ + Header: map[string][]string{ + "X-Forwarded-Proto": { + "https", + "http", + }, + }, + }, + proxyProtocol: "http", + expected: "http", + }, + "http x-forwarded-proto with https proxyProtocol": { + request: http.Request{ + Header: map[string][]string{ + "X-Forwarded-Proto": { + "http", + }, + }, + }, + proxyProtocol: "https", + expected: "https", + }, + "https x-forwarded-proto with empty proxyProtocol": { + request: http.Request{ + Header: map[string][]string{ + "X-Forwarded-Proto": { + "https", + }, + }, + }, + proxyProtocol: "", + expected: "https", + }, } for name, tc := range tests { t.Run(name, fn(tc)) } + + ProxyProtocol = "" } func TestBuildCapabilitiesURL(t *testing.T) { + type tcase struct { - request http.Request - uriParts []string - uriPrefix string - query url.Values - expected string + request http.Request + uriParts []string + uriPrefix string + query url.Values + expected string + proxyProtocol string } fn := func(tc tcase) func(t *testing.T) { @@ -143,6 +206,12 @@ func TestBuildCapabilitiesURL(t *testing.T) { URIPrefix = "/" } + if tc.proxyProtocol != "" { + ProxyProtocol = tc.proxyProtocol + } else { + ProxyProtocol = "" + } + output := buildCapabilitiesURL(&tc.request, tc.uriParts, tc.query) if output != tc.expected { t.Errorf("expected (%v) got (%v)", tc.expected, output) @@ -179,6 +248,44 @@ func TestBuildCapabilitiesURL(t *testing.T) { }, expected: "http://cdn.tegola.io/tegola/foo/bar?debug=true", }, + "http proxy_protocol": { + request: http.Request{ + Host: "cdn.tegola.io", + }, + uriParts: []string{"foo", "bar"}, + query: url.Values{}, + proxyProtocol: "http", + expected: "http://cdn.tegola.io/foo/bar", + }, + "https proxy_protocol": { + request: http.Request{ + Host: "cdn.tegola.io", + }, + uriParts: []string{"foo", "bar"}, + query: url.Values{}, + proxyProtocol: "https", + expected: "https://cdn.tegola.io/foo/bar", + }, + "http proxy_protocol with https Request": { + request: http.Request{ + TLS: &tls.ConnectionState{}, + Host: "cdn.tegola.io", + }, + uriParts: []string{"foo", "bar"}, + query: url.Values{}, + proxyProtocol: "http", + expected: "http://cdn.tegola.io/foo/bar", + }, + "https proxy_protocol with uri_prefix": { + request: http.Request{ + Host: "cdn.tegola.io", + }, + uriParts: []string{"foo", "bar"}, + uriPrefix: "/tegola", + query: url.Values{}, + proxyProtocol: "https", + expected: "https://cdn.tegola.io/tegola/foo/bar", + }, } for name, tc := range tests { @@ -189,4 +296,5 @@ func TestBuildCapabilitiesURL(t *testing.T) { // designed as a singleton right now. Eventually this will change so the tests // don't need to consider each other URIPrefix = "/" + ProxyProtocol = "" } diff --git a/server/viewer_embed.go b/server/viewer_embed.go index c24e2f618..23737ee0a 100644 --- a/server/viewer_embed.go +++ b/server/viewer_embed.go @@ -1,3 +1,4 @@ +//go:build !noViewer && go1.16 // +build !noViewer,go1.16 package server @@ -13,7 +14,8 @@ import ( // setupViewer in this file is used for registering the viewer routes when the viewer // is included in the build (default) func setupViewer(o observability.Interface, group *httptreemux.Group) { - - group.UsingContext().Handler(observability.InstrumentViewerHandler(http.MethodGet, "/", o, http.FileServer(ui.GetDistFileSystem()))) - group.UsingContext().Handler(observability.InstrumentViewerHandler(http.MethodGet, "/*path", o, http.FileServer(ui.GetDistFileSystem()))) + // We need to Strip the URIPrefix from the request path before serving the file + // This is used when the server sits behind a reverse proxy with a prefix (i.e. /tegola) + group.UsingContext().Handler(observability.InstrumentViewerHandler(http.MethodGet, "/", o, http.StripPrefix(URIPrefix, http.FileServer(ui.GetDistFileSystem())))) + group.UsingContext().Handler(observability.InstrumentViewerHandler(http.MethodGet, "/*path", o, http.StripPrefix(URIPrefix, http.FileServer(ui.GetDistFileSystem())))) } diff --git a/ui/package-lock.json b/ui/package-lock.json index 6bc0653bb..39e50d58d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2611,9 +2611,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001487", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", - "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", + "version": "1.0.30001584", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz", + "integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==", "dev": true, "funding": [ { diff --git a/ui/vite.config.js b/ui/vite.config.js index 4f0e45939..24a98d8fd 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -4,6 +4,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [vue()], + base: "", resolve: { alias: {