├── api
├── ceryx
│ ├── __init__.py
│ ├── exceptions.py
│ ├── settings.py
│ ├── schemas.py
│ └── db.py
├── README.md
├── MANIFEST.in
├── bin
│ ├── test
│ └── populate-api
├── Dockerfile
├── Pipfile
├── LICENSE.txt
├── api.py
├── tests.py
└── Pipfile.lock
├── ceryx
├── tests
│ ├── client
│ │ ├── __init__.py
│ │ ├── poolmanager.py
│ │ ├── adapters.py
│ │ ├── client.py
│ │ ├── connectionpool.py
│ │ └── connection.py
│ ├── base.py
│ ├── utils.py
│ ├── test_certificates.py
│ └── test_routes.py
├── Pipfile
├── Dockerfile.test
├── bin
│ └── entrypoint.sh
├── README.md
├── nginx
│ ├── lualib
│ │ ├── letsencrypt.lua
│ │ ├── ceryx
│ │ │ ├── utils.lua
│ │ │ ├── redis.lua
│ │ │ ├── certificates.lua
│ │ │ └── routes.lua
│ │ ├── router.lua
│ │ └── certificate.lua
│ └── conf
│ │ ├── nginx.conf.tmpl
│ │ ├── ceryx.conf.tmpl
│ │ └── mime.types
├── static
│ ├── 500.html
│ └── 503.html
├── Dockerfile
└── Pipfile.lock
├── bin
└── test
├── k8s
└── ceryx
│ ├── Chart.yaml
│ ├── .helmignore
│ ├── templates
│ ├── _helpers.tpl
│ ├── ceryx-redis.yaml
│ ├── ceryx-api.yaml
│ └── ceryx-nginx.yaml
│ └── values.yaml
├── .gitignore
├── docker-compose.test.yml
├── .travis.yml
├── docker-compose.override.yml
├── LICENSE
├── docker-compose.yml
└── README.md
/api/ceryx/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/api/README.md:
--------------------------------------------------------------------------------
1 | ## API definitions to be added.
2 |
--------------------------------------------------------------------------------
/ceryx/tests/client/__init__.py:
--------------------------------------------------------------------------------
1 | from .client import CeryxTestClient
--------------------------------------------------------------------------------
/api/ceryx/exceptions.py:
--------------------------------------------------------------------------------
1 | class NotFound(Exception):
2 | status_code = 404
3 | pass
--------------------------------------------------------------------------------
/api/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.txt
2 | include README.md
3 | recursive-include docs *.rst
4 | recursive-include ceryx/api/templates *
5 | recursive-include ceryx/api/static *
--------------------------------------------------------------------------------
/api/bin/test:
--------------------------------------------------------------------------------
1 | #! /bin/sh
2 | #
3 | # Run the Ceryx API test suite, using Nose.
4 |
5 | set -ex
6 |
7 | mypy --ignore-missing-imports api.py
8 | pytest tests.py
9 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 |
3 | set -ex
4 |
5 | export DOCKER_COMPOSE_VERSION=1.23.2
6 | export COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.test.yml
7 |
8 | docker-compose run test
--------------------------------------------------------------------------------
/k8s/ceryx/Chart.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v2
2 | name: ceryx
3 | description: A Helm chart for ceryx
4 | type: application
5 | version: 0.1.0
6 | appVersion: 1.16.0
7 | keywords:
8 | - ceryx
9 | sources:
10 | - https://github.com/sourcelair/ceryx
11 |
--------------------------------------------------------------------------------
/api/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.6
2 |
3 | RUN pip install pipenv==11.9.0
4 |
5 | RUN mkdir /etc/ceryx
6 | COPY Pipfile Pipfile.lock /etc/ceryx/
7 |
8 | WORKDIR /etc/ceryx
9 | RUN pipenv install --system --dev --deploy
10 |
11 | COPY . /opt/ceryx
12 | WORKDIR /opt/ceryx
13 |
14 | ENV PORT 5555
15 | CMD python api.py
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | .venv
3 | *.pyc
4 | production.yml
5 | dev.yml
6 | data
7 | docker-compose.yaml
8 | .stolos
9 | .vscode
10 |
11 | # Ignore Vim files
12 | *.swp
13 | *.swo
14 |
15 | # Ignore generated files from template
16 | ceryx/nginx/conf/ceryx.conf
17 | ceryx/nginx/conf/nginx.conf
18 |
19 | .mypy_cache
20 | .pytest_cache
--------------------------------------------------------------------------------
/ceryx/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | url = "https://pypi.python.org/simple"
3 | verify_ssl = true
4 | name = "pypi"
5 |
6 | [packages]
7 |
8 | [dev-packages]
9 | pytest = "*"
10 | requests = "*"
11 | black = "==18.9b0"
12 | redis = "*"
13 | urllib3 = "*"
14 |
15 | [requires]
16 | python_version = "3.6"
17 |
18 | [pipenv]
19 | allow_prereleases = true
20 |
--------------------------------------------------------------------------------
/api/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | url = "https://pypi.python.org/simple"
3 | verify_ssl = true
4 | name = "pypi"
5 |
6 | [packages]
7 | redis = "*"
8 | requests = ">=2.21.0"
9 | typesystem = "*"
10 | responder = "*"
11 |
12 | [dev-packages]
13 | nose = "*"
14 | black = "==18.9b0"
15 | pytest = "*"
16 | mypy = "*"
17 |
18 | [requires]
19 | python_version = "3.6"
20 |
--------------------------------------------------------------------------------
/api/bin/populate-api:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 | #
3 | # This file should populate the API to the NGINX Proxy (we are dogfooding Ceryx
4 | # access our own API 💪).
5 |
6 | set -e
7 |
8 | echo "Register the API to Ceryx"
9 | curl -H "Content-Type: application/json" \
10 | -X POST \
11 | -d '{"source":"'$CERYX_API_HOSTNAME'","target":"api:'$CERYX_API_PORT'"}' \
12 | http://localhost:$CERYX_API_PORT/api/routes/
13 |
--------------------------------------------------------------------------------
/k8s/ceryx/.helmignore:
--------------------------------------------------------------------------------
1 | # Patterns to ignore when building packages.
2 | # This supports shell glob matching, relative path matching, and
3 | # negation (prefixed with !). Only one pattern per line.
4 | .DS_Store
5 | # Common VCS dirs
6 | .git/
7 | .gitignore
8 | .bzr/
9 | .bzrignore
10 | .hg/
11 | .hgignore
12 | .svn/
13 | # Common backup files
14 | *.swp
15 | *.bak
16 | *.tmp
17 | *.orig
18 | *~
19 | # Various IDEs
20 | .project
21 | .idea/
22 | *.tmproj
23 | .vscode/
24 |
--------------------------------------------------------------------------------
/ceryx/tests/base.py:
--------------------------------------------------------------------------------
1 | import os
2 | import uuid
3 |
4 | import redis
5 |
6 | from client import CeryxTestClient
7 |
8 |
9 | class BaseTest:
10 | def setup_method(self):
11 | self.uuid = uuid.uuid4()
12 | self.host = f"{self.uuid}.ceryx.test"
13 | self.redis_target_key = f"ceryx:routes:{self.host}"
14 | self.redis_settings_key = f"ceryx:settings:{self.host}"
15 | self.client = CeryxTestClient()
16 | self.redis = redis.Redis(host='redis')
--------------------------------------------------------------------------------
/ceryx/tests/client/poolmanager.py:
--------------------------------------------------------------------------------
1 | from urllib3.poolmanager import PoolManager
2 |
3 | from .connectionpool import (
4 | CeryxTestsHTTPConnectionPool,
5 | CeryxTestsHTTPSConnectionPool,
6 | )
7 |
8 |
9 | class CeryxTestsPoolManager(PoolManager):
10 | def __init__(self, *args, **kwargs):
11 | super().__init__(*args, **kwargs)
12 | self.pool_classes_by_scheme = {
13 | "http": CeryxTestsHTTPConnectionPool,
14 | "https": CeryxTestsHTTPSConnectionPool,
15 | }
--------------------------------------------------------------------------------
/docker-compose.test.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | ceryx:
5 | volumes:
6 | - test_certificates:/usr/local/share/certificates
7 |
8 | test:
9 | build:
10 | context: ./ceryx
11 | dockerfile: Dockerfile.test
12 | environment:
13 | CERYX_API_URL: "http://api:${CERYX_API_PORT:-5555}"
14 | volumes:
15 | - ./ceryx:/usr/src/app
16 | - test_certificates:/usr/local/share/certificates
17 | depends_on:
18 | - ceryx
19 | - api
20 |
21 | volumes:
22 | test_certificates:
23 |
--------------------------------------------------------------------------------
/ceryx/Dockerfile.test:
--------------------------------------------------------------------------------
1 | FROM python:3.6
2 |
3 | ENV PYTHONDONTWRITEBYTECODE 1
4 | ENV PYTHONUNBUFFERED 1
5 |
6 | RUN pip install pipenv==2018.11.26
7 |
8 | WORKDIR /usr/src/app
9 |
10 | COPY Pipfile Pipfile.lock ./
11 | RUN pipenv install --system --dev --deploy
12 |
13 | COPY . ./
14 |
15 | ENV CERYX_DEBUG true
16 | ENV CERYX_DISABLE_LETS_ENCRYPT true
17 |
18 | COPY --from=sourcelair/ceryx:latest /etc/ceryx/ssl/default.key /etc/ceryx/ssl/default.key
19 | COPY --from=sourcelair/ceryx:latest /etc/ceryx/ssl/default.crt /etc/ceryx/ssl/default.crt
20 |
21 | CMD ["pytest", "tests/"]
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 |
3 | python:
4 | - 3.6
5 |
6 | env:
7 | - DOCKER_COMPOSE_VERSION=1.23.2 COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.test.yml CERYX_DISABLE_LETS_ENCRYPT=true
8 |
9 | install:
10 | - pip install --upgrade --ignore-installed docker-compose==${DOCKER_COMPOSE_VERSION}
11 | - docker-compose build
12 |
13 | services:
14 | - redis-server
15 | - docker
16 |
17 | script:
18 | - docker-compose up -d
19 | - docker-compose run api ./bin/test
20 | - docker-compose run test
21 |
22 | addons:
23 | apt:
24 | packages:
25 | - docker-ce
26 |
27 | notifications:
28 | email: false
29 |
--------------------------------------------------------------------------------
/ceryx/bin/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 |
3 | set -ex
4 |
5 | if [ $CERYX_DEBUG == "true" ]
6 | then
7 | export CERYX_LOG_LEVEL=debug
8 | else
9 | export CERYX_LOG_LEVEL=info
10 | fi
11 |
12 | # Use Dockerize for templates and to wait for Redis
13 | /usr/local/bin/dockerize \
14 | ${CERYX_DOCKERIZE_EXTRA_ARGS} \
15 | -template /usr/local/openresty/nginx/conf/nginx.conf.tmpl:/usr/local/openresty/nginx/conf/nginx.conf \
16 | -template /usr/local/openresty/nginx/conf/ceryx.conf.tmpl:/usr/local/openresty/nginx/conf/ceryx.conf \
17 | -wait tcp://${CERYX_REDIS_HOST:-redis}:${CERYX_REDIS_PORT:-6379}
18 |
19 | # Execute subcommand
20 | exec "$@"
21 |
--------------------------------------------------------------------------------
/ceryx/tests/client/adapters.py:
--------------------------------------------------------------------------------
1 | from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
2 |
3 | from .poolmanager import CeryxTestsPoolManager
4 |
5 |
6 | class CeryxTestsHTTPAdapter(HTTPAdapter):
7 | def init_poolmanager(
8 | self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs,
9 | ):
10 | # Comment from original Requests HTTPAdapter: Save these values for pickling
11 | self._pool_connections = connections
12 | self._pool_maxsize = maxsize
13 | self._pool_block = block
14 |
15 | self.poolmanager = CeryxTestsPoolManager(
16 | num_pools=connections, maxsize=maxsize, block=block, strict=True,
17 | **pool_kwargs,
18 | )
19 |
--------------------------------------------------------------------------------
/ceryx/README.md:
--------------------------------------------------------------------------------
1 | # Setting up dynamic SSL certificates with Let's encrypt
2 |
3 | [Let's encrypt](https://letsencrypt.org/) is a free, automated, and open Certificate Authority.
4 |
5 | This means that we can generate such certificates dynamically, each time a new HTTPS domain hits Ceryx. To do so, we'll use the great `[lua-resty-auto-ssl](https://github.com/GUI/lua-resty-auto-ssl)` library.
6 |
7 | An example `docker-compose.yaml` file for this would be:
8 |
9 | ```yaml
10 | version: '2'
11 |
12 | services:
13 | proxy:
14 | image: sourcelair/ceryx-proxy:dynamic-ssl
15 | ports:
16 | - 80:80
17 | - 443:443
18 | environment:
19 | - CERYX_REDIS_HOST=redis
20 | - CERYX_REDIS_PORT=6379
21 | restart: always
22 |
23 | ...
24 | ```
25 |
--------------------------------------------------------------------------------
/ceryx/tests/client/client.py:
--------------------------------------------------------------------------------
1 | from requests import Session
2 |
3 | from .adapters import CeryxTestsHTTPAdapter
4 |
5 |
6 | class CeryxTestClient(Session):
7 | """
8 | The Ceryx testing client lets us test Ceryx hosts without any
9 | configuration. Essentially lets us make requests to
10 | hostnames ending in `.ceryx.test`, without any name resolution
11 | needed. The testing client will make these requests to the configured
12 | Ceryx host automatically, but will set both the `Host` HTTP header
13 | and `SNI` SSL attribute to the initial host.
14 | """
15 |
16 | def __init__(self):
17 | super().__init__()
18 | self.mount("http://", CeryxTestsHTTPAdapter())
19 | self.mount("https://", CeryxTestsHTTPAdapter())
20 |
--------------------------------------------------------------------------------
/api/ceryx/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Settings file, which is populated from the environment while enforcing common
3 | use-case defaults.
4 | """
5 | import os
6 |
7 |
8 | DEBUG = True
9 | if os.getenv("CERYX_DEBUG", "").lower() in ["0", "no", "false"]:
10 | DEBUG = False
11 |
12 | API_BIND_HOST = os.getenv("CERYX_API_HOST", "127.0.0.1")
13 | API_BIND_PORT = int(os.getenv("CERYX_API_PORT", 5555))
14 | SECRET_KEY = os.getenv("CERYX_SECRET_KEY")
15 | if SECRET_KEY:
16 | with open(SECRET_KEY, "r") as f:
17 | SECRET_KEY = f.read()
18 |
19 | REDIS_HOST = os.getenv("CERYX_REDIS_HOST", "127.0.0.1")
20 | REDIS_PORT = int(os.getenv("CERYX_REDIS_PORT", 6379))
21 | REDIS_PASSWORD = os.getenv("CERYX_REDIS_PASSWORD", None)
22 | REDIS_PREFIX = os.getenv("CERYX_REDIS_PREFIX", "ceryx")
23 | REDIS_TIMEOUT = int(os.getenv("CERYX_REDIS_TIMEOUT", 100)) * 0.001 # Python Redis client requires units of seconds
24 |
--------------------------------------------------------------------------------
/docker-compose.override.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | x-common-dev-settings:
4 | &common-dev-settings
5 | CERYX_DEBUG: ${CERYX_DEBUG:-true}
6 |
7 | services:
8 | ceryx:
9 | build: ./ceryx
10 | volumes:
11 | - ./ceryx/nginx/conf:/usr/local/openresty/nginx/conf
12 | - ./ceryx/nginx/lualib:/usr/local/openresty/nginx/lualib
13 | - ./ceryx/static:/etc/ceryx/static
14 | environment:
15 | <<: *common-dev-settings
16 | CERYX_DOCKERIZE_EXTRA_ARGS: ""
17 |
18 | api:
19 | build: ./api
20 | volumes:
21 | - ./api:/opt/ceryx
22 | environment:
23 | <<: *common-dev-settings
24 | CERYX_API_HOSTNAME: ${CERYX_API_HOSTNAME:-api.ceryx.dev}
25 | command: uvicorn --reload --host 0.0.0.0 --port 5555 api:api
26 |
27 | networks:
28 | default:
29 | driver: bridge # Use a Bridge network for local development
30 | name: ceryx
31 |
--------------------------------------------------------------------------------
/ceryx/tests/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import stat
3 | import subprocess
4 |
5 |
6 | CERTIFICATE_ROOT = "/usr/local/share/certificates"
7 | EVERYBODY_CAN_READ = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
8 |
9 |
10 | def create_certificates_for_host(host):
11 | base_path = f"{CERTIFICATE_ROOT}/{host}"
12 | certificate_path = f"{base_path}.crt"
13 | key_path = f"{base_path}.key"
14 |
15 | command = [
16 | "openssl",
17 | "req", "-x509",
18 | "-newkey", "rsa:4096",
19 | "-keyout", key_path,
20 | "-out", certificate_path,
21 | "-days", "1",
22 | "-subj", f"/C=GR/ST=Attica/L=Athens/O=SourceLair/OU=Org/CN={host}",
23 | "-nodes",
24 | ]
25 | subprocess.run(
26 | command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True,
27 | )
28 | os.chmod(certificate_path, EVERYBODY_CAN_READ)
29 | os.chmod(key_path, EVERYBODY_CAN_READ)
30 |
31 | return certificate_path, key_path
32 |
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/letsencrypt.lua:
--------------------------------------------------------------------------------
1 | auto_ssl = (require "resty.auto-ssl").new()
2 |
3 | local redis = require "ceryx.redis"
4 | local routes = require "ceryx.routes"
5 |
6 | -- Define a function to determine which SNI domains to automatically handle
7 | -- and register new certificates for. Defaults to not allowing any domains,
8 | -- so this must be configured.
9 | auto_ssl:set(
10 | "allow_domain",
11 | function(domain)
12 | local redisClient = redis:client()
13 | local host = domain
14 | local target = routes.getTargetForSource(host, redisClient)
15 |
16 | if target == nil then
17 | return target
18 | end
19 |
20 | return true
21 | end
22 | )
23 |
24 | -- Set the resty-auto-ssl storage to Redis, using the CERYX_* env variables
25 | auto_ssl:set("storage_adapter", "resty.auto-ssl.storage_adapters.redis")
26 | auto_ssl:set(
27 | "redis",
28 | {
29 | host = redis.host,
30 | port = redis.port
31 | }
32 | )
33 |
34 | auto_ssl:init()
35 | require "resty.core"
36 |
--------------------------------------------------------------------------------
/api/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 SourceLair PC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 - 2018 SourceLair Private Company
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/ceryx/static/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Ceryx - Server Error
6 |
7 |
8 |
33 |
34 |
35 |
36 | Ceryx
37 | Server Error
38 | We are sorry, an unexpected error occured while servicing your request.
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/ceryx/static/503.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Ceryx - Service Not Available
6 |
7 |
8 |
33 |
34 |
35 |
36 | Ceryx
37 | Service Not Available
38 | The website you are trying to access, does not exist.
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/ceryx/tests/client/connectionpool.py:
--------------------------------------------------------------------------------
1 | from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
2 |
3 | from .connection import CeryxTestsHTTPConnection, CeryxTestsHTTPSConnection
4 |
5 |
6 | class CeryxTestsHTTPConnectionPool(HTTPConnectionPool):
7 | ConnectionCls = CeryxTestsHTTPConnection
8 |
9 | def __init__(self, host, *args, **kwargs):
10 | """
11 | Store the original HTTP request host, so we can pass it over via the
12 | `Host` header.
13 | """
14 | self._impostor_host = host
15 | super().__init__(host, *args, **kwargs)
16 |
17 | def urlopen(self, *args, **kwargs):
18 | """
19 | This custom `urlopen` implementation enforces setting the `Host` header
20 | of the request to `self._impostor_host`.
21 | """
22 | kwargs["headers"]["Host"] = self._impostor_host
23 | return super().urlopen(*args, **kwargs)
24 |
25 |
26 | class CeryxTestsHTTPSConnectionPool(HTTPSConnectionPool):
27 | ConnectionCls = CeryxTestsHTTPSConnection
28 |
29 | def __init__(self, host, *args, **kwargs):
30 | """
31 | Force set SNI to the requested Host.
32 | """
33 | super().__init__(host, *args, **kwargs)
34 | self.conn_kw["server_hostname"] = host
35 |
--------------------------------------------------------------------------------
/ceryx/tests/test_certificates.py:
--------------------------------------------------------------------------------
1 | from base import BaseTest
2 | from utils import create_certificates_for_host
3 |
4 |
5 | class TestCertificates(BaseTest):
6 | def test_custom_certificate(self):
7 | """
8 | Ensure that Ceryx uses the given certificate for each route, if configured
9 | so.
10 | """
11 | certificate_path , key_path = create_certificates_for_host(self.host)
12 |
13 | api_base_url = "http://api:5555/"
14 | self.redis.set(self.redis_target_key, api_base_url)
15 |
16 | self.redis.hset(self.redis_settings_key, "certificate_path", certificate_path)
17 | self.redis.hset(self.redis_settings_key, "key_path", key_path)
18 |
19 | self.client.get(f"https://{self.host}/", verify=certificate_path)
20 |
21 | def test_fallback_certificate(self):
22 | """
23 | Ensure that Ceryx uses the fallback certificate if a route gets accessed
24 | via HTTPS with no configured certificate or automatic Let's Encrypt
25 | certificates enabled.
26 | """
27 | try:
28 | response = self.client.get(f"https://ghost.ceryx.test/", verify="/etc/ceryx/ssl/default.crt")
29 | except Exception as e:
30 | assert "sni-support-required-for-valid-ssl" in str(e)
31 |
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/ceryx/utils.lua:
--------------------------------------------------------------------------------
1 | local exports = {}
2 |
3 | function starts_with(subject, substring)
4 | return subject:sub(1, #substring) == substring
5 | end
6 |
7 | function ends_with(subject, substring)
8 | return subject:sub(-(#substring)) == substring
9 | end
10 |
11 | function starts_with_protocol(target)
12 | return starts_with(target, "http://") or starts_with(target, "https://")
13 | end
14 |
15 | function has_trailing_slash(target)
16 | return ends_with(target, "/")
17 | end
18 |
19 | function exports.ensure_protocol(target)
20 | if not starts_with_protocol(target) then
21 | return "http://" .. target
22 | end
23 |
24 | return target
25 | end
26 |
27 | function exports.ensure_no_trailing_slash(target)
28 | if has_trailing_slash(target) then
29 | return target:sub(1, -2)
30 | end
31 |
32 | return target
33 | end
34 |
35 | function exports.getenv(variable, default)
36 | local value = os.getenv(variable)
37 |
38 | if value then
39 | return value
40 | end
41 |
42 | return default
43 | end
44 |
45 | function exports.read_file(path)
46 | local file = assert(io.open(path, "r"))
47 | local file_data = file:read("*all")
48 | file:close()
49 |
50 | return file_data
51 | end
52 |
53 | return exports
54 |
--------------------------------------------------------------------------------
/ceryx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM openresty/openresty:1.13.6.1-xenial
2 |
3 | ARG user=www-data
4 | ARG group=www-data
5 |
6 | RUN mkdir -p /etc/letsencrypt &&\
7 | mkdir -p /etc/ceryx/ssl &&\
8 | openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
9 | -subj '/CN=sni-support-required-for-valid-ssl' \
10 | -keyout /etc/ceryx/ssl/default.key \
11 | -out /etc/ceryx/ssl/default.crt
12 |
13 | # Install dockerize binary, for templated configs
14 | # https://github.com/jwilder/dockerize
15 | ENV DOCKERIZE_VERSION=v0.6.1
16 | RUN curl -fSslL https://github.com/jwilder/dockerize/releases/download/${DOCKERIZE_VERSION}/dockerize-linux-amd64-${DOCKERIZE_VERSION}.tar.gz | \
17 | tar xzv -C /usr/local/bin/
18 |
19 | # Install lua-resty-auto-ssl for dynamically generating certificates from LE
20 | # https://github.com/GUI/lua-resty-auto-ssl
21 | RUN /usr/local/openresty/luajit/bin/luarocks install lua-resty-auto-ssl 0.13.1 &&\
22 | mkdir /etc/resty-auto-ssl/ &&\
23 | chown -R $user:$group /etc/resty-auto-ssl/
24 |
25 | # Clean up all existing NGINX configuration
26 | RUN rm -f /usr/local/openresty/nginx/conf/*
27 |
28 | COPY ./nginx/conf /usr/local/openresty/nginx/conf
29 | COPY ./nginx/lualib /usr/local/openresty/nginx/lualib
30 | COPY ./static /etc/ceryx/static
31 |
32 | # Add the entrypoint script
33 | COPY ./bin/entrypoint.sh /entrypoint.sh
34 | ENTRYPOINT [ "/entrypoint.sh" ]
35 |
36 | CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]
37 |
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/ceryx/redis.lua:
--------------------------------------------------------------------------------
1 | local redis = require "resty.redis"
2 | local utils = require "ceryx.utils"
3 |
4 | local prefix = utils.getenv("CERYX_REDIS_PREFIX", "ceryx")
5 | local host = utils.getenv("CERYX_REDIS_HOST", "127.0.0.1")
6 | local port = utils.getenv("CERYX_REDIS_PORT", 6379)
7 | local password = utils.getenv("CERYX_REDIS_PASSWORD", nil)
8 | local timeout = utils.getenv("CERYX_REDIS_TIMEOUT", 100) -- 100 ms
9 |
10 | local exports = {}
11 |
12 | function exports.client()
13 | -- Prepare the Redis client
14 | ngx.log(ngx.DEBUG, "Preparing Redis client.")
15 |
16 | local red = redis:new()
17 | red:set_timeout(timeout)
18 |
19 | local res, err = red:connect(host, port)
20 |
21 | -- Return if could not connect to Redis
22 | if not res then
23 | ngx.log(ngx.DEBUG, "Could not prepare Redis client: " .. err)
24 | return ngx.exit(ngx.HTTP_SERVER_ERROR)
25 | end
26 |
27 | ngx.log(ngx.DEBUG, "Redis client prepared.")
28 |
29 | if password then
30 | ngx.log(ngx.DEBUG, "Authenticating with Redis.")
31 | local res, err = red:auth(password)
32 | if not res then
33 | ngx.ERR("Could not authenticate with Redis: ", err)
34 | return ngx.exit(ngx.HTTP_SERVER_ERROR)
35 | end
36 | end
37 | ngx.log(ngx.DEBUG, "Authenticated with Redis.")
38 |
39 | return red
40 | end
41 |
42 | exports.prefix = prefix
43 | exports.host = host
44 | exports.port = port
45 | exports.password = password
46 |
47 | return exports
48 |
--------------------------------------------------------------------------------
/api/api.py:
--------------------------------------------------------------------------------
1 | import responder
2 |
3 | from ceryx.db import RedisClient
4 | from ceryx.exceptions import NotFound
5 |
6 |
7 | api = responder.API()
8 | client = RedisClient.from_config()
9 |
10 |
11 | @api.route(default=True)
12 | def default(req, resp):
13 | if not req.url.path.endswith("/"):
14 | api.redirect(resp, f"{req.url.path}/")
15 |
16 |
17 | @api.route("/api/routes/")
18 | class RouteListView:
19 | async def on_get(self, req, resp):
20 | resp.media = [dict(route) for route in client.list_routes()]
21 |
22 | async def on_post(self, req, resp):
23 | data = await req.media()
24 | route = client.create_route(data)
25 | resp.status_code = api.status_codes.HTTP_201
26 | resp.media = dict(route)
27 |
28 |
29 | @api.route("/api/routes/{host}/")
30 | class RouteDetailView:
31 | async def on_get(self, req, resp, *, host: str):
32 | try:
33 | route = client.get_route(host)
34 | resp.media = dict(route)
35 | except NotFound:
36 | resp.media = {"detail": f"No route found for {host}."}
37 | resp.status_code = 404
38 |
39 | async def on_put(self, req, resp, *, host: str):
40 | data = await req.media()
41 | route = client.update_route(host, data)
42 | resp.media = dict(route)
43 |
44 | async def on_delete(self, req, resp, *, host:str):
45 | client.delete_route(host)
46 | resp.status_code = api.status_codes.HTTP_204
47 |
48 |
49 | if __name__ == '__main__':
50 | api.run()
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/ceryx/certificates.lua:
--------------------------------------------------------------------------------
1 | local redis = require "ceryx.redis"
2 | local ssl = require "ngx.ssl"
3 | local utils = require "ceryx.utils"
4 |
5 | local exports = {}
6 |
7 | function getRedisKeyForHost(host)
8 | return redis.prefix .. ":settings:" .. host
9 | end
10 |
11 | function getCertificatesForHost(host)
12 | ngx.log(ngx.DEBUG, "Looking for SSL sertificate for " .. host)
13 | local redisClient = redis:client()
14 | local certificates_redis_key = getRedisKeyForHost(host)
15 | local certificate_path, certificate_err = redisClient:hget(certificates_redis_key, "certificate_path")
16 | local key_path, key_err = redisClient:hget(certificates_redis_key, "key_path")
17 |
18 | if certificate_path == ngx.null then
19 | ngx.log(ngx.ERR, "Could not retrieve SSL certificate path for " .. host .. " from Redis: " .. (certificate_err or "N/A"))
20 | return nil
21 | end
22 |
23 | if key_path == ngx.null then
24 | ngx.log(ngx.ERR, "Could not retrieve SSL key path for " .. host .. " from Redis: " .. (key_err or "N/A"))
25 | return nil
26 | end
27 |
28 | ngx.log(ngx.DEBUG, "Found SSL certificates for " .. host .. " in Redis.")
29 |
30 | local certificate_data = utils.read_file(certificate_path)
31 | local key_data = utils.read_file(key_path)
32 |
33 | local data = {}
34 |
35 | data["certificate"] = certificate_data
36 | data["key"] = key_data
37 |
38 | return data
39 | end
40 |
41 | exports.getCertificatesForHost = getCertificatesForHost
42 |
43 | return exports
44 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | x-common-settings:
4 | &common-settings
5 | CERYX_DEBUG: ${CERYX_DEBUG:-false}
6 | CERYX_REDIS_HOST: ${CERYX_REDIS_HOST:-redis}
7 | CERYX_REDIS_PORT: ${CERYX_REDIS_PORT:-6379}
8 | CERYX_REDIS_TIMEOUT: ${CERYX_REDIS_TIMEOUT:-100}
9 |
10 |
11 | services:
12 | ceryx:
13 | image: sourcelair/ceryx:latest
14 | ports:
15 | - ${CERYX_EXTERNAL_PORT:-80}:80
16 | - ${CERYX_EXTERNAL_SSL_PORT:-443}:443
17 | depends_on:
18 | - redis
19 | environment:
20 | <<: *common-settings
21 | CERYX_DISABLE_LETS_ENCRYPT: ${CERYX_DISABLE_LETS_ENCRYPT:-false}
22 | CERYX_SSL_DEFAULT_CERTIFICATE: ${CERYX_SSL_DEFAULT_CERTIFICATE:-/etc/ceryx/ssl/default.crt}
23 | CERYX_SSL_DEFAULT_KEY: ${CERYX_SSL_DEFAULT_KEY:-/etc/ceryx/ssl/default.key}
24 | CERYX_DOCKERIZE_EXTRA_ARGS: -no-overwrite
25 | command:
26 | - usr/local/openresty/bin/openresty
27 | - -g
28 | - daemon off;
29 |
30 | api:
31 | image: sourcelair/ceryx-api:latest
32 | depends_on:
33 | - redis
34 | - ceryx
35 | environment:
36 | <<: *common-settings
37 | CERYX_API_HOST: ${CERYX_API_HOST:-0.0.0.0}
38 | CERYX_API_HOSTNAME: ${CERYX_API_HOSTNAME:-localhost}
39 | CERYX_API_PORT: ${CERYX_API_PORT:-5555}
40 |
41 | redis:
42 | image: redis:3.2.11-alpine
43 | volumes:
44 | - redis_data:/data
45 |
46 | networks:
47 | default:
48 | attachable: true
49 | driver: overlay
50 | name: ceryx
51 |
52 | volumes:
53 | redis_data:
54 |
--------------------------------------------------------------------------------
/ceryx/nginx/conf/nginx.conf.tmpl:
--------------------------------------------------------------------------------
1 | user www-data www-data;
2 | worker_processes 1;
3 | pid /run/nginx.pid;
4 |
5 | env CERYX_DISABLE_LETS_ENCRYPT;
6 | env CERYX_REDIS_PREFIX;
7 | env CERYX_REDIS_HOST;
8 | env CERYX_REDIS_PASSWORD;
9 | env CERYX_REDIS_PORT;
10 | env CERYX_REDIS_TIMEOUT;
11 |
12 | events {
13 | worker_connections 1024;
14 | }
15 |
16 | http {
17 | # Basic Settings
18 | sendfile on;
19 | tcp_nopush on;
20 | tcp_nodelay on;
21 | keepalive_timeout 30s 30s;
22 | client_max_body_size {{ default .Env.CERYX_MAX_REQUEST_BODY_SIZE "100m" }};
23 |
24 | # Use the Docker internal DNS, pick your favorite if running outside of Docker
25 | resolver {{ default .Env.CERYX_DNS_RESOLVER "127.0.0.11" }};
26 |
27 | # Logging
28 | access_log /dev/stdout;
29 | error_log /dev/stderr {{ default .Env.CERYX_LOG_LEVEL "info" }};
30 |
31 | # Lua settings
32 | lua_package_path "${prefix}lualib/?.lua;;";
33 |
34 | lua_shared_dict ceryx 1m;
35 | lua_shared_dict auto_ssl 1m;
36 | lua_shared_dict auto_ssl_settings 64k;
37 | {{ if eq (lower (default .Env.CERYX_DEBUG "")) "true" }}
38 | lua_code_cache off;
39 | {{ else }}
40 | lua_code_cache on;
41 | {{ end }}
42 |
43 | {{ if ne (lower (default .Env.CERYX_DISABLE_LETS_ENCRYPT "")) "true" }}
44 | # Enable automatic Let's Encryps certificate generation, if
45 | # `CERYX_DISABLE_LETS_ENCRYPT` is *not* set to `true`.
46 | # Check out https://github.com/openresty/lua-resty-core
47 | init_by_lua_file "lualib/letsencrypt.lua";
48 |
49 | init_worker_by_lua_block {
50 | auto_ssl:init_worker()
51 | }
52 | {{ end }}
53 |
54 | # Includes
55 | include mime.types;
56 | include ceryx.conf;
57 | include ../sites-enabled/*;
58 | }
59 |
--------------------------------------------------------------------------------
/k8s/ceryx/templates/_helpers.tpl:
--------------------------------------------------------------------------------
1 | {{/* vim: set filetype=mustache: */}}
2 | {{/*
3 | Expand the name of the chart.
4 | */}}
5 | {{- define "ceryx.name" -}}
6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
7 | {{- end -}}
8 |
9 | {{/*
10 | Create a default fully qualified app name.
11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
12 | If release name contains chart name it will be used as a full name.
13 | */}}
14 | {{- define "ceryx.fullname" -}}
15 | {{- if .Values.fullnameOverride -}}
16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
17 | {{- else -}}
18 | {{- $name := default .Chart.Name .Values.nameOverride -}}
19 | {{- if contains $name .Release.Name -}}
20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}}
21 | {{- else -}}
22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
23 | {{- end -}}
24 | {{- end -}}
25 | {{- end -}}
26 |
27 | {{/*
28 | Create chart name and version as used by the chart label.
29 | */}}
30 | {{- define "ceryx.chart" -}}
31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
32 | {{- end -}}
33 |
34 | {{/*
35 | Common labels
36 | */}}
37 | {{- define "ceryx.labels" -}}
38 | helm.sh/chart: {{ include "ceryx.chart" . }}
39 | {{ include "ceryx.selectorLabels" . }}
40 | {{- if .Chart.AppVersion }}
41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
42 | {{- end }}
43 | app.kubernetes.io/managed-by: {{ .Release.Service }}
44 | {{- end -}}
45 |
46 | {{/*
47 | Selector labels
48 | */}}
49 | {{- define "ceryx.selectorLabels" -}}
50 | app.kubernetes.io/name: {{ include "ceryx.name" . }}
51 | app.kubernetes.io/instance: {{ .Release.Name }}
52 | {{- end -}}
53 |
54 | {{/*
55 | Create the name of the service account to use
56 | */}}
57 | {{- define "ceryx.serviceAccountName" -}}
58 | {{- if .Values.serviceAccount.create -}}
59 | {{ default (include "ceryx.fullname" .) .Values.serviceAccount.name }}
60 | {{- else -}}
61 | {{ default "default" .Values.serviceAccount.name }}
62 | {{- end -}}
63 | {{- end -}}
64 |
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/router.lua:
--------------------------------------------------------------------------------
1 | local redis = require "ceryx.redis"
2 | local routes = require "ceryx.routes"
3 | local utils = require "ceryx.utils"
4 |
5 | local redisClient = redis:client()
6 |
7 | local host = ngx.var.host
8 | local cache = ngx.shared.ceryx
9 |
10 | local is_not_https = (ngx.var.scheme ~= "https")
11 |
12 | function formatTarget(target)
13 | target = utils.ensure_protocol(target)
14 | target = utils.ensure_no_trailing_slash(target)
15 |
16 | return target .. ngx.var.request_uri
17 | end
18 |
19 | function redirect(source, target)
20 | ngx.log(ngx.INFO, "Redirecting request for " .. source .. " to " .. target .. ".")
21 | return ngx.redirect(target, ngx.HTTP_MOVED_PERMANENTLY)
22 | end
23 |
24 | function proxy(source, target)
25 | ngx.var.target = target
26 | ngx.log(ngx.INFO, "Proxying request for " .. source .. " to " .. target .. ".")
27 | end
28 |
29 | function routeRequest(source, target, mode)
30 | ngx.log(ngx.DEBUG, "Received " .. mode .. " routing request from " .. source .. " to " .. target)
31 |
32 | target = formatTarget(target)
33 |
34 | if mode == "redirect" then
35 | return redirect(source, target)
36 | end
37 |
38 | return proxy(source, target)
39 | end
40 |
41 | if is_not_https then
42 | local settings_key = routes.getSettingsKeyForSource(host)
43 | local enforce_https, flags = cache:get(host .. ":enforce_https")
44 |
45 | if enforce_https == nil then
46 | local res, flags = redisClient:hget(settings_key, "enforce_https")
47 | enforce_https = tonumber(res)
48 | cache:set(host .. ":enforce_https", enforce_https, 5)
49 | end
50 |
51 | if enforce_https == 1 then
52 | return ngx.redirect("https://" .. host .. ngx.var.request_uri, ngx.HTTP_MOVED_PERMANENTLY)
53 | end
54 | end
55 |
56 | ngx.log(ngx.INFO, "HOST " .. host)
57 | local route = routes.getRouteForSource(host)
58 |
59 | if route == nil then
60 | ngx.log(ngx.INFO, "No $wildcard target configured for fallback. Exiting with Bad Gateway.")
61 | return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
62 | end
63 |
64 | -- Save found key to local cache for 5 seconds
65 | routeRequest(host, route.target, route.mode)
66 |
--------------------------------------------------------------------------------
/ceryx/tests/client/connection.py:
--------------------------------------------------------------------------------
1 | from urllib3.connection import HTTPConnection, HTTPSConnection
2 | import os
3 | import socket
4 |
5 |
6 | DEFAULT_CERYX_HOST = "ceryx" # Set by Docker Compose in tests
7 | CERYX_HOST = os.getenv("CERYX_HOST", DEFAULT_CERYX_HOST)
8 |
9 |
10 | class CeryxTestsHTTPConnection(HTTPConnection):
11 | """
12 | Custom-built HTTPConnection for Ceryx tests. Force sets the request's
13 | host to the configured Ceryx host, if the request's original host
14 | ends with `.ceryx.test`.
15 | """
16 |
17 | @property
18 | def host(self):
19 | """
20 | Do what the original property did. We just want to touch the setter.
21 | """
22 | return self._dns_host.rstrip('.')
23 |
24 | @host.setter
25 | def host(self, value):
26 | """
27 | If the request header ends with `.ceryx.test` then force set the actual
28 | host to the configured Ceryx host, so as to send corresponding
29 | requests to Ceryx.
30 | """
31 | self._dns_host = CERYX_HOST if value.endswith(".ceryx.test") else value
32 |
33 |
34 | class CeryxTestsHTTPSConnection(CeryxTestsHTTPConnection, HTTPSConnection):
35 | def __init__(
36 | self, host, port=None, key_file=None, cert_file=None,
37 | key_password=None, strict=None,
38 | timeout=socket._GLOBAL_DEFAULT_TIMEOUT, ssl_context=None,
39 | server_hostname=None, **kw,
40 | ):
41 |
42 | # Initialise the HTTPConnection subclass created above.
43 | CeryxTestsHTTPConnection.__init__(
44 | self, host, port, strict=strict, timeout=timeout, **kw,
45 | )
46 |
47 | self.key_file = key_file
48 | self.cert_file = cert_file
49 | self.key_password = key_password
50 | self.ssl_context = ssl_context
51 | self.server_hostname = server_hostname
52 |
53 | # ------------------------------
54 | # Original comment from upstream
55 | # ------------------------------
56 | #
57 | # Required property for Google AppEngine 1.9.0 which otherwise causes
58 | # HTTPS requests to go out as HTTP. (See Issue #356)
59 | self._protocol = 'https'
60 |
--------------------------------------------------------------------------------
/ceryx/nginx/conf/ceryx.conf.tmpl:
--------------------------------------------------------------------------------
1 | map $http_upgrade $connection_upgrade {
2 | default upgrade;
3 | '' close;
4 | }
5 |
6 | map $http_x_forwarded_proto $proxy_set_x_forwarded_proto {
7 | default $scheme;
8 | 'http' http;
9 | 'https' https;
10 | }
11 |
12 | server {
13 | listen 80;
14 | listen 443 ssl;
15 | default_type text/html;
16 |
17 | ssl_certificate {{ default .Env.CERYX_SSL_DEFAULT_CERTIFICATE "/etc/ceryx/ssl/default.crt" }};
18 | ssl_certificate_key {{ default .Env.CERYX_SSL_DEFAULT_KEY "/etc/ceryx/ssl/default.key" }};
19 | ssl_certificate_by_lua_file lualib/certificate.lua;
20 |
21 | location /.well-known/acme-challenge {
22 | content_by_lua_block {
23 | auto_ssl:challenge_server()
24 | }
25 | }
26 |
27 | location / {
28 | set $target "fallback";
29 |
30 | # Lua files
31 | access_by_lua_file lualib/router.lua;
32 |
33 | # Proxy configuration
34 | proxy_set_header Host $http_host;
35 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
36 | proxy_set_header X-Real-IP $remote_addr;
37 | proxy_set_header X-Forwarded-Proto $proxy_set_x_forwarded_proto;
38 |
39 | # Upgrade headers
40 | proxy_http_version 1.1;
41 | proxy_set_header Upgrade $http_upgrade;
42 | proxy_set_header Connection $connection_upgrade;
43 | proxy_redirect ~^(http://[^:]+):\d+(/.+)$ $2;
44 | proxy_redirect ~^(https://[^:]+):\d+(/.+)$ $2;
45 | proxy_redirect / /;
46 |
47 | proxy_pass $target;
48 | }
49 |
50 | error_page 503 /503.html;
51 | location = /503.html {
52 | root /etc/ceryx/static;
53 | }
54 |
55 | error_page 500 /500.html;
56 | location = /500.html {
57 | root /etc/ceryx/static;
58 | }
59 | }
60 |
61 | {{ if ne (lower (default .Env.CERYX_DISABLE_LETS_ENCRYPT "")) "true" }}
62 | # Launch the Let's Encrypt certificate internal server running on port 8999, if
63 | # `CERYX_DISABLE_LETS_ENCRYPT` is *not* set to `true`.
64 |
65 | server {
66 | listen 127.0.0.1:8999;
67 | client_body_buffer_size 1m;
68 | client_max_body_size 1m;
69 | location / {
70 | content_by_lua_block {
71 | auto_ssl:hook_server()
72 | }
73 | }
74 | }
75 | {{ end }}
76 |
--------------------------------------------------------------------------------
/k8s/ceryx/values.yaml:
--------------------------------------------------------------------------------
1 | # Default values for ceryx.
2 | # This is a YAML-formatted file.
3 | # Declare variables to be passed into your templates.
4 |
5 | ceryxNginx:
6 | name: "ceryx-nginx"
7 | replicaCount: 1
8 | nodeSelector: { beta.kubernetes.io/os: linux }
9 | image:
10 | repository: "sourcelair/ceryx"
11 | tag: "latest"
12 | pullPolicy: "IfNotPresent"
13 | containerPort:
14 | http: 80
15 | https: 443
16 | #true if you want to mount a cert and key as a secret.
17 | useHTTPSCert: true
18 | secretName: "your-cert-secret-name"
19 | certMountPath: "/etc/ceryx/ssl"
20 | service:
21 | labels: {}
22 | annotations: {}
23 | loadBalancerIP:
24 | #Only checked implementation on LoadBalancer.
25 | type: "LoadBalancer"
26 |
27 | ceryxApi:
28 | name: "ceryx-api"
29 | replicaCount: 1
30 | nodeSelector: { beta.kubernetes.io/os: linux }
31 | image:
32 | repository: "sourcelair/ceryx-api"
33 | tag: "latest"
34 | pullPolicy: "IfNotPresent"
35 | containerPort:
36 | http: 5555
37 | service:
38 | type: "ClusterIP"
39 |
40 | ceryxRedis:
41 | name: "ceryx-redis"
42 | replicaCount: 1
43 | image:
44 | repository: "redis"
45 | tag: "3.2.11-alpine"
46 | pullPolicy: "IfNotPresent"
47 | containerPort:
48 | redis: 6379
49 | #If not specified, then no password assumed.
50 | #TODO: Password shouldn't be in values file.
51 | password:
52 | #Size of persistent volume housing redis database on disk. Default: 5Gi
53 | storage: "5Gi"
54 |
55 |
56 | envvars:
57 | #Default: 0.0.0.0
58 | CERYX_API_HOST: "0.0.0.0"
59 | #Default: false
60 | CERYX_DEBUG: "false"
61 | # Default: 10.0.0.10 . May need to change in your cluster.
62 | CERYX_DNS_RESOLVER: "10.0.0.10"
63 | #Default: 100m
64 | CERYX_MAX_REQUEST_BODY_SIZE: "100m"
65 | # DNS Address of Redis Host. May need to change.
66 | CERYX_REDIS_HOST: "ceryx-redis.default.svc.cluster.local"
67 | #Default: ceryx
68 | CERYX_REDIS_PREFIX: "ceryx"
69 | #Default: 100
70 | CERYX_REDIS_TIMEOUT: 100
71 | #Default: "true". Recommend cert manager for LE in Kubernetes
72 | #https://github.com/jetstack/cert-manager
73 | CERYX_DISABLE_LETS_ENCRYPT: "true"
74 | # If cert/key is mounted via a secret, you may change the below to match.
75 | CERYX_SSL_DEFAULT_CERTIFICATE: "/etc/ceryx/ssl/tls.crt"
76 | CERYX_SSL_DEFAULT_KEY: "/etc/ceryx/ssl/tls.key"
77 |
78 |
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/certificate.lua:
--------------------------------------------------------------------------------
1 | local certificates = require "ceryx.certificates"
2 | local ssl = require "ngx.ssl"
3 | local redis = require "ceryx.redis"
4 | local utils = require "ceryx.utils"
5 |
6 | local disable_lets_encrypt = utils.getenv("CERYX_DISABLE_LETS_ENCRYPT", ""):lower() == "true"
7 | local host, host_err = ssl.server_name()
8 |
9 | if not host then
10 | ngx.log(ngx.ERROR, "Could not retrieve SSL Server Name: " .. host_err)
11 | return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
12 | end
13 |
14 | local host_certificates = certificates.getCertificatesForHost(host)
15 |
16 | if host_certificates ~= nil then
17 | -- Convert data from PEM to DER
18 | local certificate_der, certificate_der_err = ssl.cert_pem_to_der(host_certificates["certificate"])
19 | if not certificate_der or certificate_der_err then
20 | ngx.log(ngx.ERROR, "Could not convert SSL Certificate to DER. Error: " .. (certificate_der_err or ""))
21 | ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
22 | end
23 |
24 | local key_der, key_der_err = ssl.priv_key_pem_to_der(host_certificates["key"])
25 | if not key_der or key_der_err then
26 | ngx.log(ngx.ERROR, "Could not convert PEM key to DER. Error: " .. (key_der_err or ""))
27 | ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
28 | end
29 |
30 | -- Set the certificate information for the current SSL Session
31 | local ssl_certificate_ok, ssl_certficate_error = ssl.set_der_cert(certificate_der)
32 |
33 | if not ssl_certificate_ok then
34 | ngx.log(
35 | ngx.ERROR,
36 | "Could not set the certificate for the current SSL Session. Error: " .. (ssl_certficate_error or "")
37 | )
38 | ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
39 | end
40 |
41 | local ssl_key_ok, ssl_key_error = ssl.set_der_priv_key(key_der)
42 | if not ssl_key_ok then
43 | ngx.log(ngx.ERROR, "Could not set the key for the current SSL Session. Error: " .. (ssl_key_error or ""))
44 | ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
45 | end
46 | else
47 | ngx.log(ngx.INFO, "No valid SSL certificate has been configured for " .. host .. ".")
48 |
49 | if not disable_lets_encrypt then
50 | ngx.log(ngx.INFO, "Passing SSL certificate handling for " .. host .. " to Let's Encrypt.")
51 | auto_ssl:ssl_certificate()
52 | end
53 | end
54 |
55 | ngx.log(ngx.DEBUG, "Completed SSL negotiation for " .. host)
56 |
--------------------------------------------------------------------------------
/api/ceryx/schemas.py:
--------------------------------------------------------------------------------
1 | import re
2 | import typesystem
3 |
4 |
5 | def ensure_protocol(url):
6 | starts_with_protocol = r"^https?://"
7 | return url if re.match(starts_with_protocol, url) else f"http://{url}"
8 |
9 |
10 | def boolean_to_redis(value: bool):
11 | return "1" if value else "0"
12 |
13 |
14 | def redis_to_boolean(value):
15 | return True if value == "1" else False
16 |
17 |
18 | def ensure_string(value):
19 | redis_value = (
20 | None if value is None
21 | else value.decode("utf-8") if type(value) == bytes else str(value)
22 | )
23 | return redis_value
24 |
25 |
26 | def value_to_redis(field, value):
27 | if isinstance(field, typesystem.Boolean):
28 | return boolean_to_redis(value)
29 |
30 | if isinstance(field, typesystem.Reference):
31 | return field.target.validate(value).to_redis()
32 |
33 | return ensure_string(value)
34 |
35 |
36 | def redis_to_value(field, redis_value):
37 | if isinstance(field, typesystem.Boolean):
38 | return redis_to_boolean(redis_value)
39 |
40 | if isinstance(field, typesystem.Reference):
41 | return field.target.from_redis(redis_value)
42 |
43 | return ensure_string(redis_value)
44 |
45 |
46 | class BaseSchema(typesystem.Schema):
47 | @classmethod
48 | def from_redis(cls, redis_data):
49 | data = {
50 | ensure_string(key): redis_to_value(cls.fields[ensure_string(key)], value)
51 | for key, value in redis_data.items()
52 | }
53 | return cls.validate(data)
54 |
55 | def to_redis(self):
56 | return {
57 | ensure_string(key): value_to_redis(self.fields[key], value)
58 | for key, value in self.items()
59 | if value is not None
60 | }
61 |
62 |
63 | class Settings(BaseSchema):
64 | enforce_https = typesystem.Boolean(default=False)
65 | mode = typesystem.Choice(
66 | choices=(
67 | ("proxy", "Proxy"),
68 | ("redirect", "Redirect"),
69 | ),
70 | default="proxy",
71 | )
72 | certificate_path = typesystem.String(allow_null=True)
73 | key_path = typesystem.String(allow_null=True)
74 |
75 |
76 | class Route(BaseSchema):
77 | DEFAULT_SETTINGS = dict(Settings.validate({}))
78 |
79 | source = typesystem.String()
80 | target = typesystem.String()
81 | settings = typesystem.Reference(Settings, default=DEFAULT_SETTINGS)
82 |
83 | @classmethod
84 | def validate(cls, data):
85 | if "target" in data.keys():
86 | data["target"] = ensure_protocol(data["target"])
87 | return super().validate(data)
88 |
--------------------------------------------------------------------------------
/ceryx/tests/test_routes.py:
--------------------------------------------------------------------------------
1 | from base import BaseTest
2 |
3 |
4 | class TestRoutes(BaseTest):
5 | def test_no_route(self):
6 | """
7 | Ceryx should send a `503` response when receiving a request with a `Host`
8 | header that has not been registered for routing.
9 | """
10 | response = self.client.get("http://i-do-not-exist.ceryx.test/")
11 | assert response.status_code == 503
12 |
13 |
14 | def test_proxy(self):
15 | """
16 | Ceryx should successfully proxy the upstream request to the client, for a
17 | registered route.
18 | """
19 | # Register the local Ceryx API as a route
20 | target = f"http://api:5555/api/routes/"
21 | self.redis.set(self.redis_target_key, target)
22 |
23 | upstream_response = self.client.get(target)
24 | ceryx_response = self.client.get(f"http://{self.host}/")
25 |
26 | assert upstream_response.status_code == ceryx_response.status_code
27 | assert upstream_response.content == ceryx_response.content
28 |
29 |
30 | def test_redirect(self):
31 | """
32 | Ceryx should respond with 301 status and the appropriate `Location` header
33 | for redirected routes.
34 | """
35 | # Register the local Ceryx API as a redirect route
36 | target = "http://api:5555/api/routes"
37 | self.redis.set(self.redis_target_key, target)
38 | self.redis.hset(self.redis_settings_key, "mode", "redirect")
39 |
40 | url = f"http://{self.host}/some/path/?some=args&more=args"
41 | target_url = f"{target}/some/path/?some=args&more=args"
42 |
43 | ceryx_response = self.client.get(url, allow_redirects=False)
44 |
45 | assert ceryx_response.status_code == 301
46 | assert ceryx_response.headers["Location"] == target_url
47 |
48 |
49 | def test_enforce_https(self):
50 | """
51 | Ceryx should respond with 301 status and the appropriate `Location` header
52 | for routes with HTTPS enforced.
53 | """
54 | # Register the local Ceryx API as a redirect route
55 | target = "http://api:5555/"
56 | self.redis.set(self.redis_target_key, target)
57 | self.redis.hset(self.redis_settings_key, "enforce_https", "1")
58 |
59 | base_url = f"{self.host}/some/path/?some=args&more=args"
60 | http_url = f"http://{base_url}"
61 | https_url = f"https://{base_url}"
62 | ceryx_response = self.client.get(http_url, allow_redirects=False)
63 |
64 | assert ceryx_response.status_code == 301
65 | assert ceryx_response.headers["Location"] == https_url
66 |
--------------------------------------------------------------------------------
/ceryx/nginx/lualib/ceryx/routes.lua:
--------------------------------------------------------------------------------
1 | local redis = require "ceryx.redis"
2 |
3 | local exports = {}
4 |
5 | function getRouteKeyForSource(source)
6 | return redis.prefix .. ":routes:" .. source
7 | end
8 |
9 | function getSettingsKeyForSource(source)
10 | return redis.prefix .. ":settings:" .. source
11 | end
12 |
13 | function targetIsInValid(target)
14 | return not target or target == ngx.null
15 | end
16 |
17 | function getTargetForSource(source, redisClient)
18 | -- Construct Redis key and then
19 | -- try to get target for host
20 | local key = getRouteKeyForSource(source)
21 | local target, _ = redisClient:get(key)
22 |
23 | if targetIsInValid(target) then
24 | ngx.log(ngx.INFO, "Could not find target for " .. source .. ".")
25 |
26 | -- Construct Redis key for $wildcard
27 | key = getRouteKeyForSource("$wildcard")
28 | target, _ = redisClient:get(key)
29 |
30 | if targetIsInValid(target) then
31 | return nil
32 | end
33 |
34 | ngx.log(ngx.DEBUG, "Falling back to " .. target .. ".")
35 | end
36 |
37 | return target
38 | end
39 |
40 | function getModeForSource(source, redisClient)
41 | ngx.log(ngx.DEBUG, "Get routing mode for " .. source .. ".")
42 | local settings_key = getSettingsKeyForSource(source)
43 | local mode, _ = redisClient:hget(settings_key, "mode")
44 |
45 | if mode == ngx.null or not mode then
46 | mode = "proxy"
47 | end
48 |
49 | return mode
50 | end
51 |
52 | function getRouteForSource(source)
53 | local _
54 | local route = {}
55 | local cache = ngx.shared.ceryx
56 | local redisClient = redis:client()
57 |
58 | ngx.log(ngx.DEBUG, "Looking for a route for " .. source)
59 | -- Check if key exists in local cache
60 | local cached_value, _ = cache:get(source)
61 |
62 | if cached_value then
63 | ngx.log(ngx.DEBUG, "Cache hit for " .. source .. ".")
64 | route.target = cached_value
65 | else
66 | ngx.log(ngx.DEBUG, "Cache miss for " .. source .. ".")
67 | route.target = getTargetForSource(source, redisClient)
68 |
69 | if targetIsInValid(route.target) then
70 | return nil
71 | end
72 | cache:set(host, res, 5)
73 | ngx.log(ngx.DEBUG, "Caching from " .. source .. " to " .. route.target .. " for 5 seconds.")
74 | end
75 |
76 | route.mode = getModeForSource(source, redisClient)
77 |
78 | return route
79 | end
80 |
81 | exports.getSettingsKeyForSource = getSettingsKeyForSource
82 | exports.getRouteForSource = getRouteForSource
83 | exports.getTargetForSource = getTargetForSource
84 |
85 | return exports
86 |
--------------------------------------------------------------------------------
/api/tests.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | import pytest
4 |
5 | from api import api
6 | from ceryx import schemas
7 |
8 |
9 | @pytest.fixture
10 | def client():
11 | return api.requests
12 |
13 |
14 | @pytest.fixture
15 | def host():
16 | return f"{uuid.uuid4()}.api.ceryx.test"
17 |
18 |
19 | def test_list_routes(client, host):
20 | """
21 | Assert that listing routes will return a JSON list.
22 | """
23 | route_1 = schemas.Route.validate({
24 | "source": f"route-1-{host}",
25 | "target": "http://somewhere",
26 | })
27 | client.post("/api/routes/", json=dict(route_1))
28 |
29 | route_2 = schemas.Route.validate({
30 | "source": f"route-2-{host}",
31 | "target": "http://somewhere",
32 | })
33 | client.post("/api/routes/", json=dict(route_2))
34 |
35 | response = client.get("/api/routes/")
36 | assert response.status_code == 200
37 |
38 | route_list = response.json()
39 | assert dict(route_1) in route_list
40 | assert dict(route_2) in route_list
41 |
42 |
43 | def test_create_route(client, host):
44 | """
45 | Assert that creating a route, will result in the appropriate route.
46 | """
47 | route = schemas.Route.validate({
48 | "source": host,
49 | "target": "http://somewhere",
50 | })
51 |
52 | # Create a route and assert valid data in response
53 | response = client.post("/api/routes/", json=dict(route))
54 | assert response.status_code == 201
55 | assert response.json() == dict(route)
56 |
57 | # Also get the route and assert valid data
58 | response = client.get(f"/api/routes/{host}/")
59 | assert response.status_code == 200
60 | assert response.json() == dict(route)
61 |
62 |
63 | def test_update_route(client, host):
64 | """
65 | Assert that creating a route, will result in the appropriate route.
66 | """
67 | route = schemas.Route.validate({
68 | "source": host,
69 | "target": "http://somewhere",
70 | })
71 |
72 | client.post("/api/routes/", json=dict(route))
73 |
74 | updated_route = schemas.Route.validate({
75 | "source": host,
76 | "target": "http://somewhere-else",
77 | })
78 | updated_route_payload = dict(updated_route)
79 | del updated_route_payload["source"] # We should not need that
80 | response = client.put(f"/api/routes/{host}/", json=updated_route_payload)
81 |
82 | # Also get the route and assert valid data
83 | assert response.status_code == 200
84 | assert response.json() == dict(updated_route)
85 |
86 |
87 | def test_delete_route(client, host):
88 | """
89 | Assert that deleting a route, will actually delete it.
90 | """
91 | route = schemas.Route.validate({
92 | "source": host,
93 | "target": "http://somewhere",
94 | })
95 |
96 | # Create a route
97 | client.post("/api/routes/", json=dict(route))
98 |
99 | # Delete the route
100 | response = client.delete(f"/api/routes/{host}/")
101 | assert response.status_code == 204
102 |
103 | # Also get the route and assert that it does not exist
104 | response = client.get(f"/api/routes/{host}/")
105 | assert response.status_code == 404
106 |
107 |
--------------------------------------------------------------------------------
/k8s/ceryx/templates/ceryx-redis.yaml:
--------------------------------------------------------------------------------
1 | {{ if (not (empty .Values.ceryxRedis.password)) }}
2 | apiVersion: v1
3 | kind: Secret
4 | metadata:
5 | name: ceryx-redis-secret
6 | labels:
7 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }}
8 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
9 | type: Opaque
10 | data:
11 | CERYX_REDIS_PASSWORD: {{ .Values.ceryxRedis.password | b64enc }}
12 | ---
13 | {{end}}
14 | apiVersion: v1
15 | kind: PersistentVolumeClaim
16 | metadata:
17 | name: ceryx-redis-volume-claim
18 | labels:
19 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }}
20 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
21 | spec:
22 | accessModes:
23 | - ReadWriteOnce
24 | resources:
25 | requests:
26 | storage: {{default "5Gi" .Values.ceryxRedis.storage }}
27 | ---
28 | apiVersion: v1
29 | kind: Service
30 | metadata:
31 | name: {{default "ceryx-redis" .Values.ceryxRedis.name }}
32 | labels:
33 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }}
34 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
35 | spec:
36 | ports:
37 | - port: {{default 6379 .Values.ceryxRedis.containerPort.redis }}
38 | name: ceryx-redis
39 | clusterIP: None
40 | selector:
41 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }}
42 | ---
43 | apiVersion: apps/v1
44 | kind: StatefulSet
45 | metadata:
46 | name: {{default "ceryx-redis" .Values.ceryxRedis.name }}
47 | labels:
48 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }}
49 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
50 | spec:
51 | selector:
52 | matchLabels:
53 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }} # has to match .spec.template.metadata.labels
54 | serviceName: {{default "ceryx-redis" .Values.ceryxRedis.name }}
55 | replicas: {{default 1 .Values.ceryxRedis.replicaCount }}
56 | template:
57 | metadata:
58 | labels:
59 | app: {{default "ceryx-redis" .Values.ceryxRedis.name }} # has to match .spec.selector.matchLabels
60 | spec:
61 | containers:
62 | - name: {{default "redis" .Values.ceryxRedis.image.repository }}
63 | image: {{default "redis" .Values.ceryxRedis.image.repository }}:{{default "latest" .Values.ceryxRedis.image.tag }}
64 | imagePullPolicy: {{default "redis" .Values.ceryxRedis.image.pullPolicy }}
65 | {{ if (not (empty .Values.ceryxRedis.password)) }}
66 | args: ["--requirepass", "$(CERYX_REDIS_PASSWORD)", "--appendonly", "yes", "--save", "900", "1", "--save", "30", "2"]
67 | {{else }}
68 | args: ["--appendonly", "yes", "--save", "900", "1", "--save", "30", "2"]
69 | {{end}}
70 | ports:
71 | - containerPort: {{default 6379 .Values.ceryxRedis.containerPort.redis }}
72 | name: ceryx-redis
73 | env:
74 | {{ if (not (empty .Values.ceryxRedis.password)) }}
75 | - name: CERYX_REDIS_PASSWORD
76 | valueFrom:
77 | secretKeyRef:
78 | name: ceryx-redis-secret
79 | key: CERYX_REDIS_PASSWORD
80 | {{ end }}
81 | volumeMounts:
82 | - name: ceryx-redis-volume
83 | mountPath: /data
84 | volumes:
85 | - name: ceryx-redis-volume
86 | persistentVolumeClaim:
87 | claimName: ceryx-redis-volume-claim
--------------------------------------------------------------------------------
/k8s/ceryx/templates/ceryx-api.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: {{default "ceryx-api" .Values.ceryxApi.name }}
5 | labels:
6 | app: {{default "ceryx-api" .Values.ceryxApi.name }}
7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
8 | release: {{ .Release.Name }}
9 | {{- if .Values.ceryxApi.service.labels -}}
10 | {{ toYaml .Values.ceryxApi.service.labels | indent 4}}
11 | {{- end }}
12 | {{- if .Values.ceryxApi.service.annotations }}
13 | annotations:
14 | {{ toYaml .Values.ceryxApi.annotations | indent 8}}
15 | {{- end }}
16 | spec:
17 | type: {{default "ClusterIP" .Values.ceryxApi.service.type }}
18 | selector:
19 | app: {{default "ceryx-api" .Values.ceryxApi.name }}
20 | ports:
21 | - name: {{default "ceryx-api" .Values.ceryxApi.name }}-http
22 | port: {{default 5555 .Values.ceryxApi.containerPort.http }}
23 |
24 | ---
25 | apiVersion: apps/v1beta1
26 | kind: Deployment
27 | metadata:
28 | name: {{default "ceryx-api" .Values.ceryxApi.name }}
29 | labels:
30 | app: {{default "ceryx-api" .Values.ceryxApi.name }}
31 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
32 | release: {{ .Release.Name }}
33 | spec:
34 | replicas: {{default 1 .Values.ceryxApi.replicaCount }}
35 | selector:
36 | matchLabels:
37 | app: {{default "ceryx-api" .Values.ceryxApi.name }}
38 | template:
39 | metadata:
40 | labels:
41 | app: {{default "ceryx-api" .Values.ceryxApi.name }}
42 | spec:
43 | {{- if .Values.ceryxApi.nodeSelector }}
44 | nodeSelector:
45 | {{ toYaml .Values.ceryxApi.nodeSelector | indent 12}}
46 | {{- end }}
47 | containers:
48 | - name: {{default "ceryx-api" .Values.ceryxApi.name }}
49 | image: {{ .Values.ceryxApi.image.repository }}:{{default "latest" .Values.ceryxApi.image.tag }}
50 | imagePullPolicy: {{default "IfNotPresent" .Values.ceryxApi.image.pullPolicy }}
51 | ports:
52 | - containerPort: {{default 5555 .Values.ceryxApi.containerPort.http }}
53 | #command: ["usr/local/openresty/bin/openresty"]
54 | #args: ["-g", "daemon off;"]
55 | env:
56 | - name: CERYX_DEBUG
57 | value: {{default "false" .Values.envvars.CERYX_DEBUG | quote }}
58 | - name: CERYX_MAX_REQUEST_BODY_SIZE
59 | value: {{default "100m" .Values.envvars.CERYX_MAX_REQUEST_BODY_SIZE | quote }}
60 | - name: CERYX_REDIS_HOST
61 | value: {{required "Address of host is required." .Values.envvars.CERYX_REDIS_HOST | quote }}
62 | - name: CERYX_REDIS_PORT
63 | value: {{default 6379 .Values.ceryxRedis.containerPort.redis | quote }}
64 | - name: CERYX_REDIS_PREFIX
65 | value: {{default "ceryx" .Values.ceryxRedis.containerPort.redis | quote }}
66 | {{ if (not (empty .Values.ceryxRedis.password)) }}
67 | - name: CERYX_REDIS_PASSWORD
68 | valueFrom:
69 | secretKeyRef:
70 | name: ceryx-redis-secret
71 | key: CERYX_REDIS_PASSWORD
72 | {{end }}
73 | - name: CERYX_REDIS_TIMEOUT
74 | value: {{default 100 .Values.envvars.CERYX_REDIS_TIMEOUT | quote }}
75 | - name: CERYX_DNS_RESOLVER
76 | value: {{default "10.0.0.10" .Values.envvars.CERYX_DNS_RESOLVER | quote }}
77 | - name: CERYX_API_HOST
78 | value: {{default "0.0.0.0" .Values.envvars.CERYX_API_HOST | quote }}
79 | - name: CERYX_API_PORT
80 | value: {{default 5555 .Values.ceryxApi.containerPort.http | quote }}
81 |
--------------------------------------------------------------------------------
/api/ceryx/db.py:
--------------------------------------------------------------------------------
1 | """
2 | Simple Redis client, implemented the data logic of Ceryx.
3 | """
4 | import redis
5 |
6 | from ceryx import exceptions, schemas, settings
7 |
8 |
9 | def _str(subject):
10 | return subject.decode("utf-8") if type(subject) == bytes else str(bytes)
11 |
12 |
13 | class RedisClient:
14 | @staticmethod
15 | def from_config(path=None):
16 | """
17 | Returns a RedisClient, using the default configuration from Ceryx
18 | settings.
19 | """
20 | return RedisClient(
21 | settings.REDIS_HOST,
22 | settings.REDIS_PORT,
23 | settings.REDIS_PASSWORD,
24 | 0,
25 | settings.REDIS_PREFIX,
26 | settings.REDIS_TIMEOUT,
27 | )
28 |
29 | def __init__(self, host, port, password, db, prefix, timeout):
30 | self.client = redis.StrictRedis(host=host, port=port, password=password, db=db, socket_timeout=timeout, socket_connect_timeout=timeout)
31 | self.prefix = prefix
32 |
33 | def _prefixed_key(self, key):
34 | return f"{self.prefix}:{key}"
35 |
36 | def _route_key(self, source):
37 | return self._prefixed_key(f"routes:{source}")
38 |
39 | def _settings_key(self, source):
40 | return self._prefixed_key(f"settings:{source}")
41 |
42 | def _delete_target(self, host):
43 | key = self._route_key(host)
44 | self.client.delete(key)
45 |
46 | def _delete_settings(self, host):
47 | key = self._settings_key(host)
48 | self.client.delete(key)
49 |
50 | def _lookup_target(self, host, raise_exception=False):
51 | key = self._route_key(host)
52 | target = self.client.get(key)
53 |
54 | if target is None and raise_exception:
55 | raise exceptions.NotFound("Route not found.")
56 |
57 | return target
58 |
59 | def _lookup_settings(self, host):
60 | key = self._settings_key(host)
61 | return self.client.hgetall(key)
62 |
63 | def lookup_hosts(self, pattern="*"):
64 | lookup_pattern = self._route_key(pattern)
65 | left_padding = len(lookup_pattern) - 1
66 | keys = self.client.keys(lookup_pattern)
67 | return [_str(key)[left_padding:] for key in keys]
68 |
69 | def _set_target(self, host, target):
70 | key = self._route_key(host)
71 | self.client.set(key, target)
72 |
73 | def _set_settings(self, host, settings):
74 | key = self._settings_key(host)
75 | self.client.hmset(key, settings)
76 |
77 | def _set_route(self, route: schemas.Route):
78 | redis_data = route.to_redis()
79 | self._set_target(route.source, redis_data["target"])
80 | self._set_settings(route.source, redis_data["settings"])
81 | return route
82 |
83 | def get_route(self, host):
84 | target = self._lookup_target(host, raise_exception=True)
85 | settings = self._lookup_settings(host)
86 | route = schemas.Route.from_redis({
87 | "source": host,
88 | "target": target,
89 | "settings": settings
90 | })
91 | return route
92 |
93 | def list_routes(self):
94 | hosts = self.lookup_hosts()
95 | routes = [self.get_route(host) for host in hosts]
96 | return routes
97 |
98 | def create_route(self, data: dict):
99 | route = schemas.Route.validate(data)
100 | return self._set_route(route)
101 |
102 | def update_route(self, host: str, data: dict):
103 | data["source"] = host
104 | route = schemas.Route.validate(data)
105 | return self._set_route(route)
106 |
107 | def delete_route(self, host: str):
108 | self._delete_target(host)
109 | self._delete_settings(host)
110 |
--------------------------------------------------------------------------------
/ceryx/nginx/conf/mime.types:
--------------------------------------------------------------------------------
1 | types {
2 | text/html html htm shtml;
3 | text/css css;
4 | text/xml xml rss;
5 | image/gif gif;
6 | image/jpeg jpeg jpg;
7 | application/x-javascript js;
8 | application/atom+xml atom;
9 |
10 | text/mathml mml;
11 | text/plain txt;
12 | text/vnd.sun.j2me.app-descriptor jad;
13 | text/vnd.wap.wml wml;
14 | text/x-component htc;
15 |
16 | image/png png;
17 | image/tiff tif tiff;
18 | image/vnd.wap.wbmp wbmp;
19 | image/x-icon ico;
20 | image/x-jng jng;
21 | image/x-ms-bmp bmp;
22 | image/svg+xml svg svgz;
23 |
24 | application/java-archive jar war ear;
25 | application/json json;
26 | application/mac-binhex40 hqx;
27 | application/msword doc;
28 | application/pdf pdf;
29 | application/postscript ps eps ai;
30 | application/rtf rtf;
31 | application/vnd.ms-excel xls;
32 | application/vnd.ms-powerpoint ppt;
33 | application/vnd.wap.wmlc wmlc;
34 | application/vnd.google-earth.kml+xml kml;
35 | application/vnd.google-earth.kmz kmz;
36 | application/x-7z-compressed 7z;
37 | application/x-cocoa cco;
38 | application/x-java-archive-diff jardiff;
39 | application/x-java-jnlp-file jnlp;
40 | application/x-makeself run;
41 | application/x-perl pl pm;
42 | application/x-pilot prc pdb;
43 | application/x-rar-compressed rar;
44 | application/x-redhat-package-manager rpm;
45 | application/x-sea sea;
46 | application/x-shockwave-flash swf;
47 | application/x-stuffit sit;
48 | application/x-tcl tcl tk;
49 | application/x-x509-ca-cert der pem crt;
50 | application/x-xpinstall xpi;
51 | application/xhtml+xml xhtml;
52 | application/zip zip;
53 |
54 | application/octet-stream bin exe dll;
55 | application/octet-stream deb;
56 | application/octet-stream dmg;
57 | application/octet-stream eot;
58 | application/octet-stream iso img;
59 | application/octet-stream msi msp msm;
60 | application/ogg ogx;
61 |
62 | audio/midi mid midi kar;
63 | audio/mpeg mpga mpega mp2 mp3 m4a;
64 | audio/ogg oga ogg spx;
65 | audio/x-realaudio ra;
66 | audio/webm weba;
67 |
68 | video/3gpp 3gpp 3gp;
69 | video/mp4 mp4;
70 | video/mpeg mpeg mpg mpe;
71 | video/ogg ogv;
72 | video/quicktime mov;
73 | video/webm webm;
74 | video/x-flv flv;
75 | video/x-mng mng;
76 | video/x-ms-asf asx asf;
77 | video/x-ms-wmv wmv;
78 | video/x-msvideo avi;
79 | }
--------------------------------------------------------------------------------
/k8s/ceryx/templates/ceryx-nginx.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
5 | labels:
6 | app: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
8 | release: {{ .Release.Name }}
9 | {{ if .Values.ceryxNginx.service.labels }}
10 | {{ toYaml .Values.ceryxNginx.service.labels | indent 4}}
11 | {{ end }}
12 | {{ if .Values.ceryxNginx.service.annotations }}
13 | annotations:
14 | {{ toYaml .Values.ceryxNginx.service.annotations | indent 4}}
15 | {{ end }}
16 | spec:
17 | {{ if (and (eq .Values.ceryxNginx.service.type "LoadBalancer") (not (empty .Values.ceryxNginx.service.loadBalancerIP))) }}
18 | loadBalancerIP: {{ .Values.ceryxNginx.service.loadBalancerIP }}
19 | {{ end }}
20 | type: {{ .Values.ceryxNginx.service.type }}
21 | selector:
22 | app: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
23 | ports:
24 | - port: {{default 80 .Values.ceryxNginx.containerPort.http }}
25 | targetPort: 80
26 | protocol: TCP
27 | name: {{default "ceryx-nginx" .Values.ceryxNginx.name }}-http
28 | - port: {{default 443 .Values.ceryxNginx.containerPort.https }}
29 | targetPort: 443
30 | protocol: TCP
31 | name: {{default "ceryx-nginx" .Values.ceryxNginx.name }}-https
32 |
33 | ---
34 | apiVersion: apps/v1beta1
35 | kind: Deployment
36 | metadata:
37 | name: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
38 | labels:
39 | app: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
40 | spec:
41 | replicas: {{default 1 .Values.ceryxNginx.replicaCount }}
42 | selector:
43 | matchLabels:
44 | app: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
45 | template:
46 | metadata:
47 | labels:
48 | app: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
49 | spec:
50 | {{- if .Values.ceryxNginx.nodeSelector }}
51 | nodeSelector:
52 | {{ toYaml .Values.ceryxNginx.nodeSelector | indent 8}}
53 | {{- end }}
54 | containers:
55 | - name: {{default "ceryx-nginx" .Values.ceryxNginx.name }}
56 | image: {{ .Values.ceryxNginx.image.repository}}:{{default "latest" .Values.ceryxNginx.image.tag}}
57 | imagePullPolicy: {{default "IfNotPresent" .Values.ceryxNginx.image.pullPolicy }}
58 | ports:
59 | - containerPort: {{ .Values.ceryxNginx.containerPort.http }}
60 | - containerPort: {{ .Values.ceryxNginx.containerPort.https }}
61 | env:
62 | - name: CERYX_DEBUG
63 | value: {{default "false" .Values.envvars.CERYX_DEBUG | quote }}
64 | - name: CERYX_MAX_REQUEST_BODY_SIZE
65 | value: {{default "100m" .Values.envvars.CERYX_MAX_REQUEST_BODY_SIZE | quote }}
66 | - name: CERYX_REDIS_HOST
67 | value: {{ .Values.envvars.CERYX_REDIS_HOST | quote }}
68 | - name: CERYX_REDIS_PORT
69 | value: {{default 6379 .Values.ceryxRedis.containerPort.redis | quote }}
70 | - name: CERYX_REDIS_PREFIX
71 | value: {{default "ceryx" .Values.ceryxRedis.containerPort.redis | quote }}
72 |
73 | {{ if (not (empty .Values.ceryxRedis.password)) }}
74 | - name: CERYX_REDIS_PASSWORD
75 | valueFrom:
76 | secretKeyRef:
77 | name: ceryx-redis-secret
78 | key: CERYX_REDIS_PASSWORD
79 | {{end }}
80 | - name: CERYX_REDIS_TIMEOUT
81 | value: {{default "100" .Values.envvars.CERYX_REDIS_TIMEOUT | quote }}
82 | - name: CERYX_DISABLE_LETS_ENCRYPT
83 | value: {{default "false" .Values.envvars.CERYX_DISABLE_LETS_ENCRYPT | quote }}
84 | - name: CERYX_SSL_DEFAULT_CERTIFICATE
85 | value: {{default "/etc/ceryx/ssl/default.crt" .Values.envvars.CERYX_SSL_DEFAULT_CERTIFICATE }}
86 | - name: CERYX_SSL_DEFAULT_KEY
87 | value: {{default "/etc/ceryx/ssl/default.key" .Values.envvars.CERYX_SSL_DEFAULT_KEY }}
88 | - name: CERYX_DNS_RESOLVER
89 | value: {{default "10.0.0.10" .Values.envvars.CERYX_DNS_RESOLVER | quote }}
90 | {{ if (and (eq .Values.ceryxNginx.useHTTPSCert true) (not (empty .Values.ceryxNginx.secretName))) }}
91 | volumeMounts:
92 | - name: certificatesvolume
93 | mountPath: {{default "/etc/ceryx/ssl" .Values.ceryxNginx.certMountPath | quote }}
94 | readOnly: true
95 | volumes:
96 | - name: certificatesvolume
97 | secret:
98 | secretName: {{ .Values.ceryxNginx.secretName }}
99 | {{ end }}
100 |
101 |
--------------------------------------------------------------------------------
/ceryx/Pipfile.lock:
--------------------------------------------------------------------------------
1 | {
2 | "_meta": {
3 | "hash": {
4 | "sha256": "d5e51dbb4c258b0dde740fec8a0e1eff435ac14b3c808cb0ff2a900ed1eefa38"
5 | },
6 | "pipfile-spec": 6,
7 | "requires": {
8 | "python_version": "3.6"
9 | },
10 | "sources": [
11 | {
12 | "name": "pypi",
13 | "url": "https://pypi.python.org/simple",
14 | "verify_ssl": true
15 | }
16 | ]
17 | },
18 | "default": {},
19 | "develop": {
20 | "appdirs": {
21 | "hashes": [
22 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
23 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
24 | ],
25 | "version": "==1.4.4"
26 | },
27 | "atomicwrites": {
28 | "hashes": [
29 | "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"
30 | ],
31 | "version": "==1.4.1"
32 | },
33 | "attrs": {
34 | "hashes": [
35 | "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6",
36 | "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"
37 | ],
38 | "markers": "python_version >= '3.5'",
39 | "version": "==22.1.0"
40 | },
41 | "black": {
42 | "hashes": [
43 | "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739",
44 | "sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"
45 | ],
46 | "index": "pypi",
47 | "version": "==18.9b0"
48 | },
49 | "certifi": {
50 | "hashes": [
51 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3",
52 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"
53 | ],
54 | "index": "pypi",
55 | "version": "==2022.12.7"
56 | },
57 | "chardet": {
58 | "hashes": [
59 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
60 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
61 | ],
62 | "version": "==3.0.4"
63 | },
64 | "click": {
65 | "hashes": [
66 | "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1",
67 | "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"
68 | ],
69 | "markers": "python_version >= '3.6'",
70 | "version": "==8.0.4"
71 | },
72 | "idna": {
73 | "hashes": [
74 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
75 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
76 | ],
77 | "version": "==2.8"
78 | },
79 | "importlib-metadata": {
80 | "hashes": [
81 | "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e",
82 | "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"
83 | ],
84 | "markers": "python_version < '3.8'",
85 | "version": "==4.8.3"
86 | },
87 | "more-itertools": {
88 | "hashes": [
89 | "sha256:1bc4f91ee5b1b31ac7ceacc17c09befe6a40a503907baf9c839c229b5095cfd2",
90 | "sha256:c09443cd3d5438b8dafccd867a6bc1cb0894389e90cb53d227456b0b0bccb750"
91 | ],
92 | "markers": "python_version > '2.7'",
93 | "version": "==8.14.0"
94 | },
95 | "pluggy": {
96 | "hashes": [
97 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
98 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
99 | ],
100 | "markers": "python_version >= '3.6'",
101 | "version": "==1.0.0"
102 | },
103 | "py": {
104 | "hashes": [
105 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719",
106 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"
107 | ],
108 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
109 | "version": "==1.11.0"
110 | },
111 | "pytest": {
112 | "hashes": [
113 | "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d",
114 | "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5"
115 | ],
116 | "index": "pypi",
117 | "version": "==4.4.1"
118 | },
119 | "redis": {
120 | "hashes": [
121 | "sha256:6946b5dca72e86103edc8033019cc3814c031232d339d5f4533b02ea85685175",
122 | "sha256:8ca418d2ddca1b1a850afa1680a7d2fd1f3322739271de4b704e0d4668449273"
123 | ],
124 | "index": "pypi",
125 | "version": "==3.2.1"
126 | },
127 | "requests": {
128 | "hashes": [
129 | "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
130 | "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
131 | ],
132 | "index": "pypi",
133 | "version": "==2.21.0"
134 | },
135 | "setuptools": {
136 | "hashes": [
137 | "sha256:22c7348c6d2976a52632c67f7ab0cdf40147db7789f9aed18734643fe9cf3373",
138 | "sha256:4ce92f1e1f8f01233ee9952c04f6b81d1e02939d6e1b488428154974a4d0783e"
139 | ],
140 | "markers": "python_version >= '3.6'",
141 | "version": "==59.6.0"
142 | },
143 | "six": {
144 | "hashes": [
145 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
146 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
147 | ],
148 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
149 | "version": "==1.16.0"
150 | },
151 | "toml": {
152 | "hashes": [
153 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
154 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
155 | ],
156 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
157 | "version": "==0.10.2"
158 | },
159 | "typing-extensions": {
160 | "hashes": [
161 | "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42",
162 | "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"
163 | ],
164 | "markers": "python_version < '3.8'",
165 | "version": "==4.1.1"
166 | },
167 | "urllib3": {
168 | "hashes": [
169 | "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
170 | "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
171 | ],
172 | "index": "pypi",
173 | "version": "==1.24.2"
174 | },
175 | "zipp": {
176 | "hashes": [
177 | "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832",
178 | "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"
179 | ],
180 | "markers": "python_version >= '3.6'",
181 | "version": "==3.6.0"
182 | }
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ceryx - Rock-solid, programmable HTTP(S) reverse proxy
2 |
3 | [](https://travis-ci.org/sourcelair/ceryx)
4 |
5 | Ceryx is the rock-solid, programmable reverse proxy used to provide tens of thousands of [SourceLair](https://www.sourcelair.com/) projects with their unique HTTPS-enabled public URLs.
6 |
7 | ## High-level architecture
8 |
9 | One of the main traits of Ceryx that makes it rock-solid is the simplicity in its design. Ceryx is comprised of two components and a Redis backend: the HTTP(S) reverse proxy and an API.
10 |
11 | ### Proxy
12 | Ceryx uses NGINX OpenResty under the hood to route requests, based on the HTTP request's `Host` header or the [Server Name Indication](https://en.wikipedia.org/wiki/Server_Name_Indication) in HTTPS requests. Ceryx queries the Redis backend to decide to which target it should route each request.
13 |
14 | ### API
15 | The Ceryx API lets users dynamically create, update and delete Ceryx routes via any HTTP client. The API essentially validates, sanitizes and eventually stores input in the Ceryx backend, to be queried by the proxy.
16 |
17 | ## Configuration
18 |
19 | Ceryx is configured with the following environment variables:
20 |
21 | - `CERYX_API_HOST`: The host to bind the Ceryx API (default: `127.0.0.1`)
22 | - `CERYX_API_HOSTNAME`: Optional publicly accessible hostname for the Ceryx API (default: None)
23 | - `CERYX_API_PORT`: The port to bind the Ceryx API (default: `5555`)
24 | - `CERYX_DEBUG`: Enable debug logs for Ceryx API (default: `true`)
25 | - `CERYX_DISABLE_LETS_ENCRYPT`: Disable automatic Let's Encrypt HTTPS certificate generation (default: `false`)
26 | - `CERYX_DNS_RESOLVER`: The IP of the DNS resolver to use (default: `127.0.0.11` — the Docker DNS resolver)
27 | - `CERYX_DOCKERIZE_EXTRA_ARGS`: Extra arguments, to pass to `dockerize` (default: None)
28 | - `CERYX_MAX_REQUEST_BODY_SIZE`: The maximum body size allowed for an incoming request to Ceryx (default: `100m` — 100 megabytes)
29 | - `CERYX_REDIS_HOST`: The Redis host to use as backend (default: `127.0.0.1`)
30 | - `CERYX_REDIS_PASSWORD`: Optional password to use for authenticating with Redis (default: None)
31 | - `CERYX_REDIS_PORT`: The where Redis should be reached (default: `6379`)
32 | - `CERYX_REDIS_PREFIX`: The prefix to use in Ceryx-related Redis keys (default: `ceryx`)
33 | - `CERYX_REDIS_TIMEOUT`: The timeout for all Redis operations, including the intial connection to Redis, specified in milliseconds (default: `100`)
34 | - `CERYX_SSL_DEFAULT_CERTIFICATE`: The path to the fallback SSL certificate (default: `/etc/ceryx/ssl/default.crt` — randomly generated at build time)
35 | - `CERYX_SSL_DEFAULT_KEY`: The path to the fallback SSL certificate key (default: `/etc/ceryx/ssl/default.key` — randomly generated at build time)
36 |
37 | ## Adjusting log level
38 |
39 | Ceryx will output logs of level to equal or higher of `info` by default. Setting `CERYX_DEBUG` to `true` will also output logs of `debug` level.
40 |
41 | ### Not running Ceryx as container?
42 |
43 | 👋 **Heads up!** Ceryx is designed to be run inside a container using Docker or similar tools.
44 |
45 | If you're not running Ceryx using the official [`sourcelair/ceryx`](https://hub.docker.com/r/sourcelair/ceryx/) image, you'll need to take care of configuration file generation yourself. Take a look at [`entrypoint.sh`](ceryx/bin/entrypoint.sh) to get ideas.
46 |
47 | ### Dynamic SSL certificates
48 |
49 | By default, Ceryx will try to generate a certificate when a domain is hit via HTTPS through Let's Encrypt, if and only if a route exists for it. To disable this behavior, set `CERYX_DISABLE_LETS_ENCRYPT` to `true`.
50 |
51 | ## Quick start
52 |
53 | You can start using Ceryx in a few seconds!
54 |
55 | ### Requirements
56 |
57 | Before getting started, make sure you have the following:
58 |
59 | 1. A computer accessible from the internet with Docker ([docs](https://docs.docker.com/install/linux/docker-ce/ubuntu/)) and Docker Compose ([docs](https://docs.docker.com/compose/install/))
60 | 2. At least one domain (or subdomain) resolving to the computer's public IP addtess
61 |
62 | ### Running Ceryx
63 |
64 | Just run the following command to run Ceryx in the background:
65 |
66 | ```
67 | docker-compose up -d
68 | ```
69 |
70 | ### Running Ceryx in Kubernetes ###
71 |
72 | #### Kubernetes Requirements ####
73 | 1. A Kubernetes cluster deployed with a public facing IP. Kubectl, Helm installed on your machine. Tiller installed on the cluster.
74 |
75 | 2. At least one domain/subdomain (or even a wildcard A record) resolving to the cluster IP address.
76 |
77 | 3. Edit the values file in .k8s/ceryx/values.yaml to suit your deployment needs.
78 |
79 | 4.
80 | ```
81 | cd k8s
82 |
83 | helm install --debug --generate-name --values ./ceryx
84 |
85 | Recommend: Add --dry-run to the above before deploying to check generated yaml.
86 |
87 | ```
88 |
89 | ### Exposing the API to the public
90 |
91 | **👋 Heads up!** Don't ever do this in production! Anyone from the internet will be able to access the Ceryx API and mess with it. It's useful for development/testing though.
92 |
93 | To access (and therefore 🐶 dogfood) the Ceryx API via Ceryx' proxy, set the `CERYX_API_HOSTNAME` setting and run the following command in your terminal:
94 |
95 | ```
96 | docker-compose exec api bin/populate-api
97 | ```
98 |
99 | ## The Ceryx API
100 |
101 | ### Add a new route to Ceryx
102 |
103 | ```
104 | curl -H "Content-Type: application/json" \
105 | -X POST \
106 | -d '{"source":"publicly.accessible.domain","target":"http://service.internal:8000"}' \
107 | http://ceryx-api-host/api/routes
108 | ```
109 |
110 | ### Update a route in Ceryx
111 |
112 | ```
113 | curl -H "Content-Type: application/json" \
114 | -X PUT \
115 | -d '{"source":"publicly.accessible.domain","target":"http://another-service.internal:8000"}' \
116 | http://ceryx-api-host/api/routes/publicly.accessible.domain
117 | ```
118 |
119 | ### Delete a route from Ceryx
120 |
121 | ```
122 | curl -H "Content-Type: application/json" \
123 | -X DELETE \
124 | http://ceryx-api-host/api/routes/publicly.accessible.domain
125 | ```
126 |
127 | ### Enforce HTTPS
128 |
129 | You can enforce redirection from HTTP to HTTPS for any host you would like.
130 |
131 | ```
132 | curl -H "Content-Type: application/json" \
133 | -X POST \
134 | -d '{"source":"publicly.accessible.domain","target":"http://service.internal:8000", "settings": {"enforce_https": true}}' \
135 | http://ceryx-api-host/api/routes
136 | ```
137 |
138 | The above functionality works in `PUT` update requests as well.
139 |
140 | ### Redirect to target, instead of proxying
141 |
142 | Instead of proxying the request to the targetm you can prompt the client to redirect the request there itself.
143 |
144 | ```
145 | curl -H "Content-Type: application/json" \
146 | -X POST \
147 | -d '{"source":"sourcelair.com","target":"https://www.sourcelair.com", "settings": {"mode": "redirect"}}' \
148 | http://ceryx-api-host/api/routes
149 | ```
150 |
151 | ## Ceryx web UI
152 |
153 | The [Ceryx Web community project](https://github.com/parisk/ceryx-web) provides a sweet web UI
154 |
155 | ## Real-world uses
156 |
157 | Ceryx has proven to be extremely reliable in production systems, handling tens of thousands of routes in its backend. Some of them are:
158 |
159 | - [**SourceLair**](https://www.sourcelair.com/): In-browser IDE for web applications, made publicly accessible via development web servers powered by Ceryx.
160 | - [**Stolos**](http://stolos.io/): Managed Docker development environments for enterprises.
161 |
162 | Do you use Ceryx in production as well? Please [open a Pull Request](https://github.com/sourcelair/ceryx/pulls) to include it here. We would love to have it in our list.
163 |
164 | ## Origin
165 |
166 | Ceryx started in [SourceLair](https://www.sourcelair.com) to help provide tens of thousands of users with a unique public URL (subdomain) for each one of their projects. Initial development had different stages; from using [tproxy](https://github.com/benoitc/tproxy), [Twisted](https://www.twistedmatrix.com/trac/) and bare [NGINX](https://nginx.org/en/) as a proxy and backends ranging from [MongoDB](https://www.mongodb.com/) to [etcd](https://github.com/etcd-io/etcd).
167 |
168 | After a lot of experimentation, we have ended up in using [OpenResty](https://openresty.org/en/) as the proxy and [Redis](https://redis.io/) as the backend. This solution has served us and we are now developing it in the open as an open source project.
169 |
170 | ## License
171 |
172 | Ceryx is [MIT licensed](LICENSE).
173 |
--------------------------------------------------------------------------------
/api/Pipfile.lock:
--------------------------------------------------------------------------------
1 | {
2 | "_meta": {
3 | "hash": {
4 | "sha256": "79eaad70504a24b4d81cc5b1479a8c3b77104b140781e05dfeb83608a6fc7fa7"
5 | },
6 | "pipfile-spec": 6,
7 | "requires": {
8 | "python_version": "3.6"
9 | },
10 | "sources": [
11 | {
12 | "name": "pypi",
13 | "url": "https://pypi.python.org/simple",
14 | "verify_ssl": true
15 | }
16 | ]
17 | },
18 | "default": {
19 | "aiofiles": {
20 | "hashes": [
21 | "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937",
22 | "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"
23 | ],
24 | "markers": "python_version >= '3.6' and python_version < '4'",
25 | "version": "==0.8.0"
26 | },
27 | "aniso8601": {
28 | "hashes": [
29 | "sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e",
30 | "sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b"
31 | ],
32 | "version": "==7.0.0"
33 | },
34 | "apispec": {
35 | "hashes": [
36 | "sha256:5bc5404b19259aeeb307ce9956e2c1a97722c6a130ef414671dfc21acd622afc",
37 | "sha256:d167890e37f14f3f26b588ff2598af35faa5c27612264ea1125509c8ff860834"
38 | ],
39 | "markers": "python_version >= '3.6'",
40 | "version": "==5.1.1"
41 | },
42 | "apistar": {
43 | "hashes": [
44 | "sha256:8da0d3f15748c8ed6e68914ba5b8f6dd5dff5afbe137950d07103575df0bce73"
45 | ],
46 | "markers": "python_version >= '3.6'",
47 | "version": "==0.7.2"
48 | },
49 | "asgiref": {
50 | "hashes": [
51 | "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9",
52 | "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"
53 | ],
54 | "markers": "python_version >= '3.6'",
55 | "version": "==3.4.1"
56 | },
57 | "certifi": {
58 | "hashes": [
59 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3",
60 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"
61 | ],
62 | "index": "pypi",
63 | "version": "==2022.12.7"
64 | },
65 | "chardet": {
66 | "hashes": [
67 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
68 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
69 | ],
70 | "version": "==3.0.4"
71 | },
72 | "click": {
73 | "hashes": [
74 | "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1",
75 | "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"
76 | ],
77 | "markers": "python_version >= '3.6'",
78 | "version": "==8.0.4"
79 | },
80 | "dataclasses": {
81 | "hashes": [
82 | "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf",
83 | "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"
84 | ],
85 | "markers": "python_version < '3.7'",
86 | "version": "==0.8"
87 | },
88 | "docopt": {
89 | "hashes": [
90 | "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
91 | ],
92 | "version": "==0.6.2"
93 | },
94 | "graphene": {
95 | "hashes": [
96 | "sha256:3d446eb1237c551052bc31155cf1a3a607053e4f58c9172b83a1b597beaa0868",
97 | "sha256:b9f2850e064eebfee9a3ef4a1f8aa0742848d97652173ab44c82cc8a62b9ed93"
98 | ],
99 | "version": "==2.1.9"
100 | },
101 | "graphql-core": {
102 | "hashes": [
103 | "sha256:44c9bac4514e5e30c5a595fac8e3c76c1975cae14db215e8174c7fe995825bad",
104 | "sha256:aac46a9ac524c9855910c14c48fc5d60474def7f99fd10245e76608eba7af746"
105 | ],
106 | "version": "==2.3.2"
107 | },
108 | "graphql-relay": {
109 | "hashes": [
110 | "sha256:870b6b5304123a38a0b215a79eace021acce5a466bf40cd39fa18cb8528afabb",
111 | "sha256:ac514cb86db9a43014d7e73511d521137ac12cf0101b2eaa5f0a3da2e10d913d"
112 | ],
113 | "version": "==2.0.1"
114 | },
115 | "graphql-server-core": {
116 | "hashes": [
117 | "sha256:11fa8a434e1cd05d29709af29414b8b6f596925d26afe39eff33bd24a5f93605"
118 | ],
119 | "version": "==2.0.0"
120 | },
121 | "h11": {
122 | "hashes": [
123 | "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06",
124 | "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"
125 | ],
126 | "markers": "python_version >= '3.6'",
127 | "version": "==0.13.0"
128 | },
129 | "idna": {
130 | "hashes": [
131 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
132 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
133 | ],
134 | "version": "==2.8"
135 | },
136 | "importlib-metadata": {
137 | "hashes": [
138 | "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e",
139 | "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"
140 | ],
141 | "markers": "python_version < '3.8'",
142 | "version": "==4.8.3"
143 | },
144 | "itsdangerous": {
145 | "hashes": [
146 | "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c",
147 | "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"
148 | ],
149 | "markers": "python_version >= '3.6'",
150 | "version": "==2.0.1"
151 | },
152 | "jinja2": {
153 | "hashes": [
154 | "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8",
155 | "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"
156 | ],
157 | "markers": "python_version >= '3.6'",
158 | "version": "==3.0.3"
159 | },
160 | "markupsafe": {
161 | "hashes": [
162 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
163 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
164 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
165 | "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194",
166 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
167 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
168 | "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724",
169 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
170 | "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646",
171 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
172 | "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6",
173 | "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a",
174 | "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6",
175 | "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad",
176 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
177 | "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38",
178 | "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac",
179 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
180 | "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6",
181 | "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047",
182 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
183 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
184 | "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b",
185 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
186 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
187 | "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a",
188 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
189 | "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1",
190 | "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9",
191 | "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864",
192 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
193 | "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee",
194 | "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f",
195 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
196 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
197 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
198 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
199 | "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b",
200 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
201 | "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86",
202 | "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6",
203 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
204 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
205 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
206 | "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28",
207 | "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e",
208 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
209 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
210 | "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f",
211 | "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d",
212 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
213 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
214 | "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145",
215 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
216 | "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c",
217 | "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1",
218 | "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a",
219 | "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207",
220 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
221 | "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53",
222 | "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd",
223 | "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134",
224 | "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85",
225 | "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9",
226 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
227 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
228 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
229 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
230 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
231 | ],
232 | "markers": "python_version >= '3.6'",
233 | "version": "==2.0.1"
234 | },
235 | "marshmallow": {
236 | "hashes": [
237 | "sha256:04438610bc6dadbdddb22a4a55bcc7f6f8099e69580b2e67f5a681933a1f4400",
238 | "sha256:4c05c1684e0e97fe779c62b91878f173b937fe097b356cd82f793464f5bc6138"
239 | ],
240 | "markers": "python_version >= '3.6'",
241 | "version": "==3.14.1"
242 | },
243 | "parse": {
244 | "hashes": [
245 | "sha256:9ff82852bcb65d139813e2a5197627a94966245c897796760a3a2a8eb66f020b"
246 | ],
247 | "version": "==1.19.0"
248 | },
249 | "promise": {
250 | "hashes": [
251 | "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"
252 | ],
253 | "version": "==2.3"
254 | },
255 | "python-multipart": {
256 | "hashes": [
257 | "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"
258 | ],
259 | "version": "==0.0.5"
260 | },
261 | "pyyaml": {
262 | "hashes": [
263 | "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf",
264 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
265 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
266 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
267 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
268 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
269 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
270 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
271 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
272 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
273 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
274 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
275 | "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782",
276 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
277 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
278 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
279 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
280 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
281 | "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1",
282 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
283 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
284 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
285 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
286 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
287 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
288 | "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d",
289 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
290 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
291 | "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7",
292 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
293 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
294 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
295 | "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358",
296 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
297 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
298 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
299 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
300 | "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f",
301 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
302 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
303 | ],
304 | "markers": "python_version >= '3.6'",
305 | "version": "==6.0"
306 | },
307 | "redis": {
308 | "hashes": [
309 | "sha256:6946b5dca72e86103edc8033019cc3814c031232d339d5f4533b02ea85685175",
310 | "sha256:8ca418d2ddca1b1a850afa1680a7d2fd1f3322739271de4b704e0d4668449273"
311 | ],
312 | "index": "pypi",
313 | "version": "==3.2.1"
314 | },
315 | "requests": {
316 | "hashes": [
317 | "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
318 | "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
319 | ],
320 | "index": "pypi",
321 | "version": "==2.21.0"
322 | },
323 | "requests-toolbelt": {
324 | "hashes": [
325 | "sha256:18565aa58116d9951ac39baa288d3adb5b3ff975c4f25eee78555d89e8f247f7",
326 | "sha256:62e09f7ff5ccbda92772a29f394a49c3ad6cb181d568b1337626b2abb628a63d"
327 | ],
328 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
329 | "version": "==0.10.1"
330 | },
331 | "responder": {
332 | "hashes": [
333 | "sha256:8418d015874ad82ddb2da31c4fe82ca42a7d62462325097d79ceb907c0622e02",
334 | "sha256:a18454d517551d2788acbac2557948ea6729d0c837a676e3ff7a57863190743d"
335 | ],
336 | "index": "pypi",
337 | "version": "==1.3.0"
338 | },
339 | "rfc3986": {
340 | "hashes": [
341 | "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
342 | "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
343 | ],
344 | "version": "==1.5.0"
345 | },
346 | "rx": {
347 | "hashes": [
348 | "sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23",
349 | "sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105"
350 | ],
351 | "version": "==1.6.1"
352 | },
353 | "six": {
354 | "hashes": [
355 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
356 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
357 | ],
358 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
359 | "version": "==1.16.0"
360 | },
361 | "starlette": {
362 | "hashes": [
363 | "sha256:8bc2e41f7638290379ae91450413796f92d6c97b88a6b754f3c1a7f8bc7a07d6"
364 | ],
365 | "markers": "python_version >= '3.6'",
366 | "version": "==0.10.7"
367 | },
368 | "typesystem": {
369 | "hashes": [
370 | "sha256:aa01ac52370a7e5996960c8a899da0f939753bc49d405e92dea5cb1f6bc3700a"
371 | ],
372 | "index": "pypi",
373 | "version": "==0.2.2"
374 | },
375 | "typing-extensions": {
376 | "hashes": [
377 | "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42",
378 | "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"
379 | ],
380 | "markers": "python_version < '3.8'",
381 | "version": "==4.1.1"
382 | },
383 | "urllib3": {
384 | "hashes": [
385 | "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
386 | "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
387 | ],
388 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'",
389 | "version": "==1.24.3"
390 | },
391 | "uvicorn": {
392 | "hashes": [
393 | "sha256:d8c839231f270adaa6d338d525e2652a0b4a5f4c2430b5c4ef6ae4d11776b0d2",
394 | "sha256:eacb66afa65e0648fcbce5e746b135d09722231ffffc61883d4fac2b62fbea8d"
395 | ],
396 | "version": "==0.16.0"
397 | },
398 | "uvloop": {
399 | "hashes": [
400 | "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd",
401 | "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e",
402 | "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09",
403 | "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726",
404 | "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891",
405 | "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7",
406 | "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5",
407 | "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95",
408 | "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"
409 | ],
410 | "markers": "sys_platform != 'win32'",
411 | "version": "==0.14.0"
412 | },
413 | "whitenoise": {
414 | "hashes": [
415 | "sha256:d234b871b52271ae7ed6d9da47ffe857c76568f11dd30e28e18c5869dbd11e12",
416 | "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c"
417 | ],
418 | "markers": "python_version >= '3.5' and python_version < '4'",
419 | "version": "==5.3.0"
420 | },
421 | "zipp": {
422 | "hashes": [
423 | "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832",
424 | "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"
425 | ],
426 | "markers": "python_version >= '3.6'",
427 | "version": "==3.6.0"
428 | }
429 | },
430 | "develop": {
431 | "appdirs": {
432 | "hashes": [
433 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
434 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
435 | ],
436 | "version": "==1.4.4"
437 | },
438 | "atomicwrites": {
439 | "hashes": [
440 | "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"
441 | ],
442 | "version": "==1.4.1"
443 | },
444 | "attrs": {
445 | "hashes": [
446 | "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6",
447 | "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"
448 | ],
449 | "markers": "python_version >= '3.5'",
450 | "version": "==22.1.0"
451 | },
452 | "black": {
453 | "hashes": [
454 | "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739",
455 | "sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"
456 | ],
457 | "index": "pypi",
458 | "version": "==18.9b0"
459 | },
460 | "click": {
461 | "hashes": [
462 | "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1",
463 | "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"
464 | ],
465 | "markers": "python_version >= '3.6'",
466 | "version": "==8.0.4"
467 | },
468 | "importlib-metadata": {
469 | "hashes": [
470 | "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e",
471 | "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"
472 | ],
473 | "markers": "python_version < '3.8'",
474 | "version": "==4.8.3"
475 | },
476 | "more-itertools": {
477 | "hashes": [
478 | "sha256:1bc4f91ee5b1b31ac7ceacc17c09befe6a40a503907baf9c839c229b5095cfd2",
479 | "sha256:c09443cd3d5438b8dafccd867a6bc1cb0894389e90cb53d227456b0b0bccb750"
480 | ],
481 | "markers": "python_version > '2.7'",
482 | "version": "==8.14.0"
483 | },
484 | "mypy": {
485 | "hashes": [
486 | "sha256:2afe51527b1f6cdc4a5f34fc90473109b22bf7f21086ba3e9451857cf11489e6",
487 | "sha256:56a16df3e0abb145d8accd5dbb70eba6c4bd26e2f89042b491faa78c9635d1e2",
488 | "sha256:5764f10d27b2e93c84f70af5778941b8f4aa1379b2430f85c827e0f5464e8714",
489 | "sha256:5bbc86374f04a3aa817622f98e40375ccb28c4836f36b66706cf3c6ccce86eda",
490 | "sha256:6a9343089f6377e71e20ca734cd8e7ac25d36478a9df580efabfe9059819bf82",
491 | "sha256:6c9851bc4a23dc1d854d3f5dfd5f20a016f8da86bcdbb42687879bb5f86434b0",
492 | "sha256:b8e85956af3fcf043d6f87c91cbe8705073fc67029ba6e22d3468bfee42c4823",
493 | "sha256:b9a0af8fae490306bc112229000aa0c2ccc837b49d29a5c42e088c132a2334dd",
494 | "sha256:bbf643528e2a55df2c1587008d6e3bda5c0445f1240dfa85129af22ae16d7a9a",
495 | "sha256:c46ab3438bd21511db0f2c612d89d8344154c0c9494afc7fbc932de514cf8d15",
496 | "sha256:f7a83d6bd805855ef83ec605eb01ab4fa42bcef254b13631e451cbb44914a9b0"
497 | ],
498 | "index": "pypi",
499 | "version": "==0.701"
500 | },
501 | "mypy-extensions": {
502 | "hashes": [
503 | "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
504 | "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
505 | ],
506 | "version": "==0.4.3"
507 | },
508 | "nose": {
509 | "hashes": [
510 | "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac",
511 | "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a",
512 | "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"
513 | ],
514 | "index": "pypi",
515 | "version": "==1.3.7"
516 | },
517 | "pluggy": {
518 | "hashes": [
519 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
520 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
521 | ],
522 | "markers": "python_version >= '3.6'",
523 | "version": "==1.0.0"
524 | },
525 | "py": {
526 | "hashes": [
527 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719",
528 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"
529 | ],
530 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
531 | "version": "==1.11.0"
532 | },
533 | "pytest": {
534 | "hashes": [
535 | "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d",
536 | "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5"
537 | ],
538 | "index": "pypi",
539 | "version": "==4.4.1"
540 | },
541 | "setuptools": {
542 | "hashes": [
543 | "sha256:22c7348c6d2976a52632c67f7ab0cdf40147db7789f9aed18734643fe9cf3373",
544 | "sha256:4ce92f1e1f8f01233ee9952c04f6b81d1e02939d6e1b488428154974a4d0783e"
545 | ],
546 | "markers": "python_version >= '3.6'",
547 | "version": "==59.6.0"
548 | },
549 | "six": {
550 | "hashes": [
551 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
552 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
553 | ],
554 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
555 | "version": "==1.16.0"
556 | },
557 | "toml": {
558 | "hashes": [
559 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
560 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
561 | ],
562 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
563 | "version": "==0.10.2"
564 | },
565 | "typed-ast": {
566 | "hashes": [
567 | "sha256:132eae51d6ef3ff4a8c47c393a4ef5ebf0d1aecc96880eb5d6c8ceab7017cc9b",
568 | "sha256:18141c1484ab8784006c839be8b985cfc82a2e9725837b0ecfa0203f71c4e39d",
569 | "sha256:2baf617f5bbbfe73fd8846463f5aeafc912b5ee247f410700245d68525ec584a",
570 | "sha256:3d90063f2cbbe39177e9b4d888e45777012652d6110156845b828908c51ae462",
571 | "sha256:4304b2218b842d610aa1a1d87e1dc9559597969acc62ce717ee4dfeaa44d7eee",
572 | "sha256:4983ede548ffc3541bae49a82675996497348e55bafd1554dc4e4a5d6eda541a",
573 | "sha256:5315f4509c1476718a4825f45a203b82d7fdf2a6f5f0c8f166435975b1c9f7d4",
574 | "sha256:6cdfb1b49d5345f7c2b90d638822d16ba62dc82f7616e9b4caa10b72f3f16649",
575 | "sha256:7b325f12635598c604690efd7a0197d0b94b7d7778498e76e0710cd582fd1c7a",
576 | "sha256:8d3b0e3b8626615826f9a626548057c5275a9733512b137984a68ba1598d3d2f",
577 | "sha256:8f8631160c79f53081bd23446525db0bc4c5616f78d04021e6e434b286493fd7",
578 | "sha256:912de10965f3dc89da23936f1cc4ed60764f712e5fa603a09dd904f88c996760",
579 | "sha256:b010c07b975fe853c65d7bbe9d4ac62f1c69086750a574f6292597763781ba18",
580 | "sha256:c908c10505904c48081a5415a1e295d8403e353e0c14c42b6d67f8f97fae6616",
581 | "sha256:c94dd3807c0c0610f7c76f078119f4ea48235a953512752b9175f9f98f5ae2bd",
582 | "sha256:ce65dee7594a84c466e79d7fb7d3303e7295d16a83c22c7c4037071b059e2c21",
583 | "sha256:eaa9cfcb221a8a4c2889be6f93da141ac777eb8819f077e1d09fb12d00a09a93",
584 | "sha256:f3376bc31bad66d46d44b4e6522c5c21976bf9bca4ef5987bb2bf727f4506cbb",
585 | "sha256:f9202fa138544e13a4ec1a6792c35834250a85958fde1251b6a22e07d1260ae7"
586 | ],
587 | "version": "==1.3.5"
588 | },
589 | "typing-extensions": {
590 | "hashes": [
591 | "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42",
592 | "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"
593 | ],
594 | "markers": "python_version < '3.8'",
595 | "version": "==4.1.1"
596 | },
597 | "zipp": {
598 | "hashes": [
599 | "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832",
600 | "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"
601 | ],
602 | "markers": "python_version >= '3.6'",
603 | "version": "==3.6.0"
604 | }
605 | }
606 | }
607 |
--------------------------------------------------------------------------------