├── 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 | [![Build Status](https://travis-ci.org/sourcelair/ceryx.svg)](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 | --------------------------------------------------------------------------------