qodalis-cli-server-plugin-admin 1.0.0b1__tar.gz → 1.0.0b6__tar.gz
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.
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/PKG-INFO +2 -2
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/pyproject.toml +1 -1
- qodalis_cli_server_plugin_admin-1.0.0b6/qodalis_cli_admin/auth/auth_controller.py +188 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/auth/auth_middleware.py +23 -3
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/auth/jwt_service.py +5 -10
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/cli_admin_builder.py +40 -9
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/config_controller.py +2 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/dashboard_resolver.py +8 -10
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/services/admin_config.py +10 -3
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/services/log_ring_buffer.py +22 -5
- qodalis_cli_server_plugin_admin-1.0.0b6/qodalis_cli_admin/services/module_registry.py +113 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/spa_static_files.py +1 -1
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/auth/auth_controller.py +0 -103
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/services/module_registry.py +0 -64
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/.gitignore +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/auth/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/logs_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/plugins_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/status_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/ws_clients_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/services/__init__.py +0 -0
{qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: qodalis-cli-server-plugin-admin
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0b6
|
|
4
4
|
Summary: Admin dashboard plugin for Qodalis CLI servers
|
|
5
5
|
Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
|
|
6
6
|
Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
|
{qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qodalis-cli-server-plugin-admin"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0b6"
|
|
8
8
|
description = "Admin dashboard plugin for Qodalis CLI servers"
|
|
9
9
|
readme = { text = "", content-type = "text/plain" }
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Authentication controller — login and ``/me`` endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .jwt_service import JwtService
|
|
13
|
+
from ..services.admin_config import AdminConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoginRequest(BaseModel):
|
|
17
|
+
"""Request body for the login endpoint."""
|
|
18
|
+
|
|
19
|
+
username: str
|
|
20
|
+
password: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoginResponse(BaseModel):
|
|
24
|
+
"""Response body returned on successful login."""
|
|
25
|
+
|
|
26
|
+
token: str
|
|
27
|
+
expiresIn: int
|
|
28
|
+
username: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MAX_ATTEMPTS = 5
|
|
32
|
+
_WINDOW_SECONDS = 60
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _RateLimiter:
|
|
36
|
+
"""Simple in-memory per-IP rate limiter for failed login attempts."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._attempts: dict[str, list[float]] = defaultdict(list)
|
|
40
|
+
|
|
41
|
+
def check(self, ip: str) -> None:
|
|
42
|
+
"""Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
|
|
43
|
+
now = time.time()
|
|
44
|
+
cutoff = now - _WINDOW_SECONDS
|
|
45
|
+
self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
|
|
46
|
+
if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
|
|
47
|
+
raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
|
|
48
|
+
|
|
49
|
+
def record_failure(self, ip: str) -> None:
|
|
50
|
+
"""Record a failed login attempt for the given IP."""
|
|
51
|
+
self._attempts[ip].append(time.time())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
ADMIN_ACCESS = "admin:access"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def trusted_hops(trust_proxy: bool | int | None) -> int:
|
|
58
|
+
"""Number of trusted reverse-proxy hops for a ``trust_proxy`` setting.
|
|
59
|
+
|
|
60
|
+
``False``/``0``/``None`` trust no proxy, ``True`` one hop, an int ``n``
|
|
61
|
+
``n`` hops; any other enabled value below 1 (a negative int) counts as
|
|
62
|
+
one hop. ``bool`` is checked first (``True == 1`` in Python).
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(trust_proxy, bool):
|
|
65
|
+
return 1 if trust_proxy else 0
|
|
66
|
+
if not trust_proxy:
|
|
67
|
+
return 0
|
|
68
|
+
return max(int(trust_proxy), 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def forwarded_client_ip(headers: Any, trust_proxy: bool | int | None) -> str | None:
|
|
72
|
+
"""Client IP from ``X-Forwarded-For`` behind ``trust_proxy`` trusted hops.
|
|
73
|
+
|
|
74
|
+
Every proxy appends the address it saw, so only the rightmost ``n``
|
|
75
|
+
entries were written by trusted proxies; the ones to their left are
|
|
76
|
+
client-controlled. The client IP is the entry at ``len - n`` of the
|
|
77
|
+
comma-separated, trimmed list (the rightmost entry for one hop), or the
|
|
78
|
+
leftmost entry when the list has fewer than ``n`` entries. Returns
|
|
79
|
+
``None`` (use the socket peer) when no proxy is trusted, the header is
|
|
80
|
+
absent/empty, or the selected entry is empty.
|
|
81
|
+
"""
|
|
82
|
+
hops = trusted_hops(trust_proxy)
|
|
83
|
+
if hops == 0:
|
|
84
|
+
return None
|
|
85
|
+
entries = [
|
|
86
|
+
entry.strip()
|
|
87
|
+
for value in headers.getlist("x-forwarded-for")
|
|
88
|
+
for entry in value.split(",")
|
|
89
|
+
]
|
|
90
|
+
if not entries:
|
|
91
|
+
return None
|
|
92
|
+
return entries[max(len(entries) - hops, 0)] or None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def client_ip(request: Request, trust_proxy: bool | int | None) -> str:
|
|
96
|
+
"""Client IP; ``X-Forwarded-For`` is only used with ``trust_proxy`` hops."""
|
|
97
|
+
forwarded = forwarded_client_ip(request.headers, trust_proxy)
|
|
98
|
+
if forwarded:
|
|
99
|
+
return forwarded
|
|
100
|
+
return request.client.host if request.client else "127.0.0.1"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_auth_router(
|
|
104
|
+
admin_config: AdminConfig,
|
|
105
|
+
jwt_service: JwtService,
|
|
106
|
+
auth_dependency: Any = None,
|
|
107
|
+
*,
|
|
108
|
+
trust_proxy: bool | int = False,
|
|
109
|
+
users_plugin: Any = None,
|
|
110
|
+
) -> APIRouter:
|
|
111
|
+
"""Create a FastAPI router with ``POST /login`` and ``GET /me``.
|
|
112
|
+
|
|
113
|
+
With ``users_plugin`` the login goes through the users auth service and
|
|
114
|
+
requires the ``admin:access`` permission; the returned token is the
|
|
115
|
+
users session token. Otherwise the static admin credentials are used.
|
|
116
|
+
"""
|
|
117
|
+
router = APIRouter()
|
|
118
|
+
limiter = _RateLimiter()
|
|
119
|
+
|
|
120
|
+
@router.post("/login", response_model=LoginResponse)
|
|
121
|
+
async def login(body: LoginRequest, request: Request) -> Any:
|
|
122
|
+
ip = client_ip(request, trust_proxy)
|
|
123
|
+
limiter.check(ip)
|
|
124
|
+
|
|
125
|
+
if users_plugin is not None:
|
|
126
|
+
return await _users_login(body, request, ip)
|
|
127
|
+
|
|
128
|
+
if not admin_config.validate_credentials(body.username, body.password):
|
|
129
|
+
limiter.record_failure(ip)
|
|
130
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
131
|
+
|
|
132
|
+
expires_in = JwtService.DEFAULT_EXPIRY_SECONDS
|
|
133
|
+
token = jwt_service.sign_token(
|
|
134
|
+
{"username": body.username, "authenticatedAt": int(time.time())},
|
|
135
|
+
expires_in=expires_in,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return LoginResponse(token=token, expiresIn=expires_in, username=body.username)
|
|
139
|
+
|
|
140
|
+
async def _users_login(body: LoginRequest, request: Request, ip: str) -> Any:
|
|
141
|
+
from qodalis_cli_users import SessionMetadata
|
|
142
|
+
|
|
143
|
+
auth_service = users_plugin.auth_service
|
|
144
|
+
await users_plugin.ensure_seeded()
|
|
145
|
+
try:
|
|
146
|
+
result = await auth_service.login(
|
|
147
|
+
body.username, body.password,
|
|
148
|
+
SessionMetadata(remote_address=ip, user_agent=request.headers.get("user-agent")),
|
|
149
|
+
)
|
|
150
|
+
except HTTPException as exc:
|
|
151
|
+
if exc.status_code == 401:
|
|
152
|
+
limiter.record_failure(ip)
|
|
153
|
+
raise
|
|
154
|
+
allowed = any(_matches(p, ADMIN_ACCESS) for p in result.permissions)
|
|
155
|
+
if not allowed or result.user.must_change_password:
|
|
156
|
+
await auth_service.logout(result.token)
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=403,
|
|
159
|
+
detail="password change required" if allowed else f"missing permission: {ADMIN_ACCESS}",
|
|
160
|
+
)
|
|
161
|
+
return LoginResponse(
|
|
162
|
+
token=result.token,
|
|
163
|
+
expiresIn=auth_service.session_timeout_ms // 1000,
|
|
164
|
+
username=result.user.name,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if auth_dependency is not None:
|
|
168
|
+
@router.get("/me")
|
|
169
|
+
async def me(payload: dict[str, Any] = Depends(auth_dependency)) -> dict[str, Any]:
|
|
170
|
+
return {
|
|
171
|
+
"username": payload.get("username", ""),
|
|
172
|
+
"authenticatedAt": payload.get("authenticatedAt"),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return router
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _matches(granted: str, required: str) -> bool:
|
|
179
|
+
g = granted.strip().lower().split(":")
|
|
180
|
+
r = required.strip().lower().split(":")
|
|
181
|
+
for i, seg in enumerate(g):
|
|
182
|
+
# A trailing '*' grants everything below; elsewhere it matches
|
|
183
|
+
# exactly one segment.
|
|
184
|
+
if seg == "*" and i == len(g) - 1:
|
|
185
|
+
return True
|
|
186
|
+
if i >= len(r) or (seg != "*" and seg != r[i]):
|
|
187
|
+
return False
|
|
188
|
+
return len(g) == len(r)
|
|
@@ -10,6 +10,28 @@ from fastapi import Request, HTTPException
|
|
|
10
10
|
from .jwt_service import JwtService
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def create_users_auth_dependency(users_plugin: Any):
|
|
14
|
+
"""Admin auth through the users plugin: a valid users session holding
|
|
15
|
+
``admin:access`` (admin ``*`` has it)."""
|
|
16
|
+
|
|
17
|
+
async def require_auth(request: Request) -> dict[str, Any]:
|
|
18
|
+
auth_header = request.headers.get("Authorization", "")
|
|
19
|
+
if not auth_header.startswith("Bearer "):
|
|
20
|
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
|
21
|
+
await users_plugin.ensure_seeded()
|
|
22
|
+
ctx = await users_plugin.auth_service.authenticate_token(auth_header[7:].strip())
|
|
23
|
+
if ctx.must_change_password:
|
|
24
|
+
raise HTTPException(status_code=403, detail="password change required")
|
|
25
|
+
if not ctx.can("admin:access"):
|
|
26
|
+
raise HTTPException(status_code=403, detail="missing permission: admin:access")
|
|
27
|
+
return {
|
|
28
|
+
"username": ctx.user.name,
|
|
29
|
+
"authenticatedAt": (ctx.session.created_at // 1000) if ctx.session else None,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return require_auth
|
|
33
|
+
|
|
34
|
+
|
|
13
35
|
def create_auth_dependency(jwt_service: JwtService):
|
|
14
36
|
"""Return a FastAPI ``Depends`` callable that validates Bearer tokens."""
|
|
15
37
|
|
|
@@ -36,8 +58,6 @@ def create_auth_dependency(jwt_service: JwtService):
|
|
|
36
58
|
return require_auth
|
|
37
59
|
|
|
38
60
|
|
|
39
|
-
# Convenience alias — requires a JwtService instance to be set externally.
|
|
40
|
-
# Prefer ``create_auth_dependency(jwt_service)`` for production use.
|
|
41
61
|
async def require_auth(request: Request) -> dict[str, Any]: # noqa: F811
|
|
42
|
-
"""
|
|
62
|
+
"""No-op fallback; use ``create_auth_dependency`` to create a real dependency."""
|
|
43
63
|
raise HTTPException(status_code=401, detail="Auth not configured")
|
|
@@ -33,12 +33,9 @@ class JwtService:
|
|
|
33
33
|
) -> str:
|
|
34
34
|
"""Create a signed JWT token.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Claims to embed in the token.
|
|
40
|
-
expires_in:
|
|
41
|
-
Lifetime in seconds. Defaults to 24 hours.
|
|
36
|
+
Args:
|
|
37
|
+
payload: Claims to embed in the token.
|
|
38
|
+
expires_in: Lifetime in seconds. Defaults to 24 hours.
|
|
42
39
|
"""
|
|
43
40
|
exp = int(time.time()) + (self.DEFAULT_EXPIRY_SECONDS if expires_in is None else expires_in)
|
|
44
41
|
data = {**payload, "exp": exp, "iat": int(time.time())}
|
|
@@ -47,9 +44,7 @@ class JwtService:
|
|
|
47
44
|
def verify_token(self, token: str) -> dict[str, Any]:
|
|
48
45
|
"""Decode and verify a JWT token.
|
|
49
46
|
|
|
50
|
-
Raises
|
|
51
|
-
|
|
52
|
-
jwt.InvalidTokenError
|
|
53
|
-
If the token is invalid or expired.
|
|
47
|
+
Raises:
|
|
48
|
+
jwt.InvalidTokenError: If the token is invalid or expired.
|
|
54
49
|
"""
|
|
55
50
|
return jwt.decode(token, self._secret, algorithms=["HS256"])
|
|
@@ -11,7 +11,7 @@ from fastapi import APIRouter
|
|
|
11
11
|
from .spa_static_files import SPAStaticFiles
|
|
12
12
|
|
|
13
13
|
from .auth.jwt_service import JwtService
|
|
14
|
-
from .auth.auth_middleware import create_auth_dependency
|
|
14
|
+
from .auth.auth_middleware import create_auth_dependency, create_users_auth_dependency
|
|
15
15
|
from .auth.auth_controller import create_auth_router
|
|
16
16
|
from .controllers.status_controller import create_status_router
|
|
17
17
|
from .controllers.plugins_controller import create_plugins_router
|
|
@@ -36,16 +36,22 @@ class AdminBuildDeps:
|
|
|
36
36
|
builder: Any # CliBuilder
|
|
37
37
|
broadcast_fn: BroadcastFn | None = None
|
|
38
38
|
enabled_features: list[str] | None = None
|
|
39
|
+
#: When set, the dashboard authenticates through the users plugin
|
|
40
|
+
#: (``admin:access`` permission); static credentials are not used.
|
|
41
|
+
users_plugin: Any = None
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
@dataclass
|
|
42
45
|
class CliAdminPlugin:
|
|
43
46
|
"""Result of building the admin plugin."""
|
|
44
47
|
|
|
48
|
+
prefix: str
|
|
49
|
+
dashboard_prefix: str
|
|
45
50
|
router: APIRouter
|
|
46
51
|
auth_dependency: Any
|
|
47
52
|
log_buffer: LogRingBuffer
|
|
48
53
|
dashboard_app: Any # ASGI app (SPA fallback) or None
|
|
54
|
+
module_registry: ModuleRegistry | None = None
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
class CliAdminBuilder:
|
|
@@ -56,6 +62,8 @@ class CliAdminBuilder:
|
|
|
56
62
|
self._password: str = ""
|
|
57
63
|
self._jwt_secret: str = ""
|
|
58
64
|
self._dashboard_path: str | None = None
|
|
65
|
+
self._trust_proxy = False
|
|
66
|
+
self._users_plugin: Any = None
|
|
59
67
|
|
|
60
68
|
def set_credentials(self, username: str, password: str) -> CliAdminBuilder:
|
|
61
69
|
"""Set admin login credentials (overrides env vars)."""
|
|
@@ -73,30 +81,52 @@ class CliAdminBuilder:
|
|
|
73
81
|
self._dashboard_path = path
|
|
74
82
|
return self
|
|
75
83
|
|
|
84
|
+
def set_trust_proxy(self, trust: bool | int = True) -> CliAdminBuilder:
|
|
85
|
+
"""Trust ``X-Forwarded-For`` for login rate limiting (behind a proxy only).
|
|
86
|
+
|
|
87
|
+
``False``/``0`` uses the socket peer, ``True`` = 1 trusted hop, an int
|
|
88
|
+
``n`` = ``n`` hops: the client is the entry at ``len - n`` (the
|
|
89
|
+
rightmost for one proxy), the leftmost if there are fewer entries.
|
|
90
|
+
"""
|
|
91
|
+
self._trust_proxy = trust
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def set_users_plugin(self, users_plugin: Any) -> CliAdminBuilder:
|
|
95
|
+
"""Authenticate the dashboard through the users plugin."""
|
|
96
|
+
self._users_plugin = users_plugin
|
|
97
|
+
return self
|
|
98
|
+
|
|
76
99
|
def build(self, deps: AdminBuildDeps) -> CliAdminPlugin:
|
|
77
100
|
"""Build the plugin, returning the router, auth dependency, and log buffer."""
|
|
78
101
|
start_time = time.time()
|
|
79
102
|
|
|
80
|
-
|
|
103
|
+
users_plugin = self._users_plugin or deps.users_plugin
|
|
81
104
|
config = AdminConfig(
|
|
82
105
|
username=self._username,
|
|
83
106
|
password=self._password,
|
|
84
107
|
jwt_secret=self._jwt_secret,
|
|
108
|
+
warn_default=users_plugin is None,
|
|
85
109
|
)
|
|
86
110
|
jwt_service = JwtService(secret=config.jwt_secret)
|
|
87
111
|
log_buffer = LogRingBuffer(broadcast_fn=deps.broadcast_fn)
|
|
88
112
|
log_buffer.install_handler()
|
|
89
113
|
module_registry = ModuleRegistry(deps.builder)
|
|
90
|
-
|
|
114
|
+
if users_plugin is not None:
|
|
115
|
+
auth_dep = create_users_auth_dependency(users_plugin)
|
|
116
|
+
allow = getattr(users_plugin, "allow_anonymous", None)
|
|
117
|
+
if callable(allow):
|
|
118
|
+
allow("/api/v1/qcli/auth/login", ["POST"])
|
|
119
|
+
else:
|
|
120
|
+
auth_dep = create_auth_dependency(jwt_service)
|
|
91
121
|
|
|
92
|
-
# Build the top-level router
|
|
93
122
|
router = APIRouter()
|
|
94
123
|
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
auth_router = create_auth_router(
|
|
125
|
+
config, jwt_service, auth_dependency=auth_dep,
|
|
126
|
+
trust_proxy=self._trust_proxy, users_plugin=users_plugin,
|
|
127
|
+
)
|
|
97
128
|
router.include_router(auth_router, prefix="/auth")
|
|
98
129
|
|
|
99
|
-
# Detect enabled features
|
|
100
130
|
enabled_features: list[str] = list(deps.enabled_features or [])
|
|
101
131
|
|
|
102
132
|
if "filesystem" not in enabled_features and (
|
|
@@ -112,7 +142,6 @@ class CliAdminBuilder:
|
|
|
112
142
|
enabled_features.append("jobs")
|
|
113
143
|
break
|
|
114
144
|
|
|
115
|
-
# Protected routes
|
|
116
145
|
status_router = create_status_router(
|
|
117
146
|
start_time, deps.event_socket_manager, auth_dep, enabled_features,
|
|
118
147
|
registry=deps.registry,
|
|
@@ -131,7 +160,6 @@ class CliAdminBuilder:
|
|
|
131
160
|
ws_clients_router = create_ws_clients_router(deps.event_socket_manager, auth_dep)
|
|
132
161
|
router.include_router(ws_clients_router)
|
|
133
162
|
|
|
134
|
-
# Resolve dashboard SPA
|
|
135
163
|
dashboard_dir = resolve_dashboard_dir(self._dashboard_path)
|
|
136
164
|
dashboard_app = None
|
|
137
165
|
|
|
@@ -139,8 +167,11 @@ class CliAdminBuilder:
|
|
|
139
167
|
dashboard_app = SPAStaticFiles(directory=dashboard_dir, html=True)
|
|
140
168
|
|
|
141
169
|
return CliAdminPlugin(
|
|
170
|
+
prefix="/api/v1/qcli",
|
|
171
|
+
dashboard_prefix="/qcli/admin",
|
|
142
172
|
router=router,
|
|
143
173
|
auth_dependency=auth_dep,
|
|
144
174
|
log_buffer=log_buffer,
|
|
145
175
|
dashboard_app=dashboard_app,
|
|
176
|
+
module_registry=module_registry,
|
|
146
177
|
)
|
|
@@ -5,23 +5,22 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
|
|
8
|
+
"""Resolve the dashboard dist directory.
|
|
9
|
+
|
|
10
|
+
Searches in order: explicit path, bundled directory, npm package
|
|
11
|
+
(``node_modules/@qodalis/cli-server-dashboard/dist``), and relative
|
|
12
|
+
development paths.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The absolute path to the dashboard directory, or ``None``.
|
|
8
16
|
"""
|
|
9
|
-
Resolve the dashboard dist directory. Looks for:
|
|
10
|
-
1. Explicitly configured path
|
|
11
|
-
2. Bundled dashboard directory (embedded in this package)
|
|
12
|
-
3. node_modules/@qodalis/cli-server-dashboard/dist (npm package)
|
|
13
|
-
4. Relative development paths (sibling repo)
|
|
14
|
-
"""
|
|
15
|
-
# 1. Explicit override
|
|
16
17
|
if explicit_path and os.path.isdir(explicit_path):
|
|
17
18
|
return os.path.abspath(explicit_path)
|
|
18
19
|
|
|
19
|
-
# 2. Bundled dashboard directory (embedded in this package)
|
|
20
20
|
bundled = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard')
|
|
21
21
|
if os.path.isdir(bundled):
|
|
22
22
|
return bundled
|
|
23
23
|
|
|
24
|
-
# 3. npm package — search upwards from cwd for node_modules
|
|
25
24
|
current = os.getcwd()
|
|
26
25
|
while current != os.path.dirname(current):
|
|
27
26
|
candidate = os.path.join(
|
|
@@ -31,7 +30,6 @@ def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
|
|
|
31
30
|
return os.path.abspath(candidate)
|
|
32
31
|
current = os.path.dirname(current)
|
|
33
32
|
|
|
34
|
-
# 4. Relative development paths
|
|
35
33
|
this_dir = os.path.dirname(os.path.abspath(__file__))
|
|
36
34
|
dev_paths = [
|
|
37
35
|
os.path.join(this_dir, "..", "..", "..", "..", "cli-server-dashboard", "dist"),
|
|
@@ -22,6 +22,7 @@ class AdminConfig:
|
|
|
22
22
|
username: str = ""
|
|
23
23
|
password: str = ""
|
|
24
24
|
jwt_secret: str = ""
|
|
25
|
+
warn_default: bool = True
|
|
25
26
|
|
|
26
27
|
# Mutable runtime settings
|
|
27
28
|
_mutable_settings: dict[str, Any] = field(default_factory=dict)
|
|
@@ -38,7 +39,7 @@ class AdminConfig:
|
|
|
38
39
|
if not self.jwt_secret:
|
|
39
40
|
self.jwt_secret = os.environ.get("QCLI_ADMIN_JWT_SECRET", "")
|
|
40
41
|
|
|
41
|
-
if self.username == "admin" and self.password == "admin":
|
|
42
|
+
if self.warn_default and self.username == "admin" and self.password == "admin":
|
|
42
43
|
logger.warning(
|
|
43
44
|
"Using default admin credentials. Set QCLI_ADMIN_USERNAME"
|
|
44
45
|
" and QCLI_ADMIN_PASSWORD environment variables."
|
|
@@ -46,8 +47,13 @@ class AdminConfig:
|
|
|
46
47
|
|
|
47
48
|
def validate_credentials(self, username: str, password: str) -> bool:
|
|
48
49
|
"""Return *True* if the supplied credentials match."""
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
# surrogatepass: a lone surrogate in the input must not raise (500)
|
|
51
|
+
username_ok = hmac.compare_digest(
|
|
52
|
+
username.encode("utf-8", "surrogatepass"), self.username.encode("utf-8", "surrogatepass"),
|
|
53
|
+
)
|
|
54
|
+
password_ok = hmac.compare_digest(
|
|
55
|
+
password.encode("utf-8", "surrogatepass"), self.password.encode("utf-8", "surrogatepass"),
|
|
56
|
+
)
|
|
51
57
|
return username_ok and password_ok
|
|
52
58
|
|
|
53
59
|
def get_config_sections(self) -> list[dict[str, Any]]:
|
|
@@ -105,6 +111,7 @@ class AdminConfig:
|
|
|
105
111
|
|
|
106
112
|
|
|
107
113
|
def _infer_type(val: Any) -> str:
|
|
114
|
+
"""Infer a JSON-schema-style type string from a Python value."""
|
|
108
115
|
if isinstance(val, bool):
|
|
109
116
|
return "boolean"
|
|
110
117
|
if isinstance(val, (int, float)):
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import re
|
|
6
7
|
from collections import deque
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from datetime import datetime, timezone
|
|
@@ -21,6 +22,20 @@ class LogEntry:
|
|
|
21
22
|
|
|
22
23
|
BroadcastFn = Callable[[str], Awaitable[None]]
|
|
23
24
|
|
|
25
|
+
_REDACTIONS = [
|
|
26
|
+
# Also the URL-encoded ``Bearer%20<token>`` form (``_header_Authorization`` query).
|
|
27
|
+
(re.compile(r"(?i)(bearer(?:\s+|%20))[A-Za-z0-9._~+/=%-]+"), r"\1[redacted]"),
|
|
28
|
+
(re.compile(r"(?i)\b(token|ticket|password|secret|cmd)=([^&\s]+)"), r"\1=[redacted]"),
|
|
29
|
+
(re.compile(r'(?i)"(token|ticket|password|currentPassword|newPassword)"\s*:\s*"[^"]*"'), r'"\1": "[redacted]"'),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def redact(message: str) -> str:
|
|
34
|
+
"""Mask bearer tokens, tickets and passwords in a log message."""
|
|
35
|
+
for pattern, repl in _REDACTIONS:
|
|
36
|
+
message = pattern.sub(repl, message)
|
|
37
|
+
return message
|
|
38
|
+
|
|
24
39
|
_LEVEL_MAP = {
|
|
25
40
|
"WARNING": "WARN",
|
|
26
41
|
}
|
|
@@ -47,8 +62,6 @@ class LogRingBuffer:
|
|
|
47
62
|
self._broadcast_fn = broadcast_fn
|
|
48
63
|
self._handler: _RingBufferHandler | None = None
|
|
49
64
|
|
|
50
|
-
# -- public API -----------------------------------------------------------
|
|
51
|
-
|
|
52
65
|
def add(self, entry: LogEntry) -> None:
|
|
53
66
|
"""Append a log entry to the buffer."""
|
|
54
67
|
entry.level = (entry.level or "INFO").upper()
|
|
@@ -93,8 +106,6 @@ class LogRingBuffer:
|
|
|
93
106
|
total,
|
|
94
107
|
)
|
|
95
108
|
|
|
96
|
-
# -- logging integration --------------------------------------------------
|
|
97
|
-
|
|
98
109
|
def install_handler(self, logger_name: str = "") -> None:
|
|
99
110
|
"""Install a :class:`logging.Handler` that feeds into this buffer."""
|
|
100
111
|
if self._handler is not None:
|
|
@@ -118,10 +129,16 @@ class _RingBufferHandler(logging.Handler):
|
|
|
118
129
|
self._ring = ring
|
|
119
130
|
|
|
120
131
|
def emit(self, record: logging.LogRecord) -> None:
|
|
132
|
+
"""Convert a log record to a LogEntry and append it to the ring buffer.
|
|
133
|
+
|
|
134
|
+
Records flagged ``qcli_sensitive`` are skipped and tokens/passwords
|
|
135
|
+
are redacted."""
|
|
136
|
+
if getattr(record, "qcli_sensitive", False):
|
|
137
|
+
return
|
|
121
138
|
entry = LogEntry(
|
|
122
139
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
123
140
|
level=record.levelname,
|
|
124
|
-
message=self.format(record),
|
|
141
|
+
message=redact(self.format(record)),
|
|
125
142
|
logger_name=record.name,
|
|
126
143
|
)
|
|
127
144
|
self._ring.add(entry)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Module registry — reads modules from CliBuilder and tracks enabled state.
|
|
2
|
+
|
|
3
|
+
Implements :class:`ICliProcessorFilter` so that processors belonging to
|
|
4
|
+
disabled modules are blocked at execution time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from qodalis_cli_server_abstractions import ICliProcessorFilter
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from qodalis_cli_server_abstractions import ICliCommandProcessor
|
|
15
|
+
from qodalis_cli.extensions.cli_builder import CliBuilder
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModuleRegistry(ICliProcessorFilter):
|
|
19
|
+
"""Provides a read/toggle view over modules registered in :class:`CliBuilder`.
|
|
20
|
+
|
|
21
|
+
Also acts as a processor filter: processors that belong to a disabled
|
|
22
|
+
module are reported as *not allowed*, causing the executor to reject
|
|
23
|
+
the command with a user-friendly error.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, builder: CliBuilder) -> None:
|
|
27
|
+
self._builder = builder
|
|
28
|
+
self._disabled: set[str] = set()
|
|
29
|
+
# Map each processor instance to its module id for fast lookup.
|
|
30
|
+
self._processor_to_module: dict[int, str] = {}
|
|
31
|
+
self._rebuild_processor_map()
|
|
32
|
+
|
|
33
|
+
def _rebuild_processor_map(self) -> None:
|
|
34
|
+
"""(Re-)build the processor-to-module-id mapping."""
|
|
35
|
+
self._processor_to_module.clear()
|
|
36
|
+
for idx, module in enumerate(self._builder.modules):
|
|
37
|
+
module_id = self._module_id(idx, module)
|
|
38
|
+
for processor in module.processors:
|
|
39
|
+
self._processor_to_module[id(processor)] = module_id
|
|
40
|
+
|
|
41
|
+
# -- ICliProcessorFilter ------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def is_allowed(self, processor: ICliCommandProcessor) -> bool:
|
|
44
|
+
"""Return ``False`` if the processor belongs to a disabled module."""
|
|
45
|
+
module_id = self._processor_to_module.get(id(processor))
|
|
46
|
+
if module_id is None:
|
|
47
|
+
# Processor not registered via a module — always allowed.
|
|
48
|
+
return True
|
|
49
|
+
return module_id not in self._disabled
|
|
50
|
+
|
|
51
|
+
# -- Public API ---------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def list(self) -> list[dict[str, Any]]:
|
|
54
|
+
"""Return a list of all modules with their enabled/disabled state."""
|
|
55
|
+
return [
|
|
56
|
+
self._describe(idx, module, self._module_id(idx, module) not in self._disabled)
|
|
57
|
+
for idx, module in enumerate(self._builder.modules)
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def toggle(self, module_id: str) -> dict[str, Any]:
|
|
61
|
+
"""Toggle a module's enabled state and return the updated record.
|
|
62
|
+
|
|
63
|
+
``module_id`` is the module name (as listed); the legacy numeric index
|
|
64
|
+
is still accepted.
|
|
65
|
+
"""
|
|
66
|
+
modules = self._builder.modules
|
|
67
|
+
idx = self._find(module_id)
|
|
68
|
+
if idx is None:
|
|
69
|
+
raise KeyError(f"Module not found: {module_id}")
|
|
70
|
+
|
|
71
|
+
module_key = self._module_id(idx, modules[idx])
|
|
72
|
+
if module_key in self._disabled:
|
|
73
|
+
self._disabled.discard(module_key)
|
|
74
|
+
enabled = True
|
|
75
|
+
else:
|
|
76
|
+
self._disabled.add(module_key)
|
|
77
|
+
enabled = False
|
|
78
|
+
return self._describe(idx, modules[idx], enabled)
|
|
79
|
+
|
|
80
|
+
# -- helpers ------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _module_id(self, idx: int, module: Any) -> str:
|
|
83
|
+
"""Stable id: the module name (same as the node and .NET servers);
|
|
84
|
+
the index only when the name is missing or used twice."""
|
|
85
|
+
name = getattr(module, "name", None)
|
|
86
|
+
names = [getattr(m, "name", None) for m in self._builder.modules]
|
|
87
|
+
if isinstance(name, str) and name and names.count(name) == 1:
|
|
88
|
+
return name
|
|
89
|
+
return str(idx)
|
|
90
|
+
|
|
91
|
+
def _find(self, module_id: str) -> int | None:
|
|
92
|
+
modules = self._builder.modules
|
|
93
|
+
for idx, module in enumerate(modules):
|
|
94
|
+
if self._module_id(idx, module) == module_id:
|
|
95
|
+
return idx
|
|
96
|
+
if module_id.isdigit() and int(module_id) < len(modules):
|
|
97
|
+
return int(module_id)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _describe(self, idx: int, module: Any, enabled: bool) -> dict[str, Any]:
|
|
101
|
+
record: dict[str, Any] = {
|
|
102
|
+
"id": self._module_id(idx, module),
|
|
103
|
+
"name": getattr(module, "name", type(module).__name__),
|
|
104
|
+
"version": getattr(module, "version", None) or "",
|
|
105
|
+
"description": getattr(module, "description", None) or "",
|
|
106
|
+
"enabled": enabled,
|
|
107
|
+
"processorCount": len(module.processors),
|
|
108
|
+
"processors": [p.command for p in module.processors],
|
|
109
|
+
}
|
|
110
|
+
author = getattr(module, "author", None)
|
|
111
|
+
if author is not None:
|
|
112
|
+
record["author"] = getattr(author, "name", author) if not isinstance(author, str) else author
|
|
113
|
+
return record
|
|
@@ -24,12 +24,12 @@ class SPAStaticFiles(StaticFiles):
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
async def get_response(self, path: str, scope: Scope) -> Response:
|
|
27
|
+
"""Return the static file or fall back to ``index.html`` for SPA routes."""
|
|
27
28
|
try:
|
|
28
29
|
return await super().get_response(path, scope)
|
|
29
30
|
except StarletteHTTPException as exc:
|
|
30
31
|
if exc.status_code != 404:
|
|
31
32
|
raise
|
|
32
|
-
# File not found — serve index.html for SPA client-side routing
|
|
33
33
|
index = os.path.join(self.directory, "index.html") # type: ignore[arg-type]
|
|
34
34
|
if os.path.isfile(index):
|
|
35
35
|
return FileResponse(index, media_type="text/html")
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""Authentication controller — login and ``/me`` endpoints."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import time
|
|
6
|
-
from collections import defaultdict
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
-
from pydantic import BaseModel
|
|
11
|
-
|
|
12
|
-
from .jwt_service import JwtService
|
|
13
|
-
from ..services.admin_config import AdminConfig
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# ---------------------------------------------------------------------------
|
|
17
|
-
# Request / Response models
|
|
18
|
-
# ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class LoginRequest(BaseModel):
|
|
22
|
-
username: str
|
|
23
|
-
password: str
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class LoginResponse(BaseModel):
|
|
27
|
-
token: str
|
|
28
|
-
expiresIn: int
|
|
29
|
-
username: str
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# ---------------------------------------------------------------------------
|
|
33
|
-
# Rate limiting helpers
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
_MAX_ATTEMPTS = 5
|
|
37
|
-
_WINDOW_SECONDS = 60
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class _RateLimiter:
|
|
41
|
-
"""Simple in-memory per-IP rate limiter for failed login attempts."""
|
|
42
|
-
|
|
43
|
-
def __init__(self) -> None:
|
|
44
|
-
self._attempts: dict[str, list[float]] = defaultdict(list)
|
|
45
|
-
|
|
46
|
-
def check(self, ip: str) -> None:
|
|
47
|
-
"""Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
|
|
48
|
-
now = time.time()
|
|
49
|
-
cutoff = now - _WINDOW_SECONDS
|
|
50
|
-
# Clean old entries
|
|
51
|
-
self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
|
|
52
|
-
if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
|
|
53
|
-
raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
|
|
54
|
-
|
|
55
|
-
def record_failure(self, ip: str) -> None:
|
|
56
|
-
self._attempts[ip].append(time.time())
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# ---------------------------------------------------------------------------
|
|
60
|
-
# Router factory
|
|
61
|
-
# ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def create_auth_router(
|
|
65
|
-
admin_config: AdminConfig,
|
|
66
|
-
jwt_service: JwtService,
|
|
67
|
-
auth_dependency: Any = None,
|
|
68
|
-
) -> APIRouter:
|
|
69
|
-
"""Create a FastAPI router with ``POST /login`` and ``GET /me``."""
|
|
70
|
-
router = APIRouter()
|
|
71
|
-
limiter = _RateLimiter()
|
|
72
|
-
|
|
73
|
-
@router.post("/login", response_model=LoginResponse)
|
|
74
|
-
async def login(body: LoginRequest, request: Request) -> Any:
|
|
75
|
-
# Check X-Forwarded-For header for real client IP behind proxy
|
|
76
|
-
forwarded_for = request.headers.get("x-forwarded-for")
|
|
77
|
-
if forwarded_for:
|
|
78
|
-
client_ip = forwarded_for.split(",")[0].strip()
|
|
79
|
-
else:
|
|
80
|
-
client_ip = request.client.host if request.client else "127.0.0.1"
|
|
81
|
-
limiter.check(client_ip)
|
|
82
|
-
|
|
83
|
-
if not admin_config.validate_credentials(body.username, body.password):
|
|
84
|
-
limiter.record_failure(client_ip)
|
|
85
|
-
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
86
|
-
|
|
87
|
-
expires_in = JwtService.DEFAULT_EXPIRY_SECONDS
|
|
88
|
-
token = jwt_service.sign_token(
|
|
89
|
-
{"username": body.username, "authenticatedAt": int(time.time())},
|
|
90
|
-
expires_in=expires_in,
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
return LoginResponse(token=token, expiresIn=expires_in, username=body.username)
|
|
94
|
-
|
|
95
|
-
if auth_dependency is not None:
|
|
96
|
-
@router.get("/me")
|
|
97
|
-
async def me(payload: dict[str, Any] = Depends(auth_dependency)) -> dict[str, Any]:
|
|
98
|
-
return {
|
|
99
|
-
"username": payload.get("username", ""),
|
|
100
|
-
"authenticatedAt": payload.get("authenticatedAt"),
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return router
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Module registry — reads modules from CliBuilder and tracks enabled state."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any, TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
if TYPE_CHECKING:
|
|
8
|
-
from qodalis_cli.extensions.cli_builder import CliBuilder
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ModuleRegistry:
|
|
12
|
-
"""Provides a read/toggle view over modules registered in :class:`CliBuilder`."""
|
|
13
|
-
|
|
14
|
-
def __init__(self, builder: CliBuilder) -> None:
|
|
15
|
-
self._builder = builder
|
|
16
|
-
self._disabled: set[str] = set()
|
|
17
|
-
|
|
18
|
-
def list(self) -> list[dict[str, Any]]:
|
|
19
|
-
"""Return a list of all modules with their enabled/disabled state."""
|
|
20
|
-
result: list[dict[str, Any]] = []
|
|
21
|
-
for idx, module in enumerate(self._builder.modules):
|
|
22
|
-
module_id = str(idx)
|
|
23
|
-
name = getattr(module, "name", type(module).__name__)
|
|
24
|
-
result.append(
|
|
25
|
-
{
|
|
26
|
-
"id": module_id,
|
|
27
|
-
"name": name,
|
|
28
|
-
"processorCount": len(module.processors),
|
|
29
|
-
"processors": [p.command for p in module.processors],
|
|
30
|
-
"enabled": module_id not in self._disabled,
|
|
31
|
-
}
|
|
32
|
-
)
|
|
33
|
-
return result
|
|
34
|
-
|
|
35
|
-
def toggle(self, module_id: str) -> dict[str, Any]:
|
|
36
|
-
"""Toggle a module's enabled state and return the updated record."""
|
|
37
|
-
modules = self._builder.modules
|
|
38
|
-
idx = int(module_id)
|
|
39
|
-
if idx < 0 or idx >= len(modules):
|
|
40
|
-
raise KeyError(f"Module not found: {module_id}")
|
|
41
|
-
|
|
42
|
-
if module_id in self._disabled:
|
|
43
|
-
self._disabled.discard(module_id)
|
|
44
|
-
enabled = True
|
|
45
|
-
else:
|
|
46
|
-
self._disabled.add(module_id)
|
|
47
|
-
enabled = False
|
|
48
|
-
|
|
49
|
-
module = modules[idx]
|
|
50
|
-
result: dict[str, Any] = {
|
|
51
|
-
"id": module_id,
|
|
52
|
-
"name": getattr(module, "name", type(module).__name__),
|
|
53
|
-
"processorCount": len(module.processors),
|
|
54
|
-
"processors": [p.command for p in module.processors],
|
|
55
|
-
"enabled": enabled,
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if not enabled:
|
|
59
|
-
result["warning"] = (
|
|
60
|
-
"Module state tracked but command unregistration is not yet"
|
|
61
|
-
" supported. Processors remain active."
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
return result
|
{qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/.gitignore
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|