diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 0000000..9c78a0b --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,30 @@ +name: Run tests +on: + push: + tags: + - "*.*.*" + branches: + - main + pull_request: + +jobs: + tox: + name: "tox" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: '3.11' + architecture: 'x64' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install tox wheel flake8 build + + - name: Run tests + run: tox diff --git a/fixca/__main__.py b/fixca/__main__.py index b61e106..8041b45 100644 --- a/fixca/__main__.py +++ b/fixca/__main__.py @@ -1,6 +1,7 @@ import os import sys import resotolib.proc +from signal import SIGTERM from tempfile import TemporaryDirectory from resotolib.logger import log, setup_logger, add_args as logging_add_args from resotolib.web import WebServer @@ -9,19 +10,20 @@ from .args import parse_args from .ca import CA, WebApp, CaApp from threading import Event +from typing import Any shutdown_event = Event() -def shutdown(event) -> None: +def shutdown(even: Any) -> None: log.info("Shutting down") shutdown_event.set() def main() -> None: setup_logger("fixca") - args = parse_args([logging_add_args]) + args = parse_args([logging_add_args]) # type: ignore log.info(f"Starting FIX CA on port {args.port}") resotolib.proc.initializer() resotolib.proc.parent_pid = os.getpid() @@ -63,7 +65,7 @@ def main() -> None: shutdown_event.wait() web_server.shutdown() - resotolib.proc.kill_children(resotolib.proc.SIGTERM, ensure_death=True) + resotolib.proc.kill_children(SIGTERM, ensure_death=True) log.info("Shutdown complete") sys.exit(0) diff --git a/fixca/args.py b/fixca/args.py index 9220a98..5eb61f7 100644 --- a/fixca/args.py +++ b/fixca/args.py @@ -1,9 +1,10 @@ import os from argparse import ArgumentParser, Namespace -from typing import Callable, List +from resotolib.args import ArgumentParser as ResotoArgumentParser +from typing import Callable, List, Union -def parse_args(add_args: List[Callable]) -> Namespace: +def parse_args(add_args: List[Callable[[ArgumentParser], None]]) -> Namespace: parser = ArgumentParser(prog="fixca", description="FIX Certification Authority") parser.add_argument("--psk", dest="psk", help="Pre-shared-key", default=os.environ.get("FIXCA_PSK")) parser.add_argument( diff --git a/fixca/ca.py b/fixca/ca.py index 06ff19b..5fc7194 100644 --- a/fixca/ca.py +++ b/fixca/ca.py @@ -3,7 +3,7 @@ from functools import wraps from prometheus_client.exposition import generate_latest, CONTENT_TYPE_LATEST from typing import Optional, Dict, Callable, Tuple, Union, Any, List -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.x509.base import Certificate, CertificateSigningRequest from resotolib.logger import log from resotolib.x509 import ( @@ -25,10 +25,10 @@ class CertificateAuthority: - def __init__(self): - self.cert = None - self.__key = None - self.__initialized = False + def __init__(self) -> None: + self.cert: Optional[Certificate] = None + self.__key: Optional[RSAPrivateKey] = None + self.__initialized: bool = False @staticmethod def requires_initialized(func: Callable[..., Any]) -> Callable[..., Any]: @@ -42,6 +42,7 @@ def wrapper(ca_instance: "CertificateAuthority", *args: Any, **kwargs: Any) -> A @requires_initialized def sign(self, csr: CertificateSigningRequest) -> Certificate: + assert self.__key is not None and self.cert is not None return sign_csr(csr, self.__key, self.cert) def initialize(self, namespace: str = "cert-manager", secret_name: str = "fix-ca", dummy_ca: bool = False) -> None: @@ -62,7 +63,7 @@ def __load_ca_data( log.info("Loading CA data") ca_secret = get_secret(namespace=namespace, secret_name=secret_name) - if isinstance(ca_secret, dict) and (not "tls.key" in ca_secret or not "tls.crt" in ca_secret): + if isinstance(ca_secret, dict) and ("tls.key" not in ca_secret or "tls.crt" not in ca_secret): ca_secret = None log.error("CA secret is missing key or cert") @@ -126,6 +127,7 @@ def store_secret( include_ca_bundle: bool = False, ) -> None: log.info(f"Storing certificate {cert_crt.subject.rfc4514_string()} in {namespace}/{secret_name}") + assert self.cert is not None secret = { key_cert: cert_to_bytes(cert_crt).decode("utf-8"), key_key: key_to_bytes(cert_key).decode("utf-8"), @@ -143,10 +145,10 @@ def store_secret( CA: CertificateAuthority = CertificateAuthority() -PSK: Optional[Union[str, Certificate, RSAPublicKey]] = None +PSK: Optional[str] = None -def jwt_check(): +def jwt_check() -> None: headers = cherrypy.request.headers assert PSK is not None @@ -182,8 +184,8 @@ def __init__( if self.mountpoint not in ("/", ""): self.config[self.mountpoint] = config - @cherrypy.expose - @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.expose # type: ignore + @cherrypy.tools.allow(methods=["GET"]) # type: ignore def health(self) -> str: cherrypy.response.headers["Content-Type"] = "text/plain" unhealthy = [f"- {name}" for name, fn in self.health_conditions.items() if not fn()] @@ -195,37 +197,37 @@ def health(self) -> str: cherrypy.response.headers["Content-Type"] = "text/plain" return "not ok\r\n\r\n" + "\r\n".join(unhealthy) + "\r\n" - @cherrypy.expose - @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.expose # type: ignore + @cherrypy.tools.allow(methods=["GET"]) # type: ignore def metrics(self) -> bytes: cherrypy.response.headers["Content-Type"] = CONTENT_TYPE_LATEST return generate_latest() class CaApp: - def __init__(self, ca: CertificateAuthority, psk_or_cert: Union[str, Certificate, RSAPublicKey]) -> None: + def __init__(self, ca: CertificateAuthority, psk: str) -> None: global PSK self.ca = ca - self.psk_or_cert = psk_or_cert + self.psk = psk self.config = {"/": {"tools.gzip.on": False}} - PSK = self.psk_or_cert + PSK = self.psk - @cherrypy.expose - @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.expose # type: ignore + @cherrypy.tools.allow(methods=["GET"]) # type: ignore def cert(self) -> bytes: - assert self.psk_or_cert is not None + assert self.psk is not None and self.ca.cert is not None fingerprint = cert_fingerprint(self.ca.cert) cherrypy.response.headers["Content-Type"] = "application/x-pem-file" cherrypy.response.headers["SHA256-Fingerprint"] = fingerprint cherrypy.response.headers["Content-Disposition"] = 'attachment; filename="fix_root_ca.pem"' cherrypy.response.headers["Authorization"] = "Bearer " + encode_jwt( - {"sha256_fingerprint": fingerprint}, self.psk_or_cert + {"sha256_fingerprint": fingerprint}, self.psk ) return cert_to_bytes(self.ca.cert) - @cherrypy.expose - @cherrypy.tools.allow(methods=["POST"]) - @cherrypy.tools.jwt_check() + @cherrypy.expose # type: ignore + @cherrypy.tools.allow(methods=["POST"]) # type: ignore + @cherrypy.tools.jwt_check() # type: ignore def sign(self) -> bytes: try: csr = load_csr_from_bytes(cherrypy.request.body.read()) @@ -242,13 +244,14 @@ def sign(self) -> bytes: cherrypy.response.headers["Content-Disposition"] = f'attachment; filename="{filename}"' return cert_to_bytes(crt) - @cherrypy.expose - @cherrypy.tools.json_out() - @cherrypy.tools.json_in() - @cherrypy.tools.allow(methods=["POST"]) - @cherrypy.tools.jwt_check() - def generate(self) -> bytes: + @cherrypy.expose # type: ignore + @cherrypy.tools.json_out() # type: ignore + @cherrypy.tools.json_in() # type: ignore + @cherrypy.tools.allow(methods=["POST"]) # type: ignore + @cherrypy.tools.jwt_check() # type: ignore + def generate(self) -> Dict[str, Any]: try: + assert self.ca.cert is not None request_json = cherrypy.request.json remote_addr = cherrypy.request.remote.ip include_ca_cert = str_to_bool(request_json.get("include_ca_cert", False)) diff --git a/fixca/utils.py b/fixca/utils.py index 7518325..54b5f38 100644 --- a/fixca/utils.py +++ b/fixca/utils.py @@ -1,24 +1,24 @@ import time from functools import wraps -from typing import Callable, Any, Tuple, Dict, Union, TypeVar +from typing import Callable, Any, Tuple, Dict, Union def str_to_bool(s: Union[str, bool]) -> bool: return str(s).lower() in ("true", "1", "yes") -RT = TypeVar("RT") +def memoize( + ttl: int = 60, + cleanup_interval: int = 600, + time_fn: Callable[[], float] = time.time, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + last_cleanup: float = 0.0 + cache: Dict[Tuple[Callable[..., Any], Tuple[Any, ...], frozenset[Tuple[str, Any]]], Tuple[Any, float]] = {} - -def memoize(ttl: int = 60, cleanup_interval: int = 600) -> Callable: - state = {"last_cleanup": 0} - cache: Dict[Tuple[Callable, Tuple, frozenset], Tuple[RT, float]] = {} - - def decorating_function(user_function: Callable[..., RT]) -> Callable[..., RT]: + def decorating_function(user_function: Callable[..., Any]) -> Callable[..., Any]: @wraps(user_function) - def wrapper(*args: Any, **kwargs: Any) -> RT: - nonlocal cache - now = time.time() + def wrapper(*args: Any, **kwargs: Any) -> Any: + now = time_fn() key = (user_function, args, frozenset(kwargs.items())) if key in cache: result, timestamp = cache[key] @@ -28,11 +28,11 @@ def wrapper(*args: Any, **kwargs: Any) -> RT: result = user_function(*args, **kwargs) cache[key] = (result, now) - nonlocal state - if now - state["last_cleanup"] > cleanup_interval: + nonlocal last_cleanup + if now - last_cleanup > cleanup_interval: for k in [k for k, v in cache.items() if now - v[1] >= ttl]: cache.pop(k) - state["last_cleanup"] = now + last_cleanup = now return result diff --git a/genreq.sh b/genreq.sh new file mode 100755 index 0000000..fcc2576 --- /dev/null +++ b/genreq.sh @@ -0,0 +1,4 @@ +#!/bin/bash +pip-compile --resolver=backtracking --upgrade --allow-unsafe --no-header --unsafe-package n/a --output-file requirements.txt +pip-compile --extra test --resolver=backtracking --upgrade --allow-unsafe --no-header --unsafe-package n/a --output-file requirements-test.txt + diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..f27567c --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,265 @@ +aiodns==3.0.0 + # via aiohttp +aiohttp[speedups]==3.8.5 + # via resotolib +aiosignal==1.3.1 + # via aiohttp +annotated-types==0.5.0 + # via pydantic +astroid==3.0.0 + # via pylint +async-timeout==4.0.3 + # via aiohttp +attrs==23.1.0 + # via + # aiohttp + # cattrs + # hypothesis + # resotolib +autocommand==2.2.2 + # via jaraco-text +black==23.9.1 + # via fixca (pyproject.toml) +brotli==1.1.0 + # via aiohttp +cachetools==5.3.1 + # via + # google-auth + # tox +cattrs==23.1.2 + # via resotolib +certifi==2023.7.22 + # via + # kubernetes + # requests +cffi==1.16.0 + # via + # cryptography + # pycares +chardet==5.2.0 + # via tox +charset-normalizer==3.3.0 + # via + # aiohttp + # requests +cheroot==10.0.0 + # via cherrypy +cherrypy==18.8.0 + # via resotolib +click==8.1.7 + # via black +colorama==0.4.6 + # via tox +coverage[toml]==7.3.2 + # via + # fixca (pyproject.toml) + # pytest-cov +cryptography==41.0.4 + # via + # fixca (pyproject.toml) + # resotolib +dill==0.3.7 + # via pylint +distlib==0.3.7 + # via virtualenv +filelock==3.12.4 + # via + # tox + # virtualenv +flake8==6.1.0 + # via + # fixca (pyproject.toml) + # pep8-naming +frozendict==2.3.8 + # via resotolib +frozenlist==1.4.0 + # via + # aiohttp + # aiosignal +google-auth==2.23.2 + # via kubernetes +hypothesis==6.87.1 + # via fixca (pyproject.toml) +idna==3.4 + # via + # requests + # yarl +inflect==7.0.0 + # via jaraco-text +iniconfig==2.0.0 + # via pytest +isort==5.12.0 + # via pylint +jaraco-collections==4.3.0 + # via cherrypy +jaraco-context==4.3.0 + # via jaraco-text +jaraco-functools==3.9.0 + # via + # cheroot + # jaraco-text + # tempora +jaraco-text==3.11.1 + # via jaraco-collections +jsons==1.6.3 + # via resotolib +kubernetes==28.1.0 + # via fixca (pyproject.toml) +mccabe==0.7.0 + # via + # flake8 + # pylint +more-itertools==10.1.0 + # via + # cheroot + # cherrypy + # jaraco-functools + # jaraco-text +multidict==6.0.4 + # via + # aiohttp + # yarl +mypy==1.5.1 + # via fixca (pyproject.toml) +mypy-extensions==1.0.0 + # via + # black + # mypy +networkx==3.1 + # via resotolib +oauthlib==3.2.2 + # via + # kubernetes + # requests-oauthlib +packaging==23.2 + # via + # black + # pyproject-api + # pytest + # tox +parsy==2.1 + # via resotolib +pathspec==0.11.2 + # via black +pep8-naming==0.13.3 + # via fixca (pyproject.toml) +pint==0.22 + # via resotolib +platformdirs==3.11.0 + # via + # black + # pylint + # tox + # virtualenv +pluggy==1.3.0 + # via + # pytest + # tox +portend==3.2.0 + # via cherrypy +prometheus-client==0.17.1 + # via resotolib +psutil==5.9.5 + # via resotolib +pyasn1==0.5.0 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via google-auth +pycares==4.3.0 + # via aiodns +pycodestyle==2.11.0 + # via flake8 +pycparser==2.21 + # via cffi +pydantic==2.4.2 + # via inflect +pydantic-core==2.10.1 + # via pydantic +pyflakes==3.1.0 + # via flake8 +pyjwt==2.8.0 + # via resotolib +pylint==3.0.0 + # via fixca (pyproject.toml) +pyproject-api==1.6.1 + # via tox +pytest==7.4.2 + # via + # fixca (pyproject.toml) + # pytest-asyncio + # pytest-cov +pytest-asyncio==0.21.1 + # via fixca (pyproject.toml) +pytest-cov==4.1.0 + # via fixca (pyproject.toml) +pytest-runner==6.0.0 + # via fixca (pyproject.toml) +python-dateutil==2.8.2 + # via + # kubernetes + # resotolib +pytz==2023.3.post1 + # via tempora +pyyaml==6.0.1 + # via + # kubernetes + # resotolib +requests==2.31.0 + # via + # kubernetes + # requests-oauthlib + # resotolib +requests-oauthlib==1.3.1 + # via kubernetes +resotolib==3.8.0 + # via fixca (pyproject.toml) +rsa==4.9 + # via google-auth +setuptools==68.2.2 + # via zc-lockfile +six==1.16.0 + # via + # kubernetes + # python-dateutil +sortedcontainers==2.4.0 + # via hypothesis +tempora==5.5.0 + # via portend +tomlkit==0.12.1 + # via pylint +tox==4.11.3 + # via fixca (pyproject.toml) +typeguard==4.1.5 + # via resotolib +typing-extensions==4.8.0 + # via + # inflect + # mypy + # pint + # pydantic + # pydantic-core + # typeguard +typish==1.9.3 + # via jsons +tzdata==2023.3 + # via resotolib +tzlocal==5.0.1 + # via resotolib +urllib3==1.26.17 + # via + # kubernetes + # requests +virtualenv==20.24.5 + # via tox +websocket-client==1.6.3 + # via + # kubernetes + # resotolib +wheel==0.41.2 + # via fixca (pyproject.toml) +yarl==1.9.2 + # via aiohttp +zc-lockfile==3.0.post1 + # via cherrypy diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d4e19f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,170 @@ +aiodns==3.0.0 + # via aiohttp +aiohttp[speedups]==3.8.5 + # via resotolib +aiosignal==1.3.1 + # via aiohttp +annotated-types==0.5.0 + # via pydantic +async-timeout==4.0.3 + # via aiohttp +attrs==23.1.0 + # via + # aiohttp + # cattrs + # resotolib +autocommand==2.2.2 + # via jaraco-text +brotli==1.1.0 + # via aiohttp +cachetools==5.3.1 + # via google-auth +cattrs==23.1.2 + # via resotolib +certifi==2023.7.22 + # via + # kubernetes + # requests +cffi==1.16.0 + # via + # cryptography + # pycares +charset-normalizer==3.3.0 + # via + # aiohttp + # requests +cheroot==10.0.0 + # via cherrypy +cherrypy==18.8.0 + # via resotolib +cryptography==41.0.4 + # via + # fixca (pyproject.toml) + # resotolib +frozendict==2.3.8 + # via resotolib +frozenlist==1.4.0 + # via + # aiohttp + # aiosignal +google-auth==2.23.2 + # via kubernetes +idna==3.4 + # via + # requests + # yarl +inflect==7.0.0 + # via jaraco-text +jaraco-collections==4.3.0 + # via cherrypy +jaraco-context==4.3.0 + # via jaraco-text +jaraco-functools==3.9.0 + # via + # cheroot + # jaraco-text + # tempora +jaraco-text==3.11.1 + # via jaraco-collections +jsons==1.6.3 + # via resotolib +kubernetes==28.1.0 + # via fixca (pyproject.toml) +more-itertools==10.1.0 + # via + # cheroot + # cherrypy + # jaraco-functools + # jaraco-text +multidict==6.0.4 + # via + # aiohttp + # yarl +networkx==3.1 + # via resotolib +oauthlib==3.2.2 + # via + # kubernetes + # requests-oauthlib +parsy==2.1 + # via resotolib +pint==0.22 + # via resotolib +portend==3.2.0 + # via cherrypy +prometheus-client==0.17.1 + # via resotolib +psutil==5.9.5 + # via resotolib +pyasn1==0.5.0 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via google-auth +pycares==4.3.0 + # via aiodns +pycparser==2.21 + # via cffi +pydantic==2.4.2 + # via inflect +pydantic-core==2.10.1 + # via pydantic +pyjwt==2.8.0 + # via resotolib +python-dateutil==2.8.2 + # via + # kubernetes + # resotolib +pytz==2023.3.post1 + # via tempora +pyyaml==6.0.1 + # via + # kubernetes + # resotolib +requests==2.31.0 + # via + # kubernetes + # requests-oauthlib + # resotolib +requests-oauthlib==1.3.1 + # via kubernetes +resotolib==3.8.0 + # via fixca (pyproject.toml) +rsa==4.9 + # via google-auth +setuptools==68.2.2 + # via zc-lockfile +six==1.16.0 + # via + # kubernetes + # python-dateutil +tempora==5.5.0 + # via portend +typeguard==4.1.5 + # via resotolib +typing-extensions==4.8.0 + # via + # inflect + # pint + # pydantic + # pydantic-core + # typeguard +typish==1.9.3 + # via jsons +tzdata==2023.3 + # via resotolib +tzlocal==5.0.1 + # via resotolib +urllib3==1.26.17 + # via + # kubernetes + # requests +websocket-client==1.6.3 + # via + # kubernetes + # resotolib +yarl==1.9.2 + # via aiohttp +zc-lockfile==3.0.post1 + # via cherrypy diff --git a/tests/test_ca.py b/tests/test_ca.py new file mode 100644 index 0000000..bb52a76 --- /dev/null +++ b/tests/test_ca.py @@ -0,0 +1,13 @@ +from fixca.ca import CA +from resotolib.x509 import ( + gen_rsa_key, + gen_csr, +) +from cryptography.x509.base import Certificate + + +def test_ca() -> None: + cn = "test.fix" + CA.initialize(dummy_ca=True) + test_cert: Certificate = CA.sign(gen_csr(gen_rsa_key(), common_name=cn, san_dns_names=[cn])) + assert test_cert.subject.rfc4514_string() == f"CN={cn}" diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..6757f62 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,27 @@ +import time +from fixca.utils import memoize, str_to_bool + + +def test_memoize() -> None: + foo1 = foo() + time.sleep(0.1) + assert foo() == foo1 + time.sleep(1.1) + assert foo() != foo1 + + +@memoize(ttl=1) +def foo() -> int: + return time.time() + + +def test_str_to_bool() -> None: + assert str_to_bool("true") is True + assert str_to_bool("false") is False + assert str_to_bool("1") is True + assert str_to_bool("0") is False + assert str_to_bool("yes") is True + assert str_to_bool("no") is False + assert str_to_bool(True) is True + assert str_to_bool(False) is False + assert str_to_bool("qwerty") is False diff --git a/tox.ini b/tox.ini index 3ade9d6..def9250 100644 --- a/tox.ini +++ b/tox.ini @@ -2,19 +2,19 @@ env_list = syntax, tests, black, flake8, mypy [flake8] -max-line-length=120 -exclude = .git,.tox,__pycache__,.idea,.pytest_cache -ignore=F401, F403, F405, F811, E722, N806, N813, E266, W503, E203 +max-line-length = 120 +exclude = .git,.tox,__pycache__,.idea,.pytest_cache,venv +ignore = F401, F403, F405, F811, E722, N806, N813, E266, W503, E203 [pytest] -testpaths= test -asyncio_mode= auto +testpaths = test +asyncio_mode = auto [testenv] usedevelop = true deps = - -r../requirements-all.txt -# until this is fixed: https://github.com/pypa/setuptools/issues/3518 + -rrequirements-test.txt + setenv = SETUPTOOLS_ENABLE_FEATURES = legacy-editable @@ -28,4 +28,4 @@ commands= pytest commands = black --line-length 120 --check --diff --target-version py39 . [testenv:mypy] -commands= mypy --install-types --non-interactive --python-version 3.9 --strict resotolib +commands = mypy --install-types --non-interactive --python-version 3.11 --strict fixca