From 38a2829574f2e38f20ea33ec5f3f949903c2763e Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Thu, 8 Feb 2024 19:58:39 +0200 Subject: [PATCH 1/9] add URLs for TILAUSPALVELU things --- src/rasenmaeher_api/rmsettings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rasenmaeher_api/rmsettings.py b/src/rasenmaeher_api/rmsettings.py index 8d7739a2..e35d2708 100644 --- a/src/rasenmaeher_api/rmsettings.py +++ b/src/rasenmaeher_api/rmsettings.py @@ -99,6 +99,10 @@ class Config: # pylint: disable=too-few-public-methods ldap_username: Optional[str] = None ldap_client_secret: Optional[str] = None + # Tilauspalvelu integration + tilauspalvelu_jwt: str = "file:///pvarki/publickeys/kraftwerk.pub" # FIXME: Get URL from JHH + tilauspalvelu_announce: str = "https://httpbin.org/anything" # FIXME: Get URL from JHH + _singleton: ClassVar[Optional["RMSettings"]] = None @classmethod From ec976ae5af6d5203766daa6c03b98556f6401a28 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Thu, 8 Feb 2024 21:00:30 +0200 Subject: [PATCH 2/9] Load TILAUSPALVELU public key from configured URL --- src/rasenmaeher_api/jwtinit.py | 94 +++++++++++++++++++++++++-------- tests/tlstests/test_jwt_init.py | 4 +- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/rasenmaeher_api/jwtinit.py b/src/rasenmaeher_api/jwtinit.py index 304afc8c..8cb89c26 100644 --- a/src/rasenmaeher_api/jwtinit.py +++ b/src/rasenmaeher_api/jwtinit.py @@ -5,19 +5,79 @@ import asyncio from pathlib import Path import random +import urllib.request +import ssl import filelock from multikeyjwt import Issuer from multikeyjwt.keygen import generate_keypair +from libpvarki.mtlshelp.context import get_ca_context + +from .rmsettings import RMSettings LOGGER = logging.getLogger(__name__) DEFAULT_KEY_PATH = Path("/data/persistent/private/rasenmaeher_jwt.key") DEFAULT_PUB_PATH = Path("/data/persistent/public/rasenmaeher_jwt.pub") KRAFTWERK_KEYS_PATH = Path(os.environ.get("PVARKI_PUBLICKEYS_PATH", "/pvarki/publickeys")) +HTTP_TIMEOUT = 2.0 -def check_public_keys() -> bool: - """Check public keys""" +def _check_public_keys_tilauspalvelu(pubkeydir: Path) -> None: + """handle TILAUSPALVELU public key""" + tppubkey = pubkeydir / "tilauspalvelu.pub" + if tppubkey.exists(): + LOGGER.debug("{} exists".format(tppubkey)) + return + if not RMSettings.singleton().tilauspalvelu_jwt: + LOGGER.info("No URL for TILAUSPALVELU public key given") + return + LOGGER.info("Making sure TILAUSPALVELU key is in {}".format(pubkeydir)) + lockpath = pubkeydir.parent / "tpkeycopy.lock" + lock = filelock.FileLock(lockpath) + try: + lock.acquire(timeout=0.0) + ssl_ctx = get_ca_context(ssl.Purpose.SERVER_AUTH) + try: + with urllib.request.urlopen( + RMSettings.singleton().tilauspalvelu_jwt, context=ssl_ctx, timeout=HTTP_TIMEOUT # nosec + ) as response: + tppubkey.write_bytes(response.read()) + except (urllib.request.HTTPError, TimeoutError) as exc: + LOGGER.error("Could not load TILAUSPALVELU key: {}".format(exc)) + except Exception as exc: # pylint: disable=W0718 + LOGGER.exception("Unhanled exception while loading TILAUSPALVELU key: {}".format(exc)) + except filelock.Timeout: + LOGGER.info("Someone already locked {}, leaving them to it".format(lockpath)) + finally: + lock.release() + + +def _check_public_keys_kraftwerk(pubkeydir: Path) -> None: + """Handle KRAFTWERK Public Keys copy""" + if not KRAFTWERK_KEYS_PATH.exists(): + LOGGER.warning("{} does not exist, not copying KRAFTWERK public keys".format(KRAFTWERK_KEYS_PATH)) + return + LOGGER.info("Making sure KRAFTWERK provided keys are in {}".format(pubkeydir)) + lockpath = pubkeydir.parent / "pubkeycopy.lock" + lock = filelock.FileLock(lockpath) + try: + lock.acquire(timeout=0.0) + for fpath in KRAFTWERK_KEYS_PATH.iterdir(): + tgtpath = pubkeydir / fpath.name + LOGGER.debug("Checking {} vs {} (exists={})".format(fpath, tgtpath, tgtpath.exists())) + if tgtpath.exists(): + continue + # Copy the pubkey + LOGGER.info("Copying {} to {}".format(fpath, tgtpath)) + tgtpath.write_bytes(fpath.read_bytes()) + except filelock.Timeout: + LOGGER.info("Someone already locked {}, leaving them to it".format(lockpath)) + finally: + lock.release() + + +def resolve_pubkeydir() -> Path: + """Resolve the directory for public keys and make sure it exists""" pubkeydir: Union[Path, Optional[str]] = os.environ.get("JWT_PUBKEY_PATH") LOGGER.debug("initial pubkeydir={}".format(pubkeydir)) if pubkeydir: @@ -29,27 +89,17 @@ def check_public_keys() -> bool: LOGGER.debug("final pubkeydir={}".format(pubkeydir)) if not pubkeydir.exists(): pubkeydir.mkdir(parents=True) + return pubkeydir + + +def check_public_keys() -> bool: + """Check public keys""" + pubkeydir = resolve_pubkeydir() + + # FIXME: These should be run in executors (which means this function should be async etc) + _check_public_keys_tilauspalvelu(pubkeydir) + _check_public_keys_kraftwerk(pubkeydir) - if KRAFTWERK_KEYS_PATH.exists(): - LOGGER.info("Making sure KRAFTWERK provided keys are in {}".format(pubkeydir)) - lockpath = pubkeydir.parent / "pubkeycopy.lock" - lock = filelock.FileLock(lockpath) - try: - lock.acquire(timeout=0.0) - for fpath in KRAFTWERK_KEYS_PATH.iterdir(): - tgtpath = pubkeydir / fpath.name - LOGGER.debug("Checking {} vs {} (exists={})".format(fpath, tgtpath, tgtpath.exists())) - if tgtpath.exists(): - continue - # Copy the pubkey - LOGGER.info("Copying {} to {}".format(fpath, tgtpath)) - tgtpath.write_bytes(fpath.read_bytes()) - except filelock.Timeout: - LOGGER.info("Someone already locked {}, leaving them to it".format(lockpath)) - finally: - lock.release() - else: - LOGGER.warning("{} does not exist, not copying KRAFTWERK public keys".format(KRAFTWERK_KEYS_PATH)) return True diff --git a/tests/tlstests/test_jwt_init.py b/tests/tlstests/test_jwt_init.py index 3a957beb..fe70f82a 100644 --- a/tests/tlstests/test_jwt_init.py +++ b/tests/tlstests/test_jwt_init.py @@ -9,7 +9,7 @@ from libadvian.testhelpers import nice_tmpdir # pylint: disable=W0611 from async_asgi_testclient import TestClient # pylint: disable=import-error -from rasenmaeher_api.jwtinit import check_public_keys, check_private_key, check_jwt_init, jwt_init +from rasenmaeher_api.jwtinit import check_public_keys, check_private_key, check_jwt_init, jwt_init, resolve_pubkeydir LOGGER = logging.getLogger(__name__) @@ -43,6 +43,8 @@ def test_empty_response(empty_datadirs: Tuple[Path, Path]) -> None: assert not check_jwt_init() assert not check_private_key() assert check_public_keys() # This should always be true unless shit blows up + tppath = resolve_pubkeydir() / "tilauspalvelu.pub" + assert tppath.exists() @pytest.mark.asyncio From 090fa11cf99ef1e6a9b6c312528027e404f0c831 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Thu, 8 Feb 2024 21:02:17 +0200 Subject: [PATCH 3/9] bump version --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- src/rasenmaeher_api/__init__.py | 2 +- tests/test_rasenmaeher_api.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 66ee73be..4ccbd5f7 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.1 +current_version = 1.2.0 commit = False tag = False diff --git a/pyproject.toml b/pyproject.toml index c8d0035d..5d9c99e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rasenmaeher_api" -version = "1.1.1" +version = "1.2.0" description = "python-rasenmaeher-api" authors = [ "Aciid <703382+Aciid@users.noreply.github.com>", diff --git a/src/rasenmaeher_api/__init__.py b/src/rasenmaeher_api/__init__.py index 6bcee426..ed252343 100644 --- a/src/rasenmaeher_api/__init__.py +++ b/src/rasenmaeher_api/__init__.py @@ -1,2 +1,2 @@ """ python-rasenmaeher-api """ -__version__ = "1.1.1" # NOTE Use `bump2version --config-file patch` to bump versions correctly +__version__ = "1.2.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly diff --git a/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index 6de499e0..2898dd6f 100644 --- a/tests/test_rasenmaeher_api.py +++ b/tests/test_rasenmaeher_api.py @@ -11,7 +11,7 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "1.1.1" + assert __version__ == "1.2.0" @pytest.mark.asyncio From 77a7e379107d5055752bab891a4db9c6b8b1f064 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Thu, 8 Feb 2024 21:21:00 +0200 Subject: [PATCH 4/9] fix TILAUSPALVELU jwt load in tests --- tests/conftest.py | 8 ++++++++ tests/tlstests/test_jwt_init.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ffabb0e8..efc59ace 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -148,6 +148,14 @@ def session_env_config( # pylint: disable=R0915 str(sessionpersistent / "private" / f"{CERT_NAME_PREFIX}.key"), ) mpatch.setenv("RM_MTLS_CLIENT_KEY_PATH", str(switchme_to_singleton_call.mtls_client_key_path)) + + mpatch.setattr( + switchme_to_singleton_call, + "tilauspalvelu_jwt", + "file://{}".format(str(DATA_PATH / "jwt" / "cl_jwtRS256.pub")), + ) + mpatch.setenv("TILAUSPALVELU_JWT", str(switchme_to_singleton_call.tilauspalvelu_jwt)) + assert not check_settings_clientpaths() mpatch.setattr(switchme_to_singleton_call, "kraftwerk_manifest_path", str(kfmanifest)) diff --git a/tests/tlstests/test_jwt_init.py b/tests/tlstests/test_jwt_init.py index fe70f82a..2da4dea7 100644 --- a/tests/tlstests/test_jwt_init.py +++ b/tests/tlstests/test_jwt_init.py @@ -34,17 +34,23 @@ def empty_datadirs(nice_tmpdir: str, monkeypatch: pytest.MonkeyPatch) -> Generat mpatch.setenv("JWT_PUBKEY_PATH", str(pubkeydir)) mpatch.setenv("JWT_PRIVKEY_PATH", str(privkeypath)) mpatch.setenv("PVARKI_PUBLICKEYS_PATH", str(arkikeys)) # this is probably too late already + mpatch.setenv("TILAUSPALVELU_JWT", "") yield privdir, pubkeydir +def test_tilaupalvelu_key() -> None: + """Test that default env has copied tilauspalvelu key""" + assert check_public_keys() + tppath = resolve_pubkeydir() / "tilauspalvelu.pub" + assert tppath.exists() + + def test_empty_response(empty_datadirs: Tuple[Path, Path]) -> None: """Check tnat the checking functions return False""" LOGGER.debug("empty_datadirs={}".format(empty_datadirs)) assert not check_jwt_init() assert not check_private_key() assert check_public_keys() # This should always be true unless shit blows up - tppath = resolve_pubkeydir() / "tilauspalvelu.pub" - assert tppath.exists() @pytest.mark.asyncio From 61c51894fc2a5897bdb5b92545122e67d3165546 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Thu, 8 Feb 2024 22:17:01 +0200 Subject: [PATCH 5/9] UNTESTED announce functionality --- src/rasenmaeher_api/web/application.py | 33 +++++++++++++++++++++++--- tests/conftest.py | 6 +++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/rasenmaeher_api/web/application.py b/src/rasenmaeher_api/web/application.py index 3a080b0e..fd35268d 100644 --- a/src/rasenmaeher_api/web/application.py +++ b/src/rasenmaeher_api/web/application.py @@ -1,12 +1,14 @@ """Main API entrypoint""" from typing import AsyncGenerator +import asyncio import logging from contextlib import asynccontextmanager from fastapi import FastAPI from libpvarki.logging import init_logging +import aiohttp -from ..rmsettings import switchme_to_singleton_call +from ..rmsettings import RMSettings from .api.router import api_router from ..mtlsinit import mtls_init from ..jwtinit import jwt_init @@ -27,9 +29,11 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: _ = app await jwt_init() await mtls_init() + reporter = asyncio.get_running_loop().create_task(report_to_tilauspalvelu()) # App runs yield # Cleanup + await reporter # Just to avoid warning about task that was not awaited await dbwrapper.app_shutdown_event() @@ -43,7 +47,30 @@ def get_app_no_init() -> FastAPI: def get_app() -> FastAPI: """Returns the FastAPI application.""" - init_logging(switchme_to_singleton_call.log_level_int) + init_logging(RMSettings.singleton().log_level_int) app = get_app_no_init() - LOGGER.info("API init done, setting log verbosity to '{}'.".format(switchme_to_singleton_call.log_level)) + LOGGER.info("API init done, setting log verbosity to '{}'.".format(RMSettings.singleton().log_level)) return app + + +async def report_to_tilauspalvelu() -> None: + """Call the TILAUSPALVELU announce URL if configured""" + url = RMSettings.singleton().tilauspalvelu_announce + if not url: + LOGGER.info("TILAUSPALVELU announce url is empty") + return + data = { + "dns": RMSettings.singleton().kraftwerk_manifest_dict["dns"], + "version": __version__, + } + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data) as response: + response.raise_for_status() + payload = await response.json() + LOGGER.debug("{} responded with {}".format(url, payload)) + except aiohttp.ClientError as exc: + LOGGER.warning("Failed to report to TILAUSPALVELU at {}".format(url)) + LOGGER.info(exc) + except Exception as exc: # pylint: disable=W0718 + LOGGER.exception("Unhandled exception while reporting to TILAUSPALVELU: {}".format(exc)) diff --git a/tests/conftest.py b/tests/conftest.py index efc59ace..0a6ddebe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -155,6 +155,12 @@ def session_env_config( # pylint: disable=R0915 "file://{}".format(str(DATA_PATH / "jwt" / "cl_jwtRS256.pub")), ) mpatch.setenv("TILAUSPALVELU_JWT", str(switchme_to_singleton_call.tilauspalvelu_jwt)) + mpatch.setattr( + switchme_to_singleton_call, + "tilauspalvelu_announce", + "", # FIXME: Figure out a way to mock a server + ) + mpatch.setenv("TILAUSPALVELU_ANNOUNCE", str(switchme_to_singleton_call.tilauspalvelu_announce)) assert not check_settings_clientpaths() From a817f6d74fea23666b7e1327edb9b0488e5ceff2 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 9 Feb 2024 08:17:02 +0200 Subject: [PATCH 6/9] Add test server task for testing announce --- tests/conftest.py | 51 ++++++++++++++++++++++++++++++++--- tests/test_rasenmaeher_api.py | 15 +++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0a6ddebe..f08e36b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import uuid import json import asyncio +import random import pytest from multikeyjwt import Issuer, Verifier @@ -16,6 +17,7 @@ from libadvian.binpackers import uuid_to_b64 from libadvian.testhelpers import monkeysession, nice_tmpdir_mod, nice_tmpdir_ses # pylint: disable=unused-import from pytest_docker.plugin import Services +from aiohttp import web from rasenmaeher_api.web.application import get_app from rasenmaeher_api.rmsettings import switchme_to_singleton_call @@ -69,8 +71,12 @@ def verifier() -> Verifier: @pytest.fixture(scope="session", autouse=True) -def session_env_config( # pylint: disable=R0915 - monkeysession: pytest.MonkeyPatch, docker_ip: str, docker_services: Services, nice_tmpdir_ses: str +def session_env_config( # pylint: disable=R0915,R0914 + monkeysession: pytest.MonkeyPatch, + docker_ip: str, + docker_services: Services, + nice_tmpdir_ses: str, + announce_server: str, ) -> Generator[None, None, None]: """set the JWT auth config""" sessionfiles = Path(nice_tmpdir_ses) @@ -158,7 +164,7 @@ def session_env_config( # pylint: disable=R0915 mpatch.setattr( switchme_to_singleton_call, "tilauspalvelu_announce", - "", # FIXME: Figure out a way to mock a server + f"{announce_server}/announce", ) mpatch.setenv("TILAUSPALVELU_ANNOUNCE", str(switchme_to_singleton_call.tilauspalvelu_announce)) @@ -326,3 +332,42 @@ async def app_client(request: SubRequest) -> AsyncGenerator[TestClient, None]: ) yield instance + + +@pytest_asyncio.fixture(scope="session") +async def announce_server() -> AsyncGenerator[str, None]: + """Simple test server""" + bind_port = random.randint(1000, 64000) # nosec + hostname = "localmaeher.pvarki.fi" + + request_payloads: List[Dict[str, Any]] = [] + + async def handle_announce(request: web.Request) -> web.Response: + """Handle the POST""" + nonlocal request_payloads + LOGGER.debug("request={}".format(request)) + payload = await request.json() + request_payloads.append(payload) + return web.json_response(payload) + + async def handle_log(request: web.Request) -> web.Response: + """Return payload log""" + nonlocal request_payloads + LOGGER.debug("request={}".format(request)) + return web.json_response({"payloads": request_payloads}) + + app = web.Application() + app.add_routes([web.post("/announce", handle_announce), web.get("/log", handle_log)]) + + LOGGER.debug("Starting the async server task(s)") + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=hostname, port=bind_port) + await site.start() + + uri = f"http://{hostname}:{bind_port}" + LOGGER.debug("yielding {}".format(uri)) + yield uri + + LOGGER.debug("Stopping the async server task(s)") + await runner.cleanup() diff --git a/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index 2898dd6f..aea24509 100644 --- a/tests/test_rasenmaeher_api.py +++ b/tests/test_rasenmaeher_api.py @@ -3,6 +3,7 @@ import pytest from async_asgi_testclient import TestClient +import aiohttp from rasenmaeher_api import __version__ @@ -28,3 +29,17 @@ def test_settings() -> None: """Test settings defaults""" conf = RMSettings.singleton() assert "fake.localmaeher.pvarki.fi" in conf.valid_product_cns + + +@pytest.mark.asyncio +async def test_announce(unauth_client: TestClient, announce_server: str) -> None: + """Make sure we have seen at least one announce call""" + # Make a request to make sure the app spins up + resp = await unauth_client.get("/api/v1/healthcheck") + assert resp + async with aiohttp.ClientSession() as session: + async with session.get(f"{announce_server}/log") as response: + response.raise_for_status() + resp_json = await response.json() + assert resp_json["payloads"] + assert resp_json["payloads"][0]["version"] == __version__ From 606942db84887b7443be99574e396f0588524da5 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 9 Feb 2024 19:53:01 +0200 Subject: [PATCH 7/9] add endpoint for getting RASENMAEHERs JWT signing key, fixes #93 --- src/rasenmaeher_api/jwtinit.py | 24 +++------- src/rasenmaeher_api/web/api/utils/views.py | 52 ++++++++++++++-------- tests/test_rasenmaeher_api.py | 17 +++++++ 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/src/rasenmaeher_api/jwtinit.py b/src/rasenmaeher_api/jwtinit.py index 8cb89c26..6001b467 100644 --- a/src/rasenmaeher_api/jwtinit.py +++ b/src/rasenmaeher_api/jwtinit.py @@ -129,7 +129,7 @@ def check_jwt_init() -> bool: return check_public_keys() -def _jwt_init_keypath() -> Path: +def resolve_rm_jwt_privkey_path() -> Path: """resolve the path for the private key""" keypath: Union[Path, Optional[str]] = os.environ.get("JWT_PRIVKEY_PATH") if keypath: @@ -145,28 +145,18 @@ def _jwt_init_keypath() -> Path: return keypath -def _jwt_init_pubkeypath(genpubpath: Path) -> Path: +def resolve_rm_jwt_pubkey_path(expect_name: Optional[str] = None) -> Path: """resolve the path for the public key""" - pubkeypath: Union[Path, Optional[str]] = os.environ.get("JWT_PUBKEY_PATH") - LOGGER.debug("initial pubkeypath={}".format(pubkeypath)) - if pubkeypath: - pubkeypath = Path(pubkeypath) - if pubkeypath.exists() and pubkeypath.is_dir(): - pubkeypath = pubkeypath / genpubpath.name - LOGGER.info("JWT_PUBKEY_PATH defined, copying our key there") - else: - pubkeypath = DEFAULT_PUB_PATH - LOGGER.debug("final pubkeypath={}".format(pubkeypath)) - if not pubkeypath.parent.exists(): - pubkeypath.parent.mkdir(parents=True) - return pubkeypath + if not expect_name: + expect_name = resolve_rm_jwt_privkey_path().name.replace(".key", ".pub") + return resolve_pubkeydir() / expect_name async def jwt_init() -> None: """If needed: Create keypair""" if check_jwt_init(): return - keypath = _jwt_init_keypath() + keypath = resolve_rm_jwt_privkey_path() genpubpath: Optional[Path] = None genprivpath: Optional[Path] = None @@ -196,7 +186,7 @@ async def jwt_init() -> None: raise RuntimeError("Returned private key does not exist!") if not genpubpath or not genpubpath.exists(): raise RuntimeError("Returned private key does not exist!") - pubkeypath = _jwt_init_pubkeypath(genpubpath) + pubkeypath = resolve_rm_jwt_pubkey_path(genpubpath.name) LOGGER.debug("Copy generated pubkey to {}".format(pubkeypath)) pubkeypath.write_bytes(genpubpath.read_bytes()) diff --git a/src/rasenmaeher_api/web/api/utils/views.py b/src/rasenmaeher_api/web/api/utils/views.py index ac55d16c..0efe16ea 100644 --- a/src/rasenmaeher_api/web/api/utils/views.py +++ b/src/rasenmaeher_api/web/api/utils/views.py @@ -2,12 +2,14 @@ import logging from fastapi import APIRouter, Depends, Response +from fastapi.responses import FileResponse from libpvarki.middleware.mtlsheader import MTLSHeader from .schema import LdapConnString, KeyCloakConnString -from ....rmsettings import switchme_to_singleton_call +from ....rmsettings import RMSettings from ....cfssl.public import get_crl +from ....jwtinit import resolve_rm_jwt_pubkey_path LOGGER = logging.getLogger(__name__) @@ -20,24 +22,25 @@ async def request_utils_ldap_conn_string() -> LdapConnString: TODO ldap-conn-string """ + conf = RMSettings.singleton() if None in ( - switchme_to_singleton_call.ldap_conn_string, - switchme_to_singleton_call.ldap_username, - switchme_to_singleton_call.ldap_client_secret, + conf.ldap_conn_string, + conf.ldap_username, + conf.ldap_client_secret, ): return LdapConnString( success=False, reason="One or more ldap connection variables are undefined.", - ldap_conn_string=switchme_to_singleton_call.ldap_conn_string, - ldap_user=switchme_to_singleton_call.ldap_username, - ldap_client_secret=switchme_to_singleton_call.ldap_client_secret, + ldap_conn_string=conf.ldap_conn_string, + ldap_user=conf.ldap_username, + ldap_client_secret=conf.ldap_client_secret, ) return LdapConnString( success=True, - ldap_conn_string=switchme_to_singleton_call.ldap_conn_string, - ldap_user=switchme_to_singleton_call.ldap_username, - ldap_client_secret=switchme_to_singleton_call.ldap_client_secret, + ldap_conn_string=conf.ldap_conn_string, + ldap_user=conf.ldap_username, + ldap_client_secret=conf.ldap_client_secret, reason="", ) @@ -48,11 +51,12 @@ async def request_utils_keycloak_conn_string() -> KeyCloakConnString: TODO keycloak-conn-string """ + conf = RMSettings.singleton() if None in ( - switchme_to_singleton_call.keycloak_server_url, - switchme_to_singleton_call.keycloak_client_id, - switchme_to_singleton_call.keycloak_realm_name, - switchme_to_singleton_call.keycloak_client_secret, + conf.keycloak_server_url, + conf.keycloak_client_id, + conf.keycloak_realm_name, + conf.keycloak_client_secret, ): return KeyCloakConnString( success=False, @@ -65,16 +69,26 @@ async def request_utils_keycloak_conn_string() -> KeyCloakConnString: return KeyCloakConnString( success=True, - keycloak_server_url=switchme_to_singleton_call.keycloak_server_url, - keycloak_client_id=switchme_to_singleton_call.keycloak_client_id, - keycloak_realm_name=switchme_to_singleton_call.keycloak_realm_name, - keycloak_client_s_sting=switchme_to_singleton_call.keycloak_client_secret, + keycloak_server_url=conf.keycloak_server_url, + keycloak_client_id=conf.keycloak_client_id, + keycloak_realm_name=conf.keycloak_realm_name, + keycloak_client_s_sting=conf.keycloak_client_secret, reason="", ) @router.get("/crl") async def return_crl() -> Response: - """Get the CRL from CFSSL""" + """Get the CRL from CFSSL. NOTE: This should not be used anymore, use the cosprest helper for CRLs""" crl_der = await get_crl() return Response(content=crl_der, media_type="application/pkix-crl") + + +@router.get("/jwt.pub") +async def return_jwt_pubkey() -> FileResponse: + """Return PEM encoded public key for tokens that RASENMAEHER issues""" + my_dn = RMSettings.singleton().kraftwerk_manifest_dict["dns"] + deployment_name = my_dn.split(".")[0] + return FileResponse( + resolve_rm_jwt_pubkey_path(), media_type="application/x-pem-file", filename=f"{deployment_name}_rm_jwt.pub" + ) diff --git a/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index aea24509..6e2b03d0 100644 --- a/tests/test_rasenmaeher_api.py +++ b/tests/test_rasenmaeher_api.py @@ -1,9 +1,12 @@ """Package level tests""" from typing import Any, Dict +from pathlib import Path import pytest from async_asgi_testclient import TestClient import aiohttp +from multikeyjwt.jwt.issuer import Issuer +from multikeyjwt.jwt.verifier import Verifier from rasenmaeher_api import __version__ @@ -43,3 +46,17 @@ async def test_announce(unauth_client: TestClient, announce_server: str) -> None resp_json = await response.json() assert resp_json["payloads"] assert resp_json["payloads"][0]["version"] == __version__ + + +@pytest.mark.asyncio +async def test_jwt_pub_url(unauth_client: TestClient, tmp_path: Path) -> None: + """Test the JWT public key""" + resp = await unauth_client.get("/api/v1/utils/jwt.pub") + resp.raise_for_status() + keypath = tmp_path / "jwt.pub" + keypath.write_bytes(resp.content) + verifier = Verifier(pubkeypath=tmp_path) + issuer = Issuer.singleton() + token = issuer.issue({"doggo": "besto"}) + decoded = verifier.decode(token) + assert decoded["doggo"] == "besto" From 1347e6a6134e581f0e501cba759a68a1d8da1889 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 9 Feb 2024 20:42:22 +0200 Subject: [PATCH 8/9] The announces should go to KRAFTWERK --- src/rasenmaeher_api/rmsettings.py | 4 ++-- src/rasenmaeher_api/web/application.py | 16 ++++++++-------- tests/conftest.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/rasenmaeher_api/rmsettings.py b/src/rasenmaeher_api/rmsettings.py index e35d2708..0700ecb5 100644 --- a/src/rasenmaeher_api/rmsettings.py +++ b/src/rasenmaeher_api/rmsettings.py @@ -100,8 +100,8 @@ class Config: # pylint: disable=too-few-public-methods ldap_client_secret: Optional[str] = None # Tilauspalvelu integration - tilauspalvelu_jwt: str = "file:///pvarki/publickeys/kraftwerk.pub" # FIXME: Get URL from JHH - tilauspalvelu_announce: str = "https://httpbin.org/anything" # FIXME: Get URL from JHH + tilauspalvelu_jwt: str = "https://tilaa.pvarki.fi/api/v1/config/jwtPublicKey.pem" + kraftwerk_announce: str = "" # When KRAFTWERK actually exists _singleton: ClassVar[Optional["RMSettings"]] = None diff --git a/src/rasenmaeher_api/web/application.py b/src/rasenmaeher_api/web/application.py index fd35268d..4b00189a 100644 --- a/src/rasenmaeher_api/web/application.py +++ b/src/rasenmaeher_api/web/application.py @@ -29,7 +29,7 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: _ = app await jwt_init() await mtls_init() - reporter = asyncio.get_running_loop().create_task(report_to_tilauspalvelu()) + reporter = asyncio.get_running_loop().create_task(report_to_kraftwerk()) # App runs yield # Cleanup @@ -38,7 +38,7 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: def get_app_no_init() -> FastAPI: - """Retunr the app without logging etc inits""" + """Return the app without logging etc inits""" app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=app_lifespan, version=__version__) app.include_router(router=api_router, prefix="/api/v1") app.add_middleware(DBConnectionMiddleware, gino=dbbase.db, config=DBConfig.singleton()) @@ -53,11 +53,11 @@ def get_app() -> FastAPI: return app -async def report_to_tilauspalvelu() -> None: - """Call the TILAUSPALVELU announce URL if configured""" - url = RMSettings.singleton().tilauspalvelu_announce +async def report_to_kraftwerk() -> None: + """Call the KRAFTWERK announce URL if configured""" + url = RMSettings.singleton().kraftwerk_announce if not url: - LOGGER.info("TILAUSPALVELU announce url is empty") + LOGGER.info("KRAFTWERK announce url is empty") return data = { "dns": RMSettings.singleton().kraftwerk_manifest_dict["dns"], @@ -70,7 +70,7 @@ async def report_to_tilauspalvelu() -> None: payload = await response.json() LOGGER.debug("{} responded with {}".format(url, payload)) except aiohttp.ClientError as exc: - LOGGER.warning("Failed to report to TILAUSPALVELU at {}".format(url)) + LOGGER.warning("Failed to report to KRAFTWERK at {}".format(url)) LOGGER.info(exc) except Exception as exc: # pylint: disable=W0718 - LOGGER.exception("Unhandled exception while reporting to TILAUSPALVELU: {}".format(exc)) + LOGGER.exception("Unhandled exception while reporting to KRAFTWERK: {}".format(exc)) diff --git a/tests/conftest.py b/tests/conftest.py index f08e36b7..604cb935 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -163,10 +163,10 @@ def session_env_config( # pylint: disable=R0915,R0914 mpatch.setenv("TILAUSPALVELU_JWT", str(switchme_to_singleton_call.tilauspalvelu_jwt)) mpatch.setattr( switchme_to_singleton_call, - "tilauspalvelu_announce", + "kraftwerk_announce", f"{announce_server}/announce", ) - mpatch.setenv("TILAUSPALVELU_ANNOUNCE", str(switchme_to_singleton_call.tilauspalvelu_announce)) + mpatch.setenv("KRAFTWERK_ANNOUNCE", str(switchme_to_singleton_call.kraftwerk_announce)) assert not check_settings_clientpaths() From 0ad00e3999677a67594f21c720e2e963d864e306 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 9 Feb 2024 22:55:33 +0200 Subject: [PATCH 9/9] add timeouts to kraftwerk announce --- src/rasenmaeher_api/rmsettings.py | 3 ++- src/rasenmaeher_api/web/application.py | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/rasenmaeher_api/rmsettings.py b/src/rasenmaeher_api/rmsettings.py index 0700ecb5..c12904df 100644 --- a/src/rasenmaeher_api/rmsettings.py +++ b/src/rasenmaeher_api/rmsettings.py @@ -101,7 +101,8 @@ class Config: # pylint: disable=too-few-public-methods # Tilauspalvelu integration tilauspalvelu_jwt: str = "https://tilaa.pvarki.fi/api/v1/config/jwtPublicKey.pem" - kraftwerk_announce: str = "" # When KRAFTWERK actually exists + kraftwerk_announce: Optional[str] = None # When KRAFTWERK actually exists + kraftwerk_timeout: float = 2.0 _singleton: ClassVar[Optional["RMSettings"]] = None diff --git a/src/rasenmaeher_api/web/application.py b/src/rasenmaeher_api/web/application.py index 4b00189a..3c5227da 100644 --- a/src/rasenmaeher_api/web/application.py +++ b/src/rasenmaeher_api/web/application.py @@ -55,21 +55,23 @@ def get_app() -> FastAPI: async def report_to_kraftwerk() -> None: """Call the KRAFTWERK announce URL if configured""" - url = RMSettings.singleton().kraftwerk_announce + conf = RMSettings.singleton() + url = conf.kraftwerk_announce if not url: LOGGER.info("KRAFTWERK announce url is empty") return data = { - "dns": RMSettings.singleton().kraftwerk_manifest_dict["dns"], + "dns": conf.kraftwerk_manifest_dict["dns"], "version": __version__, } try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=conf.kraftwerk_timeout)) as session: + LOGGER.debug("POSTing to {} data: {}".format(url, data)) async with session.post(url, json=data) as response: response.raise_for_status() payload = await response.json() LOGGER.debug("{} responded with {}".format(url, payload)) - except aiohttp.ClientError as exc: + except (aiohttp.ClientError, TimeoutError) as exc: LOGGER.warning("Failed to report to KRAFTWERK at {}".format(url)) LOGGER.info(exc) except Exception as exc: # pylint: disable=W0718