hivemind-admin-panel 0.1.0__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.
- hivemind_admin_panel/__init__.py +91 -0
- hivemind_admin_panel/__main__.py +267 -0
- hivemind_admin_panel/_auth.py +162 -0
- hivemind_admin_panel/_chat.py +146 -0
- hivemind_admin_panel/_metrics.py +84 -0
- hivemind_admin_panel/_persona_chat.py +111 -0
- hivemind_admin_panel/acl_config.json +84 -0
- hivemind_admin_panel/api.py +4882 -0
- hivemind_admin_panel/persona.json +12 -0
- hivemind_admin_panel/plugins_config.json +266 -0
- hivemind_admin_panel/static/css/style.css +898 -0
- hivemind_admin_panel/static/index.html +1650 -0
- hivemind_admin_panel/static/js/app.js +4516 -0
- hivemind_admin_panel/static/js/i18n.js +47 -0
- hivemind_admin_panel/version.py +17 -0
- hivemind_admin_panel-0.1.0.dist-info/METADATA +160 -0
- hivemind_admin_panel-0.1.0.dist-info/RECORD +21 -0
- hivemind_admin_panel-0.1.0.dist-info/WHEEL +5 -0
- hivemind_admin_panel-0.1.0.dist-info/entry_points.txt +2 -0
- hivemind_admin_panel-0.1.0.dist-info/licenses/LICENSE +79 -0
- hivemind_admin_panel-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# hivemind-admin-panel
|
|
2
|
+
# Copyright (C) 2026 Casimiro Ferreira
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""HiveMind Admin - Web-based management UI for HiveMind-core.
|
|
5
|
+
|
|
6
|
+
This module provides a web-based administration interface for HiveMind-core,
|
|
7
|
+
allowing management of clients, permissions, and server configuration via
|
|
8
|
+
a REST API and web UI.
|
|
9
|
+
|
|
10
|
+
When launched with the in-process hivemind-core (the default), this module gets
|
|
11
|
+
direct access to internal HiveMind-core objects for real-time monitoring.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from ovos_utils import create_daemon
|
|
17
|
+
from ovos_utils.log import LOG
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from hivemind_core.service import HiveMindService
|
|
21
|
+
from hivemind_core.database import ClientDatabase
|
|
22
|
+
from hivemind_core.protocol import HiveMindListenerProtocol
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
__all__ = ["start_admin_server", "init_injected_objects", "get_admin_app"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_injected_objects(
|
|
29
|
+
service: "HiveMindService" = None,
|
|
30
|
+
db: "ClientDatabase" = None,
|
|
31
|
+
protocol: "HiveMindListenerProtocol" = None,
|
|
32
|
+
startup_error: Exception = None
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize admin with direct access to core objects.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
service: HiveMindService instance.
|
|
38
|
+
db: ClientDatabase instance.
|
|
39
|
+
protocol: HiveMindListenerProtocol instance.
|
|
40
|
+
startup_error: Exception if core failed to start.
|
|
41
|
+
"""
|
|
42
|
+
from hivemind_admin_panel.api import init_injected_objects as _init
|
|
43
|
+
_init(service=service, db=db, protocol=protocol, logger=LOG, startup_error=startup_error)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def start_admin_server(
|
|
47
|
+
host: str = "127.0.0.1",
|
|
48
|
+
port: int = 8000,
|
|
49
|
+
reload: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Start the HiveMind Admin web server.
|
|
52
|
+
|
|
53
|
+
This function starts a uvicorn server hosting the FastAPI admin interface.
|
|
54
|
+
It should be called after hivemind-core service is running.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
host: Host to bind the server (default: 127.0.0.1).
|
|
58
|
+
port: Port to bind the server (default: 8000).
|
|
59
|
+
reload: Enable auto-reload for development (default: False).
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
This function runs the server in a daemon thread and returns
|
|
63
|
+
immediately. The server will shut down when the main process exits.
|
|
64
|
+
"""
|
|
65
|
+
import uvicorn
|
|
66
|
+
from hivemind_admin_panel.__main__ import app
|
|
67
|
+
|
|
68
|
+
def _run_server():
|
|
69
|
+
LOG.info(f"Starting HiveMind Admin UI at http://{host}:{port}")
|
|
70
|
+
LOG.info("Change admin credentials in ~/.config/hivemind-core/server.json (admin_user, admin_pass)")
|
|
71
|
+
uvicorn.run(
|
|
72
|
+
app,
|
|
73
|
+
host=host,
|
|
74
|
+
port=port,
|
|
75
|
+
reload=reload,
|
|
76
|
+
log_level="info",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Run server in daemon thread
|
|
80
|
+
create_daemon(_run_server)
|
|
81
|
+
LOG.info("HiveMind Admin server thread started")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_admin_app():
|
|
85
|
+
"""Get the FastAPI app instance for admin UI.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
FastAPI app configured with all admin routes.
|
|
89
|
+
"""
|
|
90
|
+
from hivemind_admin_panel.api import get_admin_app
|
|
91
|
+
return get_admin_app()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# hivemind-admin-panel
|
|
2
|
+
# Copyright (C) 2026 Casimiro Ferreira
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Main entry point for the HiveMind Admin Panel.
|
|
5
|
+
|
|
6
|
+
This module provides the FastAPI application that serves both the REST API and
|
|
7
|
+
the static web UI, and is the single launcher for a HiveMind deployment: by
|
|
8
|
+
default it starts an in-process ``hivemind-core`` hivemind-core and keeps a live reference
|
|
9
|
+
to it, so operators run ``hivemind-admin-panel`` only — there is no separate
|
|
10
|
+
``hivemind-core`` process to launch.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
```bash
|
|
14
|
+
# Launch hivemind-core + admin panel together (default)
|
|
15
|
+
hivemind-admin-panel --host 0.0.0.0 --port 8100
|
|
16
|
+
|
|
17
|
+
# Admin panel only, no in-process hivemind-core (manage on-disk state, or attach to a
|
|
18
|
+
# hivemind-core managed elsewhere on the host)
|
|
19
|
+
hivemind-admin-panel --no-core
|
|
20
|
+
```
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from fastapi import FastAPI
|
|
27
|
+
from fastapi.staticfiles import StaticFiles
|
|
28
|
+
from fastapi.responses import FileResponse
|
|
29
|
+
|
|
30
|
+
from hivemind_admin_panel.api import app as api_app
|
|
31
|
+
from hivemind_admin_panel.version import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = ["app", "main"]
|
|
34
|
+
|
|
35
|
+
#: Directory containing static files (SPA web UI)
|
|
36
|
+
static_dir = Path(__file__).parent / "static"
|
|
37
|
+
|
|
38
|
+
#: Main FastAPI application instance
|
|
39
|
+
app = FastAPI(title="HiveMind Admin Panel")
|
|
40
|
+
|
|
41
|
+
# Mount the API app under /api prefix
|
|
42
|
+
app.mount("/api", api_app)
|
|
43
|
+
|
|
44
|
+
# Mount static files for direct access (CSS, JS, images)
|
|
45
|
+
if static_dir.exists():
|
|
46
|
+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.get("/")
|
|
50
|
+
async def root() -> FileResponse:
|
|
51
|
+
"""Serve the main index.html file for the SPA.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
FileResponse: The index.html file from static directory.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
HTTPException: 404 if index.html is not present (e.g. package installed without static assets).
|
|
58
|
+
"""
|
|
59
|
+
from fastapi import HTTPException
|
|
60
|
+
index_path = static_dir / "index.html"
|
|
61
|
+
if not index_path.exists():
|
|
62
|
+
raise HTTPException(status_code=404, detail="Admin UI static assets not found. "
|
|
63
|
+
"Reinstall with: pip install hivemind-admin-panel")
|
|
64
|
+
return FileResponse(index_path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.get("/index.html")
|
|
68
|
+
async def index() -> FileResponse:
|
|
69
|
+
"""Serve the main index.html file for the SPA.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
FileResponse: The index.html file from static directory.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
HTTPException: 404 if index.html is not present (e.g. package installed without static assets).
|
|
76
|
+
"""
|
|
77
|
+
from fastapi import HTTPException
|
|
78
|
+
index_path = static_dir / "index.html"
|
|
79
|
+
if not index_path.exists():
|
|
80
|
+
raise HTTPException(status_code=404, detail="Admin UI static assets not found. "
|
|
81
|
+
"Reinstall with: pip install hivemind-admin-panel")
|
|
82
|
+
return FileResponse(index_path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _tracked_protocol(base, service):
|
|
86
|
+
"""Subclass hivemind-core's listener protocol to feed the admin panel live state.
|
|
87
|
+
|
|
88
|
+
Captures the live protocol instance (so ``/connections``, ``/stats`` and the
|
|
89
|
+
topology become authoritative) and taps connection + message handlers to feed
|
|
90
|
+
the metrics event/message buffers — entirely panel-side, with no core change.
|
|
91
|
+
"""
|
|
92
|
+
from hivemind_admin_panel.api import init_injected_objects
|
|
93
|
+
from hivemind_admin_panel._metrics import METRICS
|
|
94
|
+
|
|
95
|
+
class _Tracked(base):
|
|
96
|
+
def __init__(self, *a, **kw):
|
|
97
|
+
super().__init__(*a, **kw)
|
|
98
|
+
init_injected_objects(service=service, db=service.db, protocol=self)
|
|
99
|
+
METRICS.event("core.ready", "hivemind-core listener protocol online")
|
|
100
|
+
|
|
101
|
+
def handle_new_client(self, client):
|
|
102
|
+
METRICS.event("client.connected", f"{getattr(client, 'peer', '?')} connected")
|
|
103
|
+
return super().handle_new_client(client)
|
|
104
|
+
|
|
105
|
+
def handle_client_disconnected(self, client):
|
|
106
|
+
METRICS.event("client.disconnected", f"{getattr(client, 'peer', '?')} disconnected")
|
|
107
|
+
return super().handle_client_disconnected(client)
|
|
108
|
+
|
|
109
|
+
def handle_message(self, message, client):
|
|
110
|
+
try:
|
|
111
|
+
METRICS.message(str(getattr(message, "msg_type", "?")),
|
|
112
|
+
str(getattr(client, "peer", "?")))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return super().handle_message(message, client)
|
|
116
|
+
|
|
117
|
+
def handle_invalid_key_connected(self, client):
|
|
118
|
+
METRICS.event("auth.rejected", f"invalid key from {getattr(client, 'peer', '?')}")
|
|
119
|
+
return super().handle_invalid_key_connected(client)
|
|
120
|
+
|
|
121
|
+
return _Tracked
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def launch_core():
|
|
125
|
+
"""Construct an in-process hivemind-core and inject it into the admin API.
|
|
126
|
+
|
|
127
|
+
Builds a ``HiveMindService`` and hands its live ``service``/``db`` objects to
|
|
128
|
+
the admin API via ``init_injected_objects``. It does **not** run hivemind-core —
|
|
129
|
+
``HiveMindService.run()`` blocks on signal handlers and must execute on the
|
|
130
|
+
main thread (see :func:`main`). If construction fails, the error is injected
|
|
131
|
+
for diagnostics (surfaced at ``GET /api/startup-error``) and ``None`` is
|
|
132
|
+
returned so the panel can still come up.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The ``HiveMindService`` instance, or ``None`` if construction failed.
|
|
136
|
+
"""
|
|
137
|
+
from ovos_utils.log import LOG
|
|
138
|
+
from hivemind_admin_panel.api import init_injected_objects
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
from hivemind_core.service import HiveMindService
|
|
142
|
+
|
|
143
|
+
service = HiveMindService()
|
|
144
|
+
init_injected_objects(service=service, db=service.db, protocol=None)
|
|
145
|
+
# Wrap the listener protocol so the panel gets the LIVE protocol instance
|
|
146
|
+
# (authoritative connections) and a tap on every HiveMessage — no core change.
|
|
147
|
+
service.hm_protocol = _tracked_protocol(service.hm_protocol, service)
|
|
148
|
+
LOG.info("hivemind-core constructed; will run in-process")
|
|
149
|
+
return service
|
|
150
|
+
except Exception as error:
|
|
151
|
+
LOG.exception("hivemind-core failed to start; admin panel running in diagnostics mode")
|
|
152
|
+
init_injected_objects(service=None, db=None, protocol=None, startup_error=error)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> None:
|
|
157
|
+
"""Launch the hivemind-core (in-process) and the admin panel.
|
|
158
|
+
|
|
159
|
+
By default this starts ``hivemind-core`` inside this process and then serves
|
|
160
|
+
the admin panel. Pass ``--no-core`` to serve the panel only.
|
|
161
|
+
|
|
162
|
+
Note:
|
|
163
|
+
Default credentials are admin/admin. Change them in
|
|
164
|
+
~/.config/hivemind-core/server.json (admin_user, admin_pass).
|
|
165
|
+
"""
|
|
166
|
+
parser = argparse.ArgumentParser(description="HiveMind Admin Panel (launches hivemind-core + admin UI)")
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--host",
|
|
169
|
+
type=str,
|
|
170
|
+
default="127.0.0.1",
|
|
171
|
+
help="Admin panel host (default: 127.0.0.1)",
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--port",
|
|
175
|
+
type=int,
|
|
176
|
+
default=8100,
|
|
177
|
+
help="Admin panel port (default: 8100)",
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--no-core",
|
|
181
|
+
action="store_true",
|
|
182
|
+
help="Do not start an in-process hivemind-core; serve the admin panel only.",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--reload",
|
|
186
|
+
action="store_true",
|
|
187
|
+
help="Enable auto-reload for development (implies --no-core).",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--log-level",
|
|
191
|
+
type=str,
|
|
192
|
+
default="INFO",
|
|
193
|
+
help="Log level for the in-process hivemind-core (e.g. DEBUG, INFO, ERROR).",
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--version",
|
|
197
|
+
action="version",
|
|
198
|
+
version=f"%(prog)s {__version__}",
|
|
199
|
+
help="Show version and exit",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
args = parser.parse_args()
|
|
203
|
+
|
|
204
|
+
print(f"HiveMind Admin Panel v{__version__}")
|
|
205
|
+
|
|
206
|
+
# --reload runs uvicorn in a child process, which would not carry hivemind-core —
|
|
207
|
+
# so reload (a dev affordance) forces panel-only mode.
|
|
208
|
+
run_core = not args.no_core and not args.reload
|
|
209
|
+
|
|
210
|
+
from hivemind_admin_panel.api import set_runtime_info
|
|
211
|
+
set_runtime_info(run_mode="in-process" if run_core else "panel-only",
|
|
212
|
+
host=args.host)
|
|
213
|
+
|
|
214
|
+
if not run_core:
|
|
215
|
+
import uvicorn
|
|
216
|
+
print("hivemind-core: not started (--no-core)")
|
|
217
|
+
print(f"Admin panel: http://{args.host}:{args.port}")
|
|
218
|
+
print("Change admin credentials in server.json: admin_user, admin_pass")
|
|
219
|
+
uvicorn.run(
|
|
220
|
+
"hivemind_admin_panel.__main__:app",
|
|
221
|
+
host=args.host,
|
|
222
|
+
port=args.port,
|
|
223
|
+
reload=args.reload,
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# Run-core mode. hivemind-core's run() installs SIGINT/SIGTERM handlers, which only
|
|
228
|
+
# work on the main thread — so hivemind-core runs on the main thread and the admin
|
|
229
|
+
# panel (uvicorn, which skips signal handlers off the main thread) runs in a
|
|
230
|
+
# daemon thread.
|
|
231
|
+
from ovos_utils import wait_for_exit_signal
|
|
232
|
+
from ovos_utils.log import init_service_logger, LOG
|
|
233
|
+
from hivemind_admin_panel import start_admin_server
|
|
234
|
+
|
|
235
|
+
init_service_logger("core")
|
|
236
|
+
LOG.set_level(args.log_level)
|
|
237
|
+
|
|
238
|
+
service = launch_core()
|
|
239
|
+
start_admin_server(host=args.host, port=args.port) # panel in a daemon thread
|
|
240
|
+
print("hivemind-core: running in-process")
|
|
241
|
+
print(f"Admin panel: http://{args.host}:{args.port}")
|
|
242
|
+
print("Change admin credentials in server.json: admin_user, admin_pass")
|
|
243
|
+
|
|
244
|
+
if service is not None:
|
|
245
|
+
try:
|
|
246
|
+
service.run() # main thread; blocks until SIGINT/SIGTERM
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
pass
|
|
249
|
+
except Exception as error:
|
|
250
|
+
# The in-process core failed to start — e.g. its agent backend (an OVOS
|
|
251
|
+
# messagebus) is unreachable and the agent protocol now fails fast
|
|
252
|
+
# instead of hanging. Keep the admin UI up in diagnostics mode so the
|
|
253
|
+
# failure is visible at GET /api/startup-error and on the dashboard,
|
|
254
|
+
# rather than taking the whole panel down with it.
|
|
255
|
+
from hivemind_admin_panel.api import init_injected_objects
|
|
256
|
+
LOG.exception("hivemind-core stopped; admin panel staying up in diagnostics mode")
|
|
257
|
+
init_injected_objects(service=None, db=getattr(service, "db", None),
|
|
258
|
+
protocol=None, startup_error=error)
|
|
259
|
+
print(f"hivemind-core failed to start: {error}")
|
|
260
|
+
print("Admin panel staying up in diagnostics mode — see /api/startup-error")
|
|
261
|
+
wait_for_exit_signal()
|
|
262
|
+
else:
|
|
263
|
+
wait_for_exit_signal() # diagnostics mode: keep the panel up
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# hivemind-admin-panel
|
|
2
|
+
# Copyright (C) 2026 Casimiro Ferreira
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Authentication, roles and audit log for the admin panel.
|
|
5
|
+
|
|
6
|
+
Auth model
|
|
7
|
+
----------
|
|
8
|
+
Credentials live in ``server.json``. The legacy ``admin_user`` / ``admin_pass``
|
|
9
|
+
remain the primary full-admin account; an optional ``users`` list adds extra
|
|
10
|
+
accounts with roles:
|
|
11
|
+
|
|
12
|
+
"users": [{"username": "ops", "password": "...", "role": "operator"}]
|
|
13
|
+
|
|
14
|
+
Roles: ``admin`` (full) and ``operator`` (read + non-destructive writes; barred
|
|
15
|
+
from destructive actions guarded by :func:`require_admin` at the API layer).
|
|
16
|
+
|
|
17
|
+
Sessions are stateless HMAC-signed bearer tokens, so the API accepts either HTTP
|
|
18
|
+
Basic (username/password) or ``Authorization: Bearer <token>``.
|
|
19
|
+
|
|
20
|
+
Passwords are compared in plaintext for parity with hivemind-core's existing
|
|
21
|
+
``server.json`` model; hashing them is tracked as a hardening follow-up.
|
|
22
|
+
"""
|
|
23
|
+
import base64
|
|
24
|
+
import hashlib
|
|
25
|
+
import hmac
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
from ovos_utils.log import LOG
|
|
32
|
+
from ovos_utils.xdg_utils import xdg_data_home
|
|
33
|
+
|
|
34
|
+
ADMIN = "admin"
|
|
35
|
+
OPERATOR = "operator"
|
|
36
|
+
TOKEN_TTL = 12 * 3600 # 12h
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- users
|
|
40
|
+
|
|
41
|
+
def _users(config: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
42
|
+
"""All admin accounts: the primary admin_user plus any extra `users`."""
|
|
43
|
+
users = [{
|
|
44
|
+
"username": config.get("admin_user", "admin"),
|
|
45
|
+
"password": config.get("admin_pass", "admin"),
|
|
46
|
+
"role": ADMIN,
|
|
47
|
+
}]
|
|
48
|
+
for u in config.get("users", []) or []:
|
|
49
|
+
if u.get("username"):
|
|
50
|
+
users.append({
|
|
51
|
+
"username": u["username"],
|
|
52
|
+
"password": u.get("password", ""),
|
|
53
|
+
"role": u.get("role", OPERATOR),
|
|
54
|
+
})
|
|
55
|
+
return users
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def hash_password(password: str, iterations: int = 200_000) -> str:
|
|
59
|
+
"""Hash a password with PBKDF2-SHA256 (stored format ``pbkdf2_sha256$...``)."""
|
|
60
|
+
salt = os.urandom(16)
|
|
61
|
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
|
|
62
|
+
return f"pbkdf2_sha256${iterations}${salt.hex()}${dk.hex()}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def verify_password(stored: str, password: str) -> bool:
|
|
66
|
+
"""Verify a password against a stored PBKDF2 hash or legacy plaintext value."""
|
|
67
|
+
if stored.startswith("pbkdf2_sha256$"):
|
|
68
|
+
try:
|
|
69
|
+
_, iters, salt_hex, hash_hex = stored.split("$")
|
|
70
|
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode(),
|
|
71
|
+
bytes.fromhex(salt_hex), int(iters))
|
|
72
|
+
return hmac.compare_digest(dk.hex(), hash_hex)
|
|
73
|
+
except Exception:
|
|
74
|
+
return False
|
|
75
|
+
return hmac.compare_digest(stored, password) # legacy plaintext
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def authenticate(config: Dict[str, Any], username: str, password: str) -> Optional[str]:
|
|
79
|
+
"""Return the user's role if credentials match, else None.
|
|
80
|
+
|
|
81
|
+
Passwords may be PBKDF2 hashes or legacy plaintext (see :func:`verify_password`).
|
|
82
|
+
"""
|
|
83
|
+
role = None
|
|
84
|
+
for u in _users(config):
|
|
85
|
+
if hmac.compare_digest(u["username"], username) and verify_password(u["password"], password):
|
|
86
|
+
role = u["role"]
|
|
87
|
+
return role
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------- tokens
|
|
91
|
+
|
|
92
|
+
def _secret(config: Dict[str, Any]) -> bytes:
|
|
93
|
+
"""Persistent signing secret, generated into server.json on first use."""
|
|
94
|
+
secret = config.get("admin_token_secret")
|
|
95
|
+
if not secret:
|
|
96
|
+
secret = os.urandom(32).hex()
|
|
97
|
+
try:
|
|
98
|
+
config["admin_token_secret"] = secret
|
|
99
|
+
config.store()
|
|
100
|
+
except Exception as e: # config may be a plain dict in tests
|
|
101
|
+
LOG.debug(f"could not persist token secret: {e}")
|
|
102
|
+
return secret.encode()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create_token(config: Dict[str, Any], username: str, role: str, ttl: int = TOKEN_TTL) -> Dict[str, Any]:
|
|
106
|
+
payload = {"sub": username, "role": role, "exp": int(time.time()) + ttl}
|
|
107
|
+
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
|
108
|
+
sig = hmac.new(_secret(config), raw.encode(), hashlib.sha256).hexdigest()
|
|
109
|
+
return {"token": f"{raw}.{sig}", "role": role, "expires": payload["exp"]}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify_token(config: Dict[str, Any], token: str) -> Optional[Dict[str, Any]]:
|
|
113
|
+
try:
|
|
114
|
+
raw, sig = token.split(".", 1)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
expected = hmac.new(_secret(config), raw.encode(), hashlib.sha256).hexdigest()
|
|
118
|
+
if not hmac.compare_digest(sig, expected):
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
pad = "=" * (-len(raw) % 4)
|
|
122
|
+
payload = json.loads(base64.urlsafe_b64decode(raw + pad))
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
if payload.get("exp", 0) < time.time():
|
|
126
|
+
return None
|
|
127
|
+
return payload
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- audit
|
|
131
|
+
|
|
132
|
+
def _audit_path() -> str:
|
|
133
|
+
base = os.path.join(xdg_data_home(), "hivemind-admin")
|
|
134
|
+
os.makedirs(base, exist_ok=True)
|
|
135
|
+
return os.path.join(base, "audit.log")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def audit(user: str, action: str, **data: Any) -> None:
|
|
139
|
+
entry = {"ts": time.time(), "user": user, "action": action, **data}
|
|
140
|
+
try:
|
|
141
|
+
with open(_audit_path(), "a") as f:
|
|
142
|
+
f.write(json.dumps(entry) + "\n")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
LOG.debug(f"audit write failed: {e}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_audit(limit: int = 200) -> List[Dict[str, Any]]:
|
|
148
|
+
path = _audit_path()
|
|
149
|
+
if not os.path.isfile(path):
|
|
150
|
+
return []
|
|
151
|
+
try:
|
|
152
|
+
with open(path, "r", errors="replace") as f:
|
|
153
|
+
lines = f.readlines()[-limit:]
|
|
154
|
+
except Exception:
|
|
155
|
+
return []
|
|
156
|
+
out = []
|
|
157
|
+
for ln in lines:
|
|
158
|
+
try:
|
|
159
|
+
out.append(json.loads(ln))
|
|
160
|
+
except Exception:
|
|
161
|
+
continue
|
|
162
|
+
return out
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# hivemind-admin-panel
|
|
2
|
+
# Copyright (C) 2026 Casimiro Ferreira
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Server-side client impersonation: chat through the hub *as* a registered client.
|
|
5
|
+
|
|
6
|
+
The admin picks a client; the panel opens a real ``HiveMessageBusClient`` with that
|
|
7
|
+
client's credentials, connects to the hub like any satellite would, sends typed
|
|
8
|
+
utterances, and collects the agent's ``speak`` replies. This exercises the genuine
|
|
9
|
+
path — ACL enforcement, routing, the agent backend — not a simulation.
|
|
10
|
+
"""
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImpersonationSession:
|
|
18
|
+
"""One live impersonated client connection and its chat transcript."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client_id: int, name: str, key: str, password: str,
|
|
21
|
+
crypto_key: Optional[str], host: str, port: int):
|
|
22
|
+
self.id = uuid.uuid4().hex
|
|
23
|
+
self.client_id = client_id
|
|
24
|
+
self.name = name
|
|
25
|
+
self.error: Optional[str] = None
|
|
26
|
+
self.created = time.time()
|
|
27
|
+
self.last_used = time.time()
|
|
28
|
+
self._transcript: List[Dict[str, Any]] = []
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
self.bus = None
|
|
31
|
+
self._connect(key, password, crypto_key, host, port)
|
|
32
|
+
|
|
33
|
+
def _connect(self, key, password, crypto_key, host, port):
|
|
34
|
+
from ovos_utils.fakebus import FakeBus
|
|
35
|
+
from hivemind_bus_client import HiveMessageBusClient
|
|
36
|
+
|
|
37
|
+
self.bus = HiveMessageBusClient(
|
|
38
|
+
key=key, password=password, crypto_key=crypto_key,
|
|
39
|
+
host=host, port=port, self_signed=True, useragent="HiveMindAdminChat",
|
|
40
|
+
)
|
|
41
|
+
self.bus.on_mycroft("speak", self._on_speak)
|
|
42
|
+
self.bus.on_mycroft("hive.complete_intent_failure", self._on_fail)
|
|
43
|
+
|
|
44
|
+
def _do():
|
|
45
|
+
try:
|
|
46
|
+
self.bus.connect(FakeBus())
|
|
47
|
+
except Exception as e: # noqa: BLE001 - surfaced to the caller
|
|
48
|
+
self.error = str(e)
|
|
49
|
+
|
|
50
|
+
threading.Thread(target=_do, daemon=True).start()
|
|
51
|
+
if not self.bus.handshake_event.wait(15):
|
|
52
|
+
self.error = self.error or (
|
|
53
|
+
"handshake timed out — is the hub running and does this client have "
|
|
54
|
+
"a crypto key?")
|
|
55
|
+
|
|
56
|
+
# --- bus callbacks (run on the websocket thread) ---------------------------
|
|
57
|
+
def _on_speak(self, message):
|
|
58
|
+
utt = ""
|
|
59
|
+
try:
|
|
60
|
+
utt = message.data.get("utterance") or message.data.get("text") or ""
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
if utt:
|
|
64
|
+
self._append("assistant", utt)
|
|
65
|
+
|
|
66
|
+
def _on_fail(self, message):
|
|
67
|
+
self._append("system", "the hub reported no skill/agent handled that utterance")
|
|
68
|
+
|
|
69
|
+
def _append(self, role: str, text: str):
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._transcript.append({"role": role, "text": text, "ts": time.time()})
|
|
72
|
+
|
|
73
|
+
# --- public API ------------------------------------------------------------
|
|
74
|
+
def say(self, utterance: str, lang: str = "en-us"):
|
|
75
|
+
from hivemind_bus_client.message import HiveMessage, HiveMessageType
|
|
76
|
+
from ovos_bus_client.message import Message
|
|
77
|
+
|
|
78
|
+
self.last_used = time.time()
|
|
79
|
+
self._append("user", utterance)
|
|
80
|
+
self.bus.emit(HiveMessage(
|
|
81
|
+
HiveMessageType.BUS,
|
|
82
|
+
Message("recognizer_loop:utterance", {"utterances": [utterance], "lang": lang}),
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
def messages(self, since: int = 0) -> Tuple[List[Dict[str, Any]], int]:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self.last_used = time.time()
|
|
88
|
+
return list(self._transcript[since:]), len(self._transcript)
|
|
89
|
+
|
|
90
|
+
def close(self):
|
|
91
|
+
try:
|
|
92
|
+
self.bus.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ChatSessions:
|
|
98
|
+
"""Process-wide registry of impersonation sessions (bounded, idle-reaped)."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, max_sessions: int = 12, max_idle: float = 900.0):
|
|
101
|
+
self._sessions: Dict[str, ImpersonationSession] = {}
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
self._max = max_sessions
|
|
104
|
+
self._max_idle = max_idle
|
|
105
|
+
|
|
106
|
+
def _reap(self):
|
|
107
|
+
now = time.time()
|
|
108
|
+
for sid, s in list(self._sessions.items()):
|
|
109
|
+
if now - s.last_used > self._max_idle:
|
|
110
|
+
s.close()
|
|
111
|
+
self._sessions.pop(sid, None)
|
|
112
|
+
|
|
113
|
+
def create(self, client_id, name, key, password, crypto_key, host, port
|
|
114
|
+
) -> ImpersonationSession:
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._reap()
|
|
117
|
+
# one live session per client: replace any existing
|
|
118
|
+
for sid, s in list(self._sessions.items()):
|
|
119
|
+
if s.client_id == client_id:
|
|
120
|
+
s.close()
|
|
121
|
+
self._sessions.pop(sid, None)
|
|
122
|
+
if len(self._sessions) >= self._max:
|
|
123
|
+
oldest = min(self._sessions.values(), key=lambda s: s.last_used)
|
|
124
|
+
oldest.close()
|
|
125
|
+
self._sessions.pop(oldest.id, None)
|
|
126
|
+
sess = ImpersonationSession(client_id, name, key, password, crypto_key, host, port)
|
|
127
|
+
with self._lock:
|
|
128
|
+
self._sessions[sess.id] = sess
|
|
129
|
+
return sess
|
|
130
|
+
|
|
131
|
+
def get(self, sid: str) -> Optional[ImpersonationSession]:
|
|
132
|
+
return self._sessions.get(sid)
|
|
133
|
+
|
|
134
|
+
def close(self, sid: str):
|
|
135
|
+
with self._lock:
|
|
136
|
+
s = self._sessions.pop(sid, None)
|
|
137
|
+
if s:
|
|
138
|
+
s.close()
|
|
139
|
+
|
|
140
|
+
def list_active(self) -> List[Dict[str, Any]]:
|
|
141
|
+
return [{"session_id": s.id, "client_id": s.client_id, "name": s.name}
|
|
142
|
+
for s in self._sessions.values()]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# process-wide singleton
|
|
146
|
+
CHAT = ChatSessions()
|