mrok 0.7.0__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import sys
2
+
1
3
  from mrok.agent.devtools.inspector.app import module_main
2
4
 
3
- module_main()
5
+ module_main(sys.argv[1:])
@@ -22,8 +22,8 @@ async def bootstrap(
22
22
  return await bootstrap_identity(
23
23
  mgmt_api,
24
24
  client_api,
25
- settings.proxy.identity,
26
- settings.proxy.mode,
25
+ settings.frontend.identity,
26
+ settings.frontend.mode,
27
27
  forced,
28
28
  tags,
29
29
  )
@@ -5,8 +5,8 @@ import typer
5
5
  from rich import print
6
6
 
7
7
  from mrok.cli.commands.admin.utils import parse_tags
8
- from mrok.conf import Settings
9
- from mrok.constants import RE_EXTENSION_ID
8
+ from mrok.cli.utils import validate_extension_id
9
+ from mrok.conf import Settings, get_settings
10
10
  from mrok.ziti.api import ZitiManagementAPI
11
11
  from mrok.ziti.services import register_service
12
12
 
@@ -16,18 +16,16 @@ async def do_register(settings: Settings, extension_id: str, tags: list[str] | N
16
16
  await register_service(settings, api, extension_id, tags=parse_tags(tags))
17
17
 
18
18
 
19
- def validate_extension_id(extension_id: str) -> str:
20
- if not RE_EXTENSION_ID.fullmatch(extension_id):
21
- raise typer.BadParameter("it must match EXT-xxxx-yyyy (case-insensitive)")
22
- return extension_id
23
-
24
-
25
19
  def register(app: typer.Typer) -> None:
20
+ settings = get_settings()
21
+
26
22
  @app.command("extension")
27
23
  def register_extension(
28
24
  ctx: typer.Context,
29
25
  extension_id: str = typer.Argument(
30
- ..., callback=validate_extension_id, help="Extension ID in format EXT-xxxx-yyyy"
26
+ ...,
27
+ callback=validate_extension_id,
28
+ help=f"Extension ID in the format {settings.identifiers.extension.format}",
31
29
  ),
32
30
  tags: Annotated[
33
31
  list[str] | None,
@@ -6,8 +6,11 @@ from typing import Annotated
6
6
  import typer
7
7
 
8
8
  from mrok.cli.commands.admin.utils import parse_tags
9
- from mrok.conf import Settings
10
- from mrok.constants import RE_EXTENSION_ID, RE_INSTANCE_ID
9
+ from mrok.cli.utils import (
10
+ validate_extension_id,
11
+ validate_instance_id,
12
+ )
13
+ from mrok.conf import Settings, get_settings
11
14
  from mrok.ziti.api import ZitiClientAPI, ZitiManagementAPI
12
15
  from mrok.ziti.identities import register_identity
13
16
 
@@ -21,27 +24,21 @@ async def do_register(
21
24
  )
22
25
 
23
26
 
24
- def validate_extension_id(extension_id: str):
25
- if not RE_EXTENSION_ID.fullmatch(extension_id):
26
- raise typer.BadParameter("it must match EXT-xxxx-yyyy (case-insensitive)")
27
- return extension_id
28
-
29
-
30
- def validate_instance_id(instance_id: str):
31
- if not RE_INSTANCE_ID.fullmatch(instance_id):
32
- raise typer.BadParameter("it must match INS-xxxx-yyyy-zzzz (case-insensitive)")
33
- return instance_id
34
-
35
-
36
27
  def register(app: typer.Typer) -> None:
28
+ settings = get_settings()
29
+
37
30
  @app.command("instance")
38
31
  def register_instance(
39
32
  ctx: typer.Context,
40
33
  extension_id: str = typer.Argument(
41
- ..., callback=validate_extension_id, help="Extension ID in format EXT-xxxx-yyyy"
34
+ ...,
35
+ callback=validate_extension_id,
36
+ help=f"Extension ID in the format {settings.identifiers.extension.format}",
42
37
  ),
43
38
  instance_id: str = typer.Argument(
44
- ..., callback=validate_instance_id, help="Instance ID in format INS-xxxx-yyyy-zzzz"
39
+ ...,
40
+ callback=validate_instance_id,
41
+ help=f"Instance ID in the format {settings.identifiers.instance.format}",
45
42
  ),
46
43
  output: Path = typer.Argument(
47
44
  ...,
@@ -1,32 +1,28 @@
1
1
  import asyncio
2
- import re
3
2
 
4
3
  import typer
5
4
 
6
- from mrok.conf import Settings
5
+ from mrok.cli.utils import validate_extension_id
6
+ from mrok.conf import Settings, get_settings
7
7
  from mrok.ziti.api import ZitiManagementAPI
8
8
  from mrok.ziti.services import unregister_service
9
9
 
10
- RE_EXTENSION_ID = re.compile(r"(?i)EXT-\d{4}-\d{4}")
11
-
12
10
 
13
11
  async def do_unregister(settings: Settings, extension_id: str):
14
12
  async with ZitiManagementAPI(settings) as api:
15
13
  await unregister_service(settings, api, extension_id)
16
14
 
17
15
 
18
- def validate_extension_id(extension_id: str):
19
- if not RE_EXTENSION_ID.fullmatch(extension_id):
20
- raise typer.BadParameter("ext_id must match EXT-xxxx-yyyy (case-insensitive)")
21
- return extension_id
22
-
23
-
24
16
  def register(app: typer.Typer) -> None:
17
+ settings = get_settings()
18
+
25
19
  @app.command("extension")
26
20
  def unregister_extension(
27
21
  ctx: typer.Context,
28
22
  extension_id: str = typer.Argument(
29
- ..., callback=validate_extension_id, help="Extension ID in format EXT-xxxx-yyyy"
23
+ ...,
24
+ callback=validate_extension_id,
25
+ help=f"Extension ID in the format {settings.identifiers.extension.format}",
30
26
  ),
31
27
  ):
32
28
  """Unregister a new Extension in OpenZiti (service)."""
@@ -1,34 +1,34 @@
1
1
  import asyncio
2
- import re
3
2
 
4
3
  import typer
5
4
 
6
- from mrok.conf import Settings
5
+ from mrok.cli.utils import validate_extension_id, validate_instance_id
6
+ from mrok.conf import Settings, get_settings
7
7
  from mrok.ziti.api import ZitiManagementAPI
8
8
  from mrok.ziti.identities import unregister_identity
9
9
 
10
- RE_EXTENSION_ID = re.compile(r"(?i)EXT-\d{4}-\d{4}")
11
-
12
10
 
13
11
  async def do_unregister(settings: Settings, extension_id: str, instance_id: str):
14
12
  async with ZitiManagementAPI(settings) as api:
15
13
  await unregister_identity(settings, api, extension_id, instance_id)
16
14
 
17
15
 
18
- def validate_extension_id(extension_id: str):
19
- if not RE_EXTENSION_ID.fullmatch(extension_id):
20
- raise typer.BadParameter("ext_id must match EXT-xxxx-yyyy (case-insensitive)")
21
- return extension_id
22
-
23
-
24
16
  def register(app: typer.Typer) -> None:
17
+ settings = get_settings()
18
+
25
19
  @app.command("instance")
26
20
  def unregister_instance(
27
21
  ctx: typer.Context,
28
22
  extension_id: str = typer.Argument(
29
- ..., callback=validate_extension_id, help="Extension ID in format EXT-xxxx-yyyy"
23
+ ...,
24
+ callback=validate_extension_id,
25
+ help=f"Extension ID in the format {settings.identifiers.extension.format}",
26
+ ),
27
+ instance_id: str = typer.Argument(
28
+ ...,
29
+ callback=validate_instance_id,
30
+ help=f"Instance ID in the format {settings.identifiers.instance.format}",
30
31
  ),
31
- instance_id: str = typer.Argument(..., help="Instance ID"),
32
32
  ):
33
33
  """Register a new Extension Instance in OpenZiti (identity)."""
34
34
  asyncio.run(do_unregister(ctx.obj, extension_id, instance_id))
@@ -12,8 +12,8 @@ default_workers = number_of_workers()
12
12
  def register(app: typer.Typer) -> None:
13
13
  @app.command("asgi")
14
14
  def run_asgi(
15
- app: Annotated[str, typer.Argument(..., help="ASGI application")],
16
15
  identity_file: Annotated[Path, typer.Argument(..., help="Identity json file")],
16
+ app: Annotated[str, typer.Argument(..., help="ASGI application")],
17
17
  workers: Annotated[
18
18
  int,
19
19
  typer.Option(
@@ -30,7 +30,7 @@ def register(app: typer.Typer) -> None:
30
30
  int,
31
31
  typer.Option(
32
32
  "--port",
33
- "-P",
33
+ "-p",
34
34
  help="Port to bind to. Default: 8000",
35
35
  show_default=True,
36
36
  ),
mrok/cli/utils.py CHANGED
@@ -1,5 +1,31 @@
1
1
  import multiprocessing
2
+ import re
3
+
4
+ import typer
5
+
6
+ from mrok.conf import get_settings
2
7
 
3
8
 
4
9
  def number_of_workers() -> int:
5
10
  return (multiprocessing.cpu_count() * 2) + 1
11
+
12
+
13
+ def validate_identifier(regex_exp: str, format: str, identifier: str) -> str:
14
+ match = re.fullmatch(regex_exp, identifier)
15
+ if not match:
16
+ raise typer.BadParameter(f"it must match {format}")
17
+ return identifier
18
+
19
+
20
+ def validate_extension_id(extension_id: str) -> str:
21
+ settings = get_settings()
22
+ return validate_identifier(
23
+ settings.identifiers.extension.regex, settings.identifiers.extension.format, extension_id
24
+ )
25
+
26
+
27
+ def validate_instance_id(instance_id: str) -> str:
28
+ settings = get_settings()
29
+ return validate_identifier(
30
+ settings.identifiers.instance.regex, settings.identifiers.instance.format, instance_id
31
+ )
mrok/conf.py CHANGED
@@ -7,19 +7,27 @@ DEFAULT_SETTINGS = {
7
7
  "debug": False,
8
8
  "rich": False,
9
9
  },
10
- "PROXY": {
10
+ "FRONTEND": {
11
11
  "identity": "public",
12
12
  "mode": "zrok",
13
13
  },
14
14
  "ZITI": {
15
15
  "ssl_verify": False,
16
16
  },
17
- "PAGINATION": {"limit": 50},
18
- "SIDECAR": {
19
- "textual_port": 4040,
20
- "store_port": 5051,
21
- "store_size": 1000,
22
- "textual_command": "python mrok/agent/sidecar/inspector.py",
17
+ "CONTROLLER": {
18
+ "pagination": {"limit": 50},
19
+ },
20
+ "IDENTIFIERS": {
21
+ "extension": {
22
+ "regex": "(?i)EXT-\\d{4}-\\d{4}",
23
+ "format": "EXT-xxxx-yyyy",
24
+ "example": "EXT-2000-1000",
25
+ },
26
+ "instance": {
27
+ "regex": "(?i)INS-\\d{4}-\\d{4}-\\d{4}",
28
+ "format": "INS-xxxx-yyyy-zzzz",
29
+ "example": "INS-2004-2000-3000",
30
+ },
23
31
  },
24
32
  }
25
33
 
mrok/controller/app.py CHANGED
@@ -5,8 +5,8 @@ import fastapi_pagination
5
5
  from fastapi import Depends, FastAPI
6
6
  from fastapi.routing import APIRoute, APIRouter
7
7
 
8
- from mrok.conf import get_settings
9
- from mrok.controller.auth import authenticate
8
+ from mrok.conf import Settings, get_settings
9
+ from mrok.controller.auth import HTTPAuthManager
10
10
  from mrok.controller.openapi import generate_openapi_spec
11
11
  from mrok.controller.routes.extensions import router as extensions_router
12
12
  from mrok.controller.routes.instances import router as instances_router
@@ -36,7 +36,8 @@ def setup_custom_serialization(router: APIRouter):
36
36
  api_route.response_model_exclude_none = True
37
37
 
38
38
 
39
- def setup_app():
39
+ def setup_app(settings: Settings):
40
+ auth_manager = HTTPAuthManager(settings.controller.auth)
40
41
  app = FastAPI(
41
42
  title="mrok Controller API",
42
43
  description="API to orchestrate OpenZiti for Extensions.",
@@ -49,22 +50,23 @@ def setup_app():
49
50
 
50
51
  setup_custom_serialization(extensions_router)
51
52
 
52
- # TODO: Add healthcheck
53
+ @app.get("/healthcheck")
54
+ async def healthcheck():
55
+ return {"status": "healthy"}
56
+
53
57
  app.include_router(
54
58
  extensions_router,
55
59
  prefix="/extensions",
56
- dependencies=[Depends(authenticate)],
60
+ dependencies=[Depends(auth_manager)],
57
61
  )
58
62
  app.include_router(
59
63
  instances_router,
60
64
  prefix="/instances",
61
- dependencies=[Depends(authenticate)],
65
+ dependencies=[Depends(auth_manager)],
62
66
  )
63
67
 
64
- settings = get_settings()
65
-
66
- app.openapi = partial(generate_openapi_spec, app, settings)
68
+ app.openapi = partial(generate_openapi_spec, app, settings) # type: ignore[method-assign]
67
69
  return app
68
70
 
69
71
 
70
- app = setup_app()
72
+ app = setup_app(get_settings())
@@ -0,0 +1,11 @@
1
+ from mrok.controller.auth.backends import OIDCJWTAuthenticationBackend # noqa: F401
2
+ from mrok.controller.auth.base import AuthIdentity, BaseHTTPAuthBackend
3
+ from mrok.controller.auth.manager import HTTPAuthManager
4
+ from mrok.controller.auth.registry import register_authentication_backend
5
+
6
+ __all__ = [
7
+ "AuthIdentity",
8
+ "BaseHTTPAuthBackend",
9
+ "HTTPAuthManager",
10
+ "register_authentication_backend",
11
+ ]
@@ -0,0 +1,60 @@
1
+ import logging
2
+
3
+ import httpx
4
+ import jwt
5
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
+ from fastapi.security.http import HTTPBase
7
+
8
+ from mrok.controller.auth.base import UNAUTHORIZED_EXCEPTION, AuthIdentity, BaseHTTPAuthBackend
9
+ from mrok.controller.auth.registry import register_authentication_backend
10
+
11
+ logger = logging.getLogger("mrok.controller")
12
+
13
+
14
+ @register_authentication_backend("oidc")
15
+ class OIDCJWTAuthenticationBackend(BaseHTTPAuthBackend):
16
+ def init_scheme(self) -> HTTPBase:
17
+ return HTTPBearer(auto_error=False)
18
+
19
+ async def authenticate(self, credentials: HTTPAuthorizationCredentials) -> AuthIdentity | None:
20
+ async with httpx.AsyncClient() as client:
21
+ try:
22
+ config_resp = await client.get(self.config.config_url)
23
+ config_resp.raise_for_status()
24
+ config = config_resp.json()
25
+ issuer = config["issuer"]
26
+ jwks_uri = config["jwks_uri"]
27
+
28
+ jwks_resp = await client.get(jwks_uri)
29
+ jwks_resp.raise_for_status()
30
+ jwks = jwks_resp.json()
31
+
32
+ header = jwt.get_unverified_header(credentials.credentials)
33
+ kid = header["kid"]
34
+
35
+ key_data = next((k for k in jwks["keys"] if k["kid"] == kid), None)
36
+ except Exception:
37
+ logger.exception("Error fetching openid-config/jwks")
38
+ raise UNAUTHORIZED_EXCEPTION
39
+ if key_data is None:
40
+ logger.error("Key ID not found in JWKS")
41
+ raise UNAUTHORIZED_EXCEPTION
42
+
43
+ try:
44
+ payload = jwt.decode(
45
+ credentials.credentials,
46
+ jwt.PyJWK(key_data),
47
+ algorithms=[header["alg"]],
48
+ issuer=issuer,
49
+ audience=self.config.audience,
50
+ )
51
+ return AuthIdentity(
52
+ subject=payload["sub"],
53
+ metadata=payload,
54
+ )
55
+ except jwt.InvalidKeyError as e:
56
+ logger.error(f"Invalid jwt token: {e} ({credentials.credentials})")
57
+ raise UNAUTHORIZED_EXCEPTION
58
+ except jwt.InvalidTokenError as e:
59
+ logger.error(f"Invalid jwt token: {e} ({credentials.credentials})")
60
+ raise UNAUTHORIZED_EXCEPTION
@@ -0,0 +1,38 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from dynaconf.utils.boxing import DynaBox
5
+ from fastapi import HTTPException, Request, status
6
+ from fastapi.security import HTTPAuthorizationCredentials
7
+ from fastapi.security.http import HTTPBase
8
+ from pydantic import BaseModel
9
+
10
+ UNAUTHORIZED_EXCEPTION = HTTPException(
11
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized."
12
+ )
13
+
14
+
15
+ class AuthIdentity(BaseModel):
16
+ subject: str
17
+ scopes: list[str] = []
18
+ metadata: dict[str, Any] = {}
19
+
20
+
21
+ class BaseHTTPAuthBackend(ABC):
22
+ def __init__(self, config: DynaBox):
23
+ self.config = config
24
+ self.scheme = self.init_scheme()
25
+
26
+ @abstractmethod
27
+ def init_scheme(self) -> HTTPBase:
28
+ raise NotImplementedError()
29
+
30
+ @abstractmethod
31
+ async def authenticate(self, credentials: HTTPAuthorizationCredentials) -> AuthIdentity | None:
32
+ raise NotImplementedError()
33
+
34
+ async def __call__(self, request: Request) -> AuthIdentity | None:
35
+ credentials = await self.scheme(request)
36
+ if not credentials:
37
+ return None
38
+ return await self.authenticate(credentials)
@@ -0,0 +1,31 @@
1
+ from dynaconf.utils.boxing import DynaBox
2
+ from fastapi import Request
3
+
4
+ from mrok.controller.auth.base import UNAUTHORIZED_EXCEPTION, AuthIdentity, BaseHTTPAuthBackend
5
+ from mrok.controller.auth.registry import get_authentication_backend
6
+
7
+
8
+ class HTTPAuthManager:
9
+ def __init__(self, auth_settings: DynaBox):
10
+ self.auth_settings = auth_settings
11
+ self.active_backends: list[BaseHTTPAuthBackend] = []
12
+ self._setup_backends()
13
+
14
+ def _setup_backends(self):
15
+ enabled_keys = self.auth_settings.get("backends", [])
16
+
17
+ for key in enabled_keys:
18
+ backend_cls = get_authentication_backend(key)
19
+ if not backend_cls:
20
+ raise ValueError(f"Backend '{key}' is not registered.")
21
+
22
+ specific_config = self.auth_settings.get(key, {})
23
+ self.active_backends.append(backend_cls(specific_config))
24
+
25
+ async def __call__(self, request: Request) -> AuthIdentity:
26
+ for backend in self.active_backends:
27
+ identity = await backend(request)
28
+ if identity:
29
+ return identity
30
+
31
+ raise UNAUTHORIZED_EXCEPTION
@@ -0,0 +1,17 @@
1
+ from mrok.controller.auth.base import BaseHTTPAuthBackend
2
+
3
+ BACKEND_REGISTRY: dict[str, type[BaseHTTPAuthBackend]] = {}
4
+
5
+
6
+ def register_authentication_backend(name: str):
7
+ """Decorator to register a backend class with a unique key."""
8
+
9
+ def decorator(cls: type[BaseHTTPAuthBackend]):
10
+ BACKEND_REGISTRY[name] = cls
11
+ return cls
12
+
13
+ return decorator
14
+
15
+
16
+ def get_authentication_backend(name: str) -> type[BaseHTTPAuthBackend] | None:
17
+ return BACKEND_REGISTRY.get(name)
mrok/frontend/app.py CHANGED
@@ -1,14 +1,21 @@
1
- import re
1
+ from http import HTTPStatus
2
+ from pathlib import Path
3
+ from typing import Any
2
4
 
3
5
  from httpcore import AsyncConnectionPool
6
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
4
7
 
5
8
  from mrok.conf import get_settings
9
+ from mrok.frontend.utils import get_target_name, parse_accept_header
6
10
  from mrok.proxy.app import ProxyAppBase
7
11
  from mrok.proxy.backend import AIOZitiNetworkBackend
8
12
  from mrok.proxy.exceptions import InvalidTargetError
9
- from mrok.types.proxy import Scope
13
+ from mrok.types.proxy import ASGISend, Scope
10
14
 
11
- RE_SUBDOMAIN = re.compile(r"(?i)^(?:EXT-\d{4}-\d{4}|INS-\d{4}-\d{4}-\d{4})$")
15
+ ERROR_TEMPLATE_FORMATS = {
16
+ "application/json": "json",
17
+ "text/html": "html",
18
+ }
12
19
 
13
20
 
14
21
  class FrontendProxyApp(ProxyAppBase):
@@ -19,10 +26,11 @@ class FrontendProxyApp(ProxyAppBase):
19
26
  max_connections: int | None = 10,
20
27
  max_keepalive_connections: int | None = None,
21
28
  keepalive_expiry: float | None = None,
22
- retries=0,
29
+ retries: int = 0,
23
30
  ):
24
31
  self._identity_file = identity_file
25
- self._proxy_domain = self._get_proxy_domain()
32
+ self._jinja_env_cache: dict[Path, Environment] = {}
33
+ self._templates_by_error = get_settings().frontend.get("errors", {})
26
34
  super().__init__(
27
35
  max_connections=max_connections,
28
36
  max_keepalive_connections=max_keepalive_connections,
@@ -46,30 +54,90 @@ class FrontendProxyApp(ProxyAppBase):
46
54
  )
47
55
 
48
56
  def get_upstream_base_url(self, scope: Scope) -> str:
49
- target = self._get_target_name(
57
+ target = get_target_name(
50
58
  {k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", {})}
51
59
  )
60
+ if not target:
61
+ raise InvalidTargetError()
62
+
52
63
  return f"http://{target.lower()}"
53
64
 
54
- def _get_proxy_domain(self):
55
- settings = get_settings()
56
- return (
57
- settings.proxy.domain
58
- if settings.proxy.domain[0] == "."
59
- else f".{settings.proxy.domain}"
60
- )
65
+ async def send_error_response(
66
+ self,
67
+ scope: Scope,
68
+ send: ASGISend,
69
+ http_status: int,
70
+ body: str,
71
+ headers: list[tuple[bytes, bytes]] | None = None,
72
+ ):
73
+ request_headers = {
74
+ k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", {})
75
+ }
76
+ accept_header = request_headers.get("accept")
77
+ if not (accept_header and str(http_status) in self._templates_by_error):
78
+ return await super().send_error_response(scope, send, http_status, body)
61
79
 
62
- def _get_target_from_header(self, headers: dict[str, str], name: str) -> str | None:
63
- header_value = headers.get(name, "")
64
- if self._proxy_domain in header_value:
65
- if ":" in header_value:
66
- header_value, _ = header_value.split(":", 1)
67
- return header_value[: -len(self._proxy_domain)]
80
+ available_templates = self._templates_by_error[str(http_status)]
68
81
 
69
- def _get_target_name(self, headers: dict[str, str]) -> str:
70
- target = self._get_target_from_header(headers, "x-forwarded-host")
71
- if not target:
72
- target = self._get_target_from_header(headers, "host")
73
- if not target or not RE_SUBDOMAIN.fullmatch(target):
74
- raise InvalidTargetError()
75
- return target
82
+ media_types = parse_accept_header(accept_header)
83
+ for media_type in media_types:
84
+ template_format = ERROR_TEMPLATE_FORMATS.get(media_type)
85
+ if template_format and template_format in available_templates:
86
+ template_path = available_templates[template_format]
87
+ rendered = await self._render_error_template(
88
+ Path(template_path), scope, http_status, body
89
+ )
90
+ return await super().send_error_response(
91
+ scope,
92
+ send,
93
+ http_status,
94
+ rendered,
95
+ headers=[(b"content-type", media_type.encode("latin-1"))],
96
+ )
97
+
98
+ return await super().send_error_response(scope, send, http_status, body)
99
+
100
+ async def _render_error_template(
101
+ self, template_path: Path, scope: Scope, http_status: int, body: str
102
+ ) -> str:
103
+ env = self._get_jinja_env(template_path)
104
+ template = env.get_template(template_path.name)
105
+ status_title = HTTPStatus(http_status).name.replace("_", " ").title()
106
+ context = {
107
+ "status": http_status,
108
+ "status_title": status_title,
109
+ "body": body,
110
+ "request": self._extract_request_context(scope),
111
+ }
112
+
113
+ return await template.render_async(context)
114
+
115
+ def _extract_request_context(self, scope: Scope) -> dict[str, Any]:
116
+ headers = {k.decode("latin-1"): v.decode("latin-1") for k, v in scope.get("headers", [])}
117
+
118
+ return {
119
+ "method": scope.get("method"),
120
+ "path": scope.get("path"),
121
+ "raw_path": scope.get("raw_path", b"").decode("latin-1"),
122
+ "query_string": scope.get("query_string", b"").decode("latin-1"),
123
+ "scheme": scope.get("scheme"),
124
+ "headers": headers,
125
+ "client": scope.get("client"),
126
+ "server": scope.get("server"),
127
+ "http_version": scope.get("http_version"),
128
+ }
129
+
130
+ def _get_jinja_env(self, template_path: Path) -> Environment:
131
+ template_dir = template_path.parent
132
+
133
+ if template_dir not in self._jinja_env_cache:
134
+ self._jinja_env_cache[template_dir] = Environment(
135
+ loader=FileSystemLoader(str(template_dir)),
136
+ autoescape=select_autoescape(
137
+ enabled_extensions=("html", "xml"),
138
+ default_for_string=False,
139
+ ),
140
+ enable_async=True,
141
+ )
142
+
143
+ return self._jinja_env_cache[template_dir]
mrok/frontend/main.py CHANGED
@@ -7,6 +7,7 @@ from uvicorn_worker import UvicornWorker
7
7
 
8
8
  from mrok.conf import get_settings
9
9
  from mrok.frontend.app import FrontendProxyApp
10
+ from mrok.frontend.middleware import HealthCheckMiddleware
10
11
  from mrok.logging import get_logging_config
11
12
 
12
13
 
@@ -42,11 +43,13 @@ def run(
42
43
  max_keepalive_connections: int | None,
43
44
  keepalive_expiry: float | None,
44
45
  ):
45
- app = FrontendProxyApp(
46
- str(identity_file),
47
- max_connections=max_connections,
48
- max_keepalive_connections=max_keepalive_connections,
49
- keepalive_expiry=keepalive_expiry,
46
+ app = HealthCheckMiddleware(
47
+ FrontendProxyApp(
48
+ str(identity_file),
49
+ max_connections=max_connections,
50
+ max_keepalive_connections=max_keepalive_connections,
51
+ keepalive_expiry=keepalive_expiry,
52
+ )
50
53
  )
51
54
 
52
55
  options = {
@@ -0,0 +1,35 @@
1
+ import json
2
+
3
+ from mrok.frontend.utils import get_target_name
4
+ from mrok.types.proxy import ASGIApp, ASGIReceive, ASGISend, Scope
5
+
6
+
7
+ class HealthCheckMiddleware:
8
+ def __init__(self, app: ASGIApp):
9
+ self.app = app
10
+
11
+ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend):
12
+ if scope["type"] == "http" and scope["path"] == "/healthcheck":
13
+ target = get_target_name(
14
+ {k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", {})}
15
+ )
16
+
17
+ if not target:
18
+ await send(
19
+ {
20
+ "type": "http.response.start",
21
+ "status": 200,
22
+ "headers": [
23
+ [b"content-type", b"application/json"],
24
+ ],
25
+ }
26
+ )
27
+ await send(
28
+ {
29
+ "type": "http.response.body",
30
+ "body": json.dumps({"status": "healthy"}).encode("utf-8"),
31
+ }
32
+ )
33
+ return
34
+
35
+ await self.app(scope, receive, send)
mrok/frontend/utils.py ADDED
@@ -0,0 +1,83 @@
1
+ import re
2
+
3
+ from mrok.conf import get_settings
4
+
5
+
6
+ def parse_accept_header(accept: str | None) -> list[str]:
7
+ if not accept:
8
+ return ["*/*"]
9
+
10
+ result: list[tuple[str, float, int]] = []
11
+
12
+ for index, item in enumerate(accept.split(",")):
13
+ item = item.strip()
14
+ if not item:
15
+ continue
16
+
17
+ parts = [p.strip() for p in item.split(";")]
18
+ media_type = parts[0].lower()
19
+
20
+ q = 1.0
21
+ for param in parts[1:]:
22
+ if param.startswith("q="):
23
+ try:
24
+ q = float(param[2:])
25
+ except ValueError:
26
+ q = 0.0
27
+
28
+ result.append((media_type, q, index))
29
+
30
+ # Sort by:
31
+ # 1) q value (desc)
32
+ # 2) specificity (more specific first)
33
+ # 3) original order (stable)
34
+ result.sort(
35
+ key=lambda x: (
36
+ -x[1],
37
+ -_media_type_specificity(x[0]),
38
+ x[2],
39
+ )
40
+ )
41
+
42
+ return [media_type for media_type, _, _ in result]
43
+
44
+
45
+ def _media_type_specificity(media_type: str) -> int:
46
+ if media_type == "*/*":
47
+ return 0
48
+ if media_type.endswith("/*"):
49
+ return 1
50
+ return 2
51
+
52
+
53
+ def get_frontend_domain():
54
+ settings = get_settings()
55
+ return (
56
+ settings.frontend.domain
57
+ if settings.frontend.domain[0] == "."
58
+ else f".{settings.frontend.domain}"
59
+ )
60
+
61
+
62
+ def _get_target_from_header(headers: dict[str, str], name: str) -> str | None:
63
+ domain_name = get_frontend_domain()
64
+ header_value = headers.get(name, "")
65
+ if domain_name in header_value:
66
+ if ":" in header_value:
67
+ header_value, _ = header_value.split(":", 1)
68
+ return header_value[: -len(domain_name)]
69
+
70
+
71
+ def get_target_name(headers: dict[str, str]) -> str | None:
72
+ settings = get_settings()
73
+
74
+ target = _get_target_from_header(headers, "x-forwarded-host")
75
+ if not target:
76
+ target = _get_target_from_header(headers, "host")
77
+
78
+ if target and (
79
+ re.fullmatch(settings.identifiers.extension.regex, target)
80
+ or re.fullmatch(settings.identifiers.instance.regex, target)
81
+ ):
82
+ return target
83
+ return None
mrok/logging.py CHANGED
@@ -1,8 +1,14 @@
1
+ import logging
1
2
  import logging.config
2
3
 
3
4
  from mrok.conf import Settings
4
5
 
5
6
 
7
+ class HealthCheckFilter(logging.Filter):
8
+ def filter(self, record):
9
+ return "/healthcheck" not in record.getMessage()
10
+
11
+
6
12
  def get_logging_config(settings: Settings, cli_mode: bool = False) -> dict:
7
13
  log_level = "DEBUG" if settings.logging.debug else "INFO"
8
14
  handler = "rich" if settings.logging.rich else "console"
@@ -26,6 +32,11 @@ def get_logging_config(settings: Settings, cli_mode: bool = False) -> dict:
26
32
  },
27
33
  "plain": {"format": "%(message)s"},
28
34
  },
35
+ "filters": {
36
+ "healthcheck_filter": {
37
+ "()": HealthCheckFilter,
38
+ }
39
+ },
29
40
  "handlers": {
30
41
  "console": {
31
42
  "class": "logging.StreamHandler",
@@ -54,17 +65,30 @@ def get_logging_config(settings: Settings, cli_mode: bool = False) -> dict:
54
65
  "handlers": [handler],
55
66
  "level": log_level,
56
67
  "propagate": False,
68
+ "filters": ["healthcheck_filter"],
57
69
  },
58
70
  "gunicorn.error": {
59
71
  "handlers": [handler],
60
72
  "level": log_level,
61
73
  "propagate": False,
62
74
  },
75
+ "uvicorn.access": {
76
+ "handlers": [handler],
77
+ "level": log_level,
78
+ "propagate": False,
79
+ "filters": ["healthcheck_filter"],
80
+ },
63
81
  "mrok": {
64
82
  "handlers": [mrok_handler],
65
83
  "level": log_level,
66
84
  "propagate": False,
67
85
  },
86
+ "mrok.access": {
87
+ "handlers": [mrok_handler],
88
+ "level": log_level,
89
+ "propagate": False,
90
+ "filters": ["healthcheck_filter"],
91
+ },
68
92
  },
69
93
  }
70
94
 
mrok/proxy/app.py CHANGED
@@ -57,7 +57,7 @@ class ProxyAppBase(abc.ABC):
57
57
  return
58
58
 
59
59
  if scope.get("type") != "http":
60
- await self._send_error(send, 500, "Unsupported")
60
+ await self.send_error_response(scope, send, 500, "Unsupported")
61
61
  return
62
62
 
63
63
  try:
@@ -105,15 +105,23 @@ class ProxyAppBase(abc.ABC):
105
105
  await response.aclose()
106
106
 
107
107
  except ProxyError as pe:
108
- await self._send_error(send, pe.http_status, pe.message)
108
+ await self.send_error_response(scope, send, pe.http_status, pe.message)
109
109
 
110
110
  except Exception:
111
111
  logger.exception("Unexpected error in forwarder")
112
- await self._send_error(send, 502, "Bad Gateway")
112
+ await self.send_error_response(scope, send, 502, "Bad Gateway")
113
113
 
114
- async def _send_error(self, send: ASGISend, http_status: int, body: str):
114
+ async def send_error_response(
115
+ self,
116
+ scope: Scope,
117
+ send: ASGISend,
118
+ http_status: int,
119
+ body: str,
120
+ headers: list[tuple[bytes, bytes]] | None = None,
121
+ ):
122
+ headers = headers or [(b"content-type", b"text/plain")]
115
123
  try:
116
- await send({"type": "http.response.start", "status": http_status, "headers": []})
124
+ await send({"type": "http.response.start", "status": http_status, "headers": headers})
117
125
  await send({"type": "http.response.body", "body": body.encode()})
118
126
  except Exception as e: # pragma: no cover
119
127
  logger.error(f"Cannot send error response: {e}")
mrok/ziti/api.py CHANGED
@@ -38,7 +38,7 @@ class ZitiBadRequestError(ZitiAPIError):
38
38
  class BaseZitiAPI(ABC):
39
39
  def __init__(self, settings: Settings):
40
40
  self.settings = settings
41
- self.limit = self.settings.pagination.limit
41
+ self.limit = self.settings.controller.pagination.limit
42
42
  self.token = None
43
43
 
44
44
  @property
@@ -263,7 +263,7 @@ class ZitiIdentityAuth(BaseZitiAuth):
263
263
  class ZitiManagementAPI(BaseZitiAPI):
264
264
  @property
265
265
  def base_url(self):
266
- return f"{self.settings.ziti.api.management}/edge/management/v1"
266
+ return f"{self.settings.ziti.base_urls.management}/edge/management/v1"
267
267
 
268
268
  def services(
269
269
  self,
@@ -465,7 +465,7 @@ class ZitiManagementAPI(BaseZitiAPI):
465
465
  class ZitiClientAPI(BaseZitiAPI):
466
466
  @property
467
467
  def base_url(self):
468
- return f"{self.settings.ziti.api.client}/edge/client/v1"
468
+ return f"{self.settings.ziti.base_urls.client}/edge/client/v1"
469
469
 
470
470
  async def enroll_identity(self, jti: str, csr_pem: str) -> dict[str, Any]:
471
471
  response = await self.httpx_client.post(
mrok/ziti/identities.py CHANGED
@@ -69,7 +69,7 @@ async def register_identity(
69
69
  mrok={
70
70
  "extension": service_name,
71
71
  "instance": identity_name,
72
- "domain": settings.proxy.domain,
72
+ "domain": settings.frontend.domain,
73
73
  "tags": identity_tags,
74
74
  },
75
75
  )
mrok/ziti/services.py CHANGED
@@ -19,15 +19,15 @@ async def register_service(
19
19
  ) -> dict[str, Any]:
20
20
  service_name = external_id.lower()
21
21
  registered = False
22
- proxy_identity = await mgmt_api.search_identity(settings.proxy.identity)
22
+ proxy_identity = await mgmt_api.search_identity(settings.frontend.identity)
23
23
  if not proxy_identity:
24
24
  raise ProxyIdentityNotFoundError(
25
- f"Identity for proxy `{settings.proxy.identity}` not found.",
25
+ f"Identity for proxy `{settings.frontend.identity}` not found.",
26
26
  )
27
27
 
28
- config_type = await mgmt_api.search_config_type(f"{settings.proxy.mode}.proxy.v1")
28
+ config_type = await mgmt_api.search_config_type(f"{settings.frontend.mode}.proxy.v1")
29
29
  if not config_type:
30
- raise ConfigTypeNotFoundError(f"Config type `{settings.proxy.mode}.proxy.v1` not found.")
30
+ raise ConfigTypeNotFoundError(f"Config type `{settings.frontend.mode}.proxy.v1` not found.")
31
31
 
32
32
  config = await mgmt_api.search_config(service_name)
33
33
  if not config:
@@ -43,7 +43,7 @@ async def register_service(
43
43
  else:
44
44
  service_id = service["id"]
45
45
  proxy_identity_id = proxy_identity["id"]
46
- service_policy_name = f"{service_name}:{settings.proxy.identity}:dial"
46
+ service_policy_name = f"{service_name}:{settings.frontend.identity}:dial"
47
47
  dial_service_policy = await mgmt_api.search_service_policy(service_policy_name)
48
48
  if not dial_service_policy:
49
49
  await mgmt_api.create_dial_service_policy(
@@ -75,7 +75,7 @@ async def unregister_service(
75
75
  if router_policy:
76
76
  await mgmt_api.delete_service_router_policy(router_policy["id"])
77
77
 
78
- service_policy_name = f"{service_name}:{settings.proxy.identity}:dial"
78
+ service_policy_name = f"{service_name}:{settings.frontend.identity}:dial"
79
79
 
80
80
  dial_service_policy = await mgmt_api.search_service_policy(service_policy_name)
81
81
  if dial_service_policy:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mrok
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: MPT Extensions OpenZiti Orchestrator
5
5
  Author: SoftwareOne AG
6
6
  License: Apache License
@@ -225,7 +225,7 @@ Requires-Dist: pyzmq<28.0.0,>=27.1.0
225
225
  Requires-Dist: rich<15.0.0,>=14.1.0
226
226
  Requires-Dist: textual-serve<2.0.0,>=1.1.3
227
227
  Requires-Dist: textual[syntax]<8.0.0,>=7.2.0
228
- Requires-Dist: typer<0.20.0,>=0.19.2
228
+ Requires-Dist: typer<1.0.0,>=0.21.1
229
229
  Requires-Dist: uvicorn-worker<0.5.0,>=0.4.0
230
230
  Description-Content-Type: text/markdown
231
231
 
@@ -1,13 +1,13 @@
1
1
  mrok/__init__.py,sha256=D1PUs3KtMCqG4bFLceVNG62L3RN53NS95uSCNXpgvzs,181
2
- mrok/conf.py,sha256=_5Z-A5LyojQeY8J7W8C0QidsmrPl99r9qKYEoMf4kcI,840
2
+ mrok/conf.py,sha256=5AgRgwE_Yq0Dv7xDv0SWakhPSr3nnUNjIIgnn0Zgf9c,1057
3
3
  mrok/constants.py,sha256=QXaMw4LuHijj_TUTCsM5uUjpgT04HBvd0wRbjvn1z9A,449
4
4
  mrok/errors.py,sha256=ruNMDFr2_0ezCGXuCG1OswCEv-bHOIzMMd02J_0ABcs,37
5
- mrok/logging.py,sha256=4F5rviPK1-MWWMZuHfzNNQmGxg-emAPRdKz0PsWDSww,2261
5
+ mrok/logging.py,sha256=PS_x0uAQDsIPF_tbF11fIyijwLS03DQwJzzUHMdVdnE,3000
6
6
  mrok/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  mrok/agent/ziticorn.py,sha256=eHUYs9QaSp35rBzYHRV-SrYxF5ySyECaQg7U-XbdINE,1025
8
8
  mrok/agent/devtools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  mrok/agent/devtools/inspector/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- mrok/agent/devtools/inspector/__main__.py,sha256=MyaIi81D-ubdxuvLV1mCpA8cSVwtq07zc5xuArPU2Dw,73
10
+ mrok/agent/devtools/inspector/__main__.py,sha256=vof04S9fwiU8lGjt7YiM6O9YOXEW_lT1AxGoXyF4bg8,97
11
11
  mrok/agent/devtools/inspector/app.py,sha256=TxBBIy8lXHj6h8KmQHCeLiIM2QqMzMsCpSWFjH8cGu4,25636
12
12
  mrok/agent/devtools/inspector/server.py,sha256=C4uD6_1psSHMjJLUDCMPGvKdQYKaEwYTw27NAbwuuA0,636
13
13
  mrok/agent/devtools/inspector/utils.py,sha256=K-_4rTyB54Y0faEIGgutMEHGyP1W7eOhbcuUKxNnsYA,4261
@@ -17,37 +17,41 @@ mrok/agent/sidecar/main.py,sha256=jeJzrCbltfXOYsKSCjcw8h5lxh4_bGT87kCC5dV4kYU,21
17
17
  mrok/cli/__init__.py,sha256=mtFEa8IeS1x6Gm4dUYoSnAxyEzNqbUVSmWxtuZUMR84,61
18
18
  mrok/cli/main.py,sha256=T029FYxK_jDrwiA14oX-Onoqp_X14XHRAA_4bbaOgV8,3123
19
19
  mrok/cli/rich.py,sha256=P3Dyu8EArUR9_0j7DPK7LRx85TWdYdZ1SaJzD_S1ZCE,511
20
- mrok/cli/utils.py,sha256=m_olScdIUGks5IoC6p2F9D6CQIucWZ7LHyrvwm2bkJw,106
20
+ mrok/cli/utils.py,sha256=FXqyNef0cRFy_d-63ZY-kT3uqmDOK3USSNgBSZyKXIE,831
21
21
  mrok/cli/commands/__init__.py,sha256=-UOGzh38oWX7fPeI2nc5I9z8LylRdQAt868q4G6rNGk,140
22
22
  mrok/cli/commands/admin/__init__.py,sha256=WU49jpMF9p18UONjYywWEFzjF57zLpLKJ0qAZvrzcR4,414
23
- mrok/cli/commands/admin/bootstrap.py,sha256=9ADSeiVbFAZXh6GxHEf9h2g_XHGOIlMmg1rgsxMfdow,1699
23
+ mrok/cli/commands/admin/bootstrap.py,sha256=McIGngjpRQSsI2CqW_LBTF1lBeGXvTbbt31jYS35xIY,1705
24
24
  mrok/cli/commands/admin/utils.py,sha256=Z7YTAFZKOi6nkw2oX4rJoGoUD41RYL3AOqEDhlV3jR0,1357
25
25
  mrok/cli/commands/admin/list/__init__.py,sha256=kjCMcpn1gopcrQaaHxfFh8Kyngldepnle8R2br5dJ_0,195
26
26
  mrok/cli/commands/admin/list/extensions.py,sha256=16fhDB5ucL8su2WQnSaQ1E6MhgC4vkP9-nuHAcPpzyE,4405
27
27
  mrok/cli/commands/admin/list/instances.py,sha256=kaqeyidwUxgYqfaHXqp2m76rm5h2ErBsYyZcNeaBRwY,5912
28
28
  mrok/cli/commands/admin/register/__init__.py,sha256=5Jb_bc2L47MEpQIrOcquzduTFWQ01Jd1U1MpqaR-Ekw,209
29
- mrok/cli/commands/admin/register/extensions.py,sha256=dxciVA_S31rZSm0A7lkecn2mI9TMlWDhcJTgwgNXbM4,1460
30
- mrok/cli/commands/admin/register/instances.py,sha256=raF57jPUTryWdvNqGCosth1C-8jjv9IbA0UuNbDel3A,2220
29
+ mrok/cli/commands/admin/register/extensions.py,sha256=3ooHR-zfFImtqAZ-06kS0555v9gQLQ1G5-ARe_mJ9e4,1353
30
+ mrok/cli/commands/admin/register/instances.py,sha256=KjyLJX1mSXK-6ZmkW9I4PJFYNfgsOyAmJWh92XxSkYg,1982
31
31
  mrok/cli/commands/admin/unregister/__init__.py,sha256=-GjjCPX1pISbWmJK6GpKO3ijGsDQb21URjU1hNu99O4,215
32
- mrok/cli/commands/admin/unregister/extensions.py,sha256=GR3Iwzeksk_R0GkgmCSG7iHRcUrI7ABqDi25Gbes64Y,1016
33
- mrok/cli/commands/admin/unregister/instances.py,sha256=-28wL8pTXTWHVHtw93y8-dqi-Dlf0OZOnlBCKOyGo80,1138
32
+ mrok/cli/commands/admin/unregister/extensions.py,sha256=xL2yX0kn8dhitQL7NcLTn83bbPZfgPJNzHjZAdiP8yM,891
33
+ mrok/cli/commands/admin/unregister/instances.py,sha256=sLBfBhHDgR7Qw5Zc-EVOSuQUgfLMgh7cFnOP-73iM70,1167
34
34
  mrok/cli/commands/agent/__init__.py,sha256=ZAi7eTkKQtfwwV1c1mv3uvEEsyMMrhCQ_-id_0wksAQ,218
35
35
  mrok/cli/commands/agent/dev/__init__.py,sha256=ZfreyRuaLqO0AwPS8Ll1DIpsKacsu7_dTmbxV5QecOM,172
36
36
  mrok/cli/commands/agent/dev/console.py,sha256=rrKAGoKXVQQBOC75H0JSuX1sYyvc2QSrV-dfMPK49p4,673
37
37
  mrok/cli/commands/agent/dev/web.py,sha256=O9dYk-o1FV2E_sKLOezdEmLsnexwbJNDdsYL5pATZRQ,1028
38
38
  mrok/cli/commands/agent/run/__init__.py,sha256=E_IJCl3BfMffqFASe8gzJwhhQgt5bQfjhuyekVwdEBA,164
39
- mrok/cli/commands/agent/run/asgi.py,sha256=dCgzwJtTLv2eyEIP7v1tDfe_PrFBS02SfN5dSDw1Jzg,2054
39
+ mrok/cli/commands/agent/run/asgi.py,sha256=FzM3suWJPRqQ08SoDrF9mLfjiBJ6huSbfP3wkTbh3Uo,2054
40
40
  mrok/cli/commands/agent/run/sidecar.py,sha256=UOewegTLFwAZ70VFJb6_9kV0LmsvnXuq-yqgrMlTeZo,4182
41
41
  mrok/cli/commands/controller/__init__.py,sha256=2xw-YVN0akiLiuGUU3XbYyZZ0ugOjQ6XhtTkzEKSmMA,161
42
42
  mrok/cli/commands/controller/openapi.py,sha256=QLjVao9UkB2vBaGkFi_q_jrlg4Np4ldMRwDIJsrJ7A8,1175
43
43
  mrok/cli/commands/controller/run.py,sha256=yl1p7oRHhQINWWjUKlRHtMIWUCV0KsxYdyVyazhX834,2406
44
44
  mrok/cli/commands/frontend/__init__.py,sha256=0kK37yG6qs7yAa8TYlKZUA-nHrWsO4y5CjbVkXafnuk,123
45
- mrok/cli/commands/frontend/run.py,sha256=_X1ylMe4-YCTghsu0XY-PB4nk3PL-PQq9YIgbkgJok8,2796
45
+ mrok/cli/commands/frontend/run.py,sha256=E6vJC9LprGyPetGLfyfJm8GDemEIRVnqetao4V3W9Kk,2796
46
46
  mrok/controller/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
- mrok/controller/app.py,sha256=XxCIB7N1YE52vSYfvGW2UPgEEOZ9jxDMe2l9D2SfXi8,1866
48
- mrok/controller/auth.py,sha256=hYa0OPJ5X0beGxRP6qbxwJOVXj5TmzHjmam2OjTBKn4,2704
47
+ mrok/controller/app.py,sha256=JgyfEFbQeGLpHCrjFKQjdP8TRfV4YVec7Re8i0P4FE8,2040
49
48
  mrok/controller/pagination.py,sha256=raYpYa34q8Ckl4BXBOEdpWlKkFj6z7e6QLWr2HT7dzI,2187
50
49
  mrok/controller/schemas.py,sha256=PZPEsSJNrGSuplfjCPF_E-VJ721AzgR1Jj8P-Shw1cg,1699
50
+ mrok/controller/auth/__init__.py,sha256=st0q-NHQEQwYlvLEnQdonMVsDmczeL5cS4hkVK7NT6s,412
51
+ mrok/controller/auth/backends.py,sha256=xwiF7qFHh5okhqbTld4P2jSnFkSPZlGR4gfQu598Xrg,2288
52
+ mrok/controller/auth/base.py,sha256=NWEVtc9Y8I56NnyYrBiNzxHZxFSzVEbmkMY2u6RCANs,1131
53
+ mrok/controller/auth/manager.py,sha256=VQwov4UiAOXHBl_a9oPG90_QKRMNz2tWONF3JFR5RmM,1118
54
+ mrok/controller/auth/registry.py,sha256=VmwPPI6E2-oyB2MZDkK_G39EaAeU3Uq-b14GGKz1E-A,485
51
55
  mrok/controller/dependencies/__init__.py,sha256=voewk6gjkA0OarL6HFmfT_RLqBns0Fpl-VIqK5xVAEI,202
52
56
  mrok/controller/dependencies/conf.py,sha256=2Pa8fxJHkZ29q6UL-w6hUP_wr7WnNELfw5LlzWg1Tec,162
53
57
  mrok/controller/dependencies/ziti.py,sha256=fYoxeJb4s6p2_3gxbExbFSRabjpvp_gZMBb3ocXZV3Y,702
@@ -58,10 +62,12 @@ mrok/controller/routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
58
62
  mrok/controller/routes/extensions.py,sha256=zoY4sNz_BIZcbly6WXM7Rbpn2jmB89njS_0xdJkoKfs,9192
59
63
  mrok/controller/routes/instances.py,sha256=v-fn_F6JHbDZ4YUNCIZzClgHp6aC1Eu5HB7k7qBG5pk,2202
60
64
  mrok/frontend/__init__.py,sha256=SN3LoFwAye18lfJ8OKNNS-7kLc2A9OxPGIEIEYYtAOA,54
61
- mrok/frontend/app.py,sha256=I2cvEI2ZGhbeazFhF6LavxBYywsv-4QkuNxCDo6-dkA,2627
62
- mrok/frontend/main.py,sha256=0KtchIGLn70A_Oxekmhr_qSYUg5QqIMfrdYcyFdpj9s,1717
65
+ mrok/frontend/app.py,sha256=_FLz5fqZdlFc1tdBBMwhmvjGa0UnNeaNj6EnYEnuPAQ,5280
66
+ mrok/frontend/main.py,sha256=zvfCh7NDx-mkpN-ppM2AqWmgKn1cBrNOCky8UgjLou4,1833
67
+ mrok/frontend/middleware.py,sha256=xTXt5gYikr9RCXaI4uVKgxFhlH49RNY3MD3eZPcX9cc,1161
68
+ mrok/frontend/utils.py,sha256=Oh987pCpg7ZIIIpRWcpX7Nrh8FGbJ4cH4ciiG1xRoQI,2120
63
69
  mrok/proxy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
- mrok/proxy/app.py,sha256=flnVPoUO3pSF3b0nYFBhKjZ9jp5ljijo2a-5e37gACs,6016
70
+ mrok/proxy/app.py,sha256=HU-YRKA3ijY2AW9v-NwfUnbSgdI-XqsnkQ90TocZ1Ts,6257
65
71
  mrok/proxy/asgi.py,sha256=2uw5bLquyUsiYlNwq8RhJd8OqVvSJDvYjzOVGLLB3Cs,3528
66
72
  mrok/proxy/backend.py,sha256=dRmIUJin2DM3PUxrVX0j4t1oB6DOX7N9JV2lIcopE38,1649
67
73
  mrok/proxy/event_publisher.py,sha256=TAuwEqIhRYxgazJFgC3DekwUAXlJ2UFjbdx_A9vwA1g,2511
@@ -77,15 +83,15 @@ mrok/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
77
83
  mrok/types/proxy.py,sha256=40Yds4tUykMpzsoQbMtHG85r8xtm5Q3fQZ17bp7cDiM,818
78
84
  mrok/types/ziti.py,sha256=EeQnTbDEJ-Y-KMS6zu1Xjxb58Up2VxUwqzUwy3H28JY,36
79
85
  mrok/ziti/__init__.py,sha256=20OWMiexRhOovZOX19zlX87-V78QyWnEnSZfyAftUdE,263
80
- mrok/ziti/api.py,sha256=CRblQUyfX_421cppA9D-LRnjh4N9P7aYVK4fG7UzQE4,16102
86
+ mrok/ziti/api.py,sha256=Z6Hs17-UaKtcRc6uMowhvQW5fbyW6wFr-7lAvI3aZm0,16125
81
87
  mrok/ziti/bootstrap.py,sha256=RSL8nZfI-MZ_z6h0F-rNevQoE6g9oGKLr6HlZF696_c,2499
82
88
  mrok/ziti/constants.py,sha256=Urq1X3bCBQZfw8NbnEa1pqmY4oq1wmzkwPfzam3kbTw,339
83
89
  mrok/ziti/errors.py,sha256=yYCbVDwktnR0AYduqtynIjo73K3HOhIrwA_vQimvEd4,368
84
- mrok/ziti/identities.py,sha256=5-7Iof5a8SfkAYsy-SPirDEJKE68ZSn1Kw1wXYQbK9Q,6880
90
+ mrok/ziti/identities.py,sha256=cOMv-Jv8MEjtzyjRcWOF8Ziz4HJY2Z-uHXWpZRqgPxs,6883
85
91
  mrok/ziti/pki.py,sha256=o2tySqHC8-7bvFuI2Tqxg9vX6H6ZSxWxfP_9x29e19M,1954
86
- mrok/ziti/services.py,sha256=TukG0vAZxgjbS8OLiyg7u1GwuVeGTco-rb9ne6a4PUA,3213
87
- mrok-0.7.0.dist-info/METADATA,sha256=bswj6K1HG4vnUbkCpqQfuH6BoAgcyrMKY3U6XIotlnI,15979
88
- mrok-0.7.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
89
- mrok-0.7.0.dist-info/entry_points.txt,sha256=tloXwvU1uJicBJR2h-8HoVclPgwJWDwuREMHN8Zq-nU,38
90
- mrok-0.7.0.dist-info/licenses/LICENSE.txt,sha256=6PaICaoA3yNsZKLv5G6OKqSfLSoX7MakYqTDgJoTCBs,11346
91
- mrok-0.7.0.dist-info/RECORD,,
92
+ mrok/ziti/services.py,sha256=P2c9qRUyUFu1pSKPdT8L6s3yTKYVpTabRiHPWqbBIiU,3231
93
+ mrok-0.8.1.dist-info/METADATA,sha256=Lw5fmWKaa06srW-jKkloypL0XGalY6xR10_xzwYYJt0,15978
94
+ mrok-0.8.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
95
+ mrok-0.8.1.dist-info/entry_points.txt,sha256=tloXwvU1uJicBJR2h-8HoVclPgwJWDwuREMHN8Zq-nU,38
96
+ mrok-0.8.1.dist-info/licenses/LICENSE.txt,sha256=6PaICaoA3yNsZKLv5G6OKqSfLSoX7MakYqTDgJoTCBs,11346
97
+ mrok-0.8.1.dist-info/RECORD,,
mrok/controller/auth.py DELETED
@@ -1,87 +0,0 @@
1
- import logging
2
- from typing import Annotated
3
-
4
- import httpx
5
- import jwt
6
- from fastapi import Depends, HTTPException, Request, status
7
- from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
8
-
9
- from mrok.controller.dependencies.conf import AppSettings
10
-
11
- logger = logging.getLogger("mrok.controller")
12
-
13
- UNAUTHORIZED_EXCEPTION = HTTPException(
14
- status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized."
15
- )
16
-
17
-
18
- class JWTCredentials(HTTPAuthorizationCredentials):
19
- pass
20
-
21
-
22
- class JWTBearer(HTTPBearer):
23
- def __init__(self):
24
- super().__init__(auto_error=False)
25
-
26
- async def __call__(self, request: Request) -> JWTCredentials:
27
- credentials = await super().__call__(request)
28
- if not credentials:
29
- raise UNAUTHORIZED_EXCEPTION
30
- try:
31
- return JWTCredentials(
32
- scheme=credentials.scheme,
33
- credentials=credentials.credentials,
34
- )
35
- except jwt.InvalidTokenError:
36
- raise UNAUTHORIZED_EXCEPTION
37
-
38
-
39
- async def authenticate(
40
- settings: AppSettings,
41
- credentials: Annotated[JWTCredentials, Depends(JWTBearer())],
42
- ):
43
- async with httpx.AsyncClient(
44
- timeout=httpx.Timeout(
45
- connect=0.25,
46
- read=settings.auth.read_timeout,
47
- write=2.0,
48
- pool=5.0,
49
- ),
50
- ) as client:
51
- try:
52
- config_resp = await client.get(settings.auth.openid_config_url)
53
- config_resp.raise_for_status()
54
- config = config_resp.json()
55
- issuer = config["issuer"]
56
- jwks_uri = config["jwks_uri"]
57
-
58
- jwks_resp = await client.get(jwks_uri)
59
- jwks_resp.raise_for_status()
60
- jwks = jwks_resp.json()
61
-
62
- header = jwt.get_unverified_header(credentials.credentials)
63
- kid = header["kid"]
64
-
65
- key_data = next((k for k in jwks["keys"] if k["kid"] == kid), None)
66
- except Exception:
67
- logger.exception("Error fetching openid-config/jwks")
68
- raise UNAUTHORIZED_EXCEPTION
69
- if key_data is None:
70
- logger.error("Key ID not found in JWKS")
71
- raise UNAUTHORIZED_EXCEPTION
72
-
73
- try:
74
- payload = jwt.decode(
75
- credentials.credentials,
76
- jwt.PyJWK(key_data),
77
- algorithms=[header["alg"]],
78
- issuer=issuer,
79
- audience=settings.auth.audience,
80
- )
81
- return payload
82
- except jwt.InvalidKeyError as e:
83
- logger.error(f"Invalid jwt token: {e} ({credentials.credentials})")
84
- raise UNAUTHORIZED_EXCEPTION
85
- except jwt.InvalidTokenError as e:
86
- logger.error(f"Invalid jwt token: {e} ({credentials.credentials})")
87
- raise UNAUTHORIZED_EXCEPTION
File without changes