diff --git a/README.md b/README.md index d47e16b..a778ac4 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ curl -H "Content-Type: application/json" \ http://ceryx-api-host/api/routes/publicly.accessible.domain ``` -### HTTPS redirects +### Enforce HTTPS You can enforce redirection from HTTP to HTTPS for any host you would like. @@ -117,6 +117,17 @@ curl -H "Content-Type: application/json" \ The above functionality works in `PUT` update requests as well. +### Redirect to target, instead of proxying + +Instead of proxying the request to the targetm you can prompt the client to redirect the request there itself. + +``` +curl -H "Content-Type: application/json" \ + -X POST \ + -d '{"source":"sourcelair.com","target":"https://www.sourcelair.com", "settings": {"mode": "redirect"}}' \ + http://ceryx-api-host/api/routes +``` + ## Ceryx web UI The [Ceryx Web community project](https://github.com/parisk/ceryx-web) provides a sweet web UI diff --git a/api/ceryx/db.py b/api/ceryx/db.py index d649827..21d390a 100644 --- a/api/ceryx/db.py +++ b/api/ceryx/db.py @@ -15,7 +15,8 @@ def encode_settings(settings): Encode and sanitize settings in order to be written to Redis. """ encoded_settings = { - 'enforce_https': str(int(settings.get('enforce_https', False))) + 'enforce_https': str(int(settings.get('enforce_https', False))), + 'mode': settings.get('mode', 'proxy'), } return encoded_settings @@ -32,7 +33,8 @@ def decode_settings(settings): _str(k): _str(v) for k, v in settings.items() } decoded = { - 'enforce_https': bool(int(_settings.get('enforce_https', '0'))) + 'enforce_https': bool(int(_settings.get('enforce_https', '0'))), + 'mode': _settings.get('mode', 'proxy'), } return decoded diff --git a/api/ceryx/types.py b/api/ceryx/types.py index a57ef63..1f26733 100644 --- a/api/ceryx/types.py +++ b/api/ceryx/types.py @@ -1,26 +1,27 @@ from apistar import types, validators +SETTINGS_VALIDATOR = validators.Object( + properties={ + 'enforce_https': validators.Boolean(default=False), + 'mode': validators.String( + default='proxy', + enum=['proxy', 'redirect'], + ), + }, + default={ + 'enforce_https': False, + 'mode': 'proxy', + }, +) + + class RouteWithoutSource(types.Type): target = validators.String() - settings = validators.Object( - properties={ - 'enforce_https': validators.Boolean(default=False), - }, - default={ - 'enforce_https': False, - }, - ) + settings = SETTINGS_VALIDATOR class Route(types.Type): source = validators.String() target = validators.String() - settings = validators.Object( - properties={ - 'enforce_https': validators.Boolean(default=False), - }, - default={ - 'enforce_https': False, - }, - ) + settings = SETTINGS_VALIDATOR diff --git a/api/tests.py b/api/tests.py index b37acd2..97abfd0 100644 --- a/api/tests.py +++ b/api/tests.py @@ -33,6 +33,7 @@ def test_create_route(self): 'target': 'localhost:11235', 'settings': { 'enforce_https': False, + 'mode': 'proxy', } } @@ -59,6 +60,7 @@ def test_enforce_https(self): 'target': 'localhost:11235', 'settings': { 'enforce_https': True, + 'mode': 'proxy', }, } route_enforce_https_false = { @@ -66,6 +68,7 @@ def test_enforce_https(self): 'target': 'localhost:11235', 'settings': { 'enforce_https': False, + 'mode': 'proxy', }, } expected_response_without_enforce_https = { @@ -73,6 +76,7 @@ def test_enforce_https(self): 'target': 'localhost:11235', 'settings': { 'enforce_https': False, + 'mode': 'proxy', }, } @@ -109,6 +113,65 @@ def test_enforce_https(self): response.json(), route_enforce_https_false, ) + def test_mode(self): + """ + Assert that creating a route with or without the `mode` setting returns + the expected results. + """ + route_without_mode = { + 'source': 'www.my-website.dev', + 'target': 'localhost:11235', + } + route_mode_proxy = { + 'source': 'www.my-website.dev', + 'target': 'localhost:11235', + 'settings': { + 'enforce_https': False, + 'mode': 'proxy', + }, + } + route_mode_redirect = { + 'source': 'my-website.dev', + 'target': 'www.my-website.dev', + 'settings': { + 'enforce_https': False, + 'mode': 'redirect', + }, + } + + response = self.client.post('/api/routes', json=route_without_mode) + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + response.json(), route_mode_proxy, + ) + + response = self.client.get('/api/routes/www.my-website.dev') + self.assertDictEqual( + response.json(), route_mode_proxy, + ) + + response = self.client.post('/api/routes', json=route_mode_proxy) + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + response.json(), route_mode_proxy, + ) + + response = self.client.get('/api/routes/www.my-website.dev') + self.assertDictEqual( + response.json(), route_mode_proxy, + ) + + response = self.client.post('/api/routes', json=route_mode_redirect) + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + response.json(), route_mode_redirect, + ) + + response = self.client.get('/api/routes/my-website.dev') + self.assertDictEqual( + response.json(), route_mode_redirect, + ) + def test_delete_route(self): """ Assert that deleting a route, will actually delete it. diff --git a/ceryx/nginx/lualib/router.lua b/ceryx/nginx/lualib/router.lua index 8a18634..7cb3f31 100644 --- a/ceryx/nginx/lualib/router.lua +++ b/ceryx/nginx/lualib/router.lua @@ -2,9 +2,14 @@ local host = ngx.var.host local is_not_https = (ngx.var.scheme ~= "https") local cache = ngx.shared.ceryx -function route(source, target) +function route(source, target, mode) + if mode == "redirect" then + ngx.log(ngx.INFO, "Redirecting request for " .. source .. " to " .. target .. ".") + return ngx.redirect(target, ngx.HTTP_MOVED_PERMANENTLY) + end + ngx.var.container_url = target - ngx.log(ngx.INFO, "Routing request for " .. source .. " to " .. target .. ".") + ngx.log(ngx.INFO, "Proxying request for " .. source .. " to " .. target .. ".") end local prefix = os.getenv("CERYX_REDIS_PREFIX") @@ -41,8 +46,9 @@ if redis_password then end ngx.log(ngx.DEBUG, "Authenticated with Redis.") +local settings_key = prefix .. ":settings:" .. host + if is_not_https then - local settings_key = prefix .. ":settings:" .. host local enforce_https, flags = cache:get(host .. ":enforce_https") if enforce_https == nil then @@ -56,11 +62,14 @@ if is_not_https then end end +-- Get routing mode +local mode, mode_flags = red:hget(settings_key, "mode") + -- Check if key exists in local cache res, flags = cache:get(host) if res then ngx.log(ngx.DEBUG, "Cache hit for " .. host .. ".") - route(host, res) + route(host, res, mode) else ngx.log(ngx.DEBUG, "Cache miss for " .. host .. ".") @@ -85,6 +94,6 @@ else end -- Save found key to local cache for 5 seconds -route(host, res) +route(host, res, mode) cache:set(host, res, 5) ngx.log(ngx.DEBUG, "Saving route from " .. host .. " to " .. res .. " in local cache for 5 seconds.") diff --git a/ceryx/tests/routes.bats b/ceryx/tests/routes.bats index ea3a4ce..fd85a50 100644 --- a/ceryx/tests/routes.bats +++ b/ceryx/tests/routes.bats @@ -18,14 +18,26 @@ [ $ceryx_status_code -eq $upstream_status_code ] } -@test "301 response and appropriate 'Location' header when enforce_https=true" { +@test "301 response when enforce_https=true" { curl -s -o /dev/null \ -X POST \ -H 'Content-Type: application/json' \ - -d '{"source": "enforced-https-route", "target": "somewhere", "settings":{"enforce_https":true}}' \ + -d '{"source": "enforced-https-route", "target": "somewhere", "settings":{"enforce_https": true}}' \ http://api:5555/api/routes/ status_code=$(curl -s -o /dev/null -I -w "%{http_code}" -H "Host: enforced-https-route" http://ceryx/) [ $status_code -eq 301 ] } + +@test "301 response when mode=redirect" { + curl -s -o /dev/null \ + -X POST \ + -H 'Content-Type: application/json' \ + -d '{"source": "redirected-route", "target": "redirection-target", "settings":{"mode": "redirect"}}' \ + http://api:5555/api/routes/ + + status_code=$(curl -s -o /dev/null -I -w "%{http_code}" -H "Host: redirected-route" http://ceryx/) + + [ $status_code -eq 301 ] +}