halyn 0.2.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.
- halyn/__init__.py +7 -0
- halyn/__main__.py +4 -0
- halyn/audit.py +278 -0
- halyn/auth.py +88 -0
- halyn/autonomy.py +262 -0
- halyn/cli.py +208 -0
- halyn/config.py +135 -0
- halyn/consent.py +243 -0
- halyn/control_plane.py +354 -0
- halyn/discovery.py +323 -0
- halyn/drivers/__init__.py +0 -0
- halyn/drivers/browser.py +60 -0
- halyn/drivers/dds.py +156 -0
- halyn/drivers/docker.py +62 -0
- halyn/drivers/http_auto.py +259 -0
- halyn/drivers/mqtt.py +93 -0
- halyn/drivers/opcua.py +77 -0
- halyn/drivers/ros2.py +124 -0
- halyn/drivers/serial.py +226 -0
- halyn/drivers/socket_raw.py +153 -0
- halyn/drivers/ssh.py +131 -0
- halyn/drivers/unitree.py +103 -0
- halyn/drivers/websocket.py +175 -0
- halyn/engine.py +222 -0
- halyn/intent.py +240 -0
- halyn/llm.py +178 -0
- halyn/mcp.py +239 -0
- halyn/memory/__init__.py +0 -0
- halyn/memory/store.py +200 -0
- halyn/nrp_bridge.py +213 -0
- halyn/py.typed +0 -0
- halyn/sanitizer.py +120 -0
- halyn/server.py +292 -0
- halyn/types.py +116 -0
- halyn/watchdog.py +252 -0
- halyn-0.2.0.dist-info/METADATA +246 -0
- halyn-0.2.0.dist-info/RECORD +41 -0
- halyn-0.2.0.dist-info/WHEEL +5 -0
- halyn-0.2.0.dist-info/entry_points.txt +2 -0
- halyn-0.2.0.dist-info/licenses/LICENSE +15 -0
- halyn-0.2.0.dist-info/top_level.txt +1 -0
halyn/control_plane.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Control Plane — The brain of Halyn.
|
|
5
|
+
|
|
6
|
+
Integrates everything:
|
|
7
|
+
Discovery → Consent → Connect → Autonomy → Intent → Audit → Watchdog
|
|
8
|
+
|
|
9
|
+
This is the single entry point. One class. Everything wired.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from nrp import NRPDriver, NRPId, NRPManifest, EventBus, Severity
|
|
21
|
+
|
|
22
|
+
from .engine import Engine
|
|
23
|
+
from .types import Action, Result, ActionStatus
|
|
24
|
+
from .autonomy import AutonomyController, Level, DomainPolicy, PRESET_DOMAINS
|
|
25
|
+
from .audit import AuditStore
|
|
26
|
+
from .consent import ConsentStore, ConsentLevel
|
|
27
|
+
from .intent import IntentChain, IntentStore
|
|
28
|
+
from .watchdog import Watchdog, Health, check_event_bus, check_disk_space
|
|
29
|
+
from .discovery import Scanner, DiscoveredNode
|
|
30
|
+
from .nrp_bridge import register_nrp_node
|
|
31
|
+
from .config import HalynConfig
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("halyn.control_plane")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ControlPlane:
|
|
37
|
+
"""
|
|
38
|
+
The complete Halyn runtime.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
cp = ControlPlane.from_config("halyn.yml")
|
|
42
|
+
await cp.start()
|
|
43
|
+
result = await cp.execute("server/prod.observe", {"channels": "cpu,ram"})
|
|
44
|
+
await cp.stop()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: HalynConfig | None = None) -> None:
|
|
48
|
+
self.config = config or HalynConfig.load()
|
|
49
|
+
|
|
50
|
+
# Core
|
|
51
|
+
self.engine = Engine()
|
|
52
|
+
self.event_bus = EventBus()
|
|
53
|
+
|
|
54
|
+
# Safety & Control
|
|
55
|
+
self.autonomy = AutonomyController(default_level=Level.SUPERVISED)
|
|
56
|
+
self.audit = AuditStore(f"{self.config.data_dir}/audit.db")
|
|
57
|
+
self.consent = ConsentStore(f"{self.config.data_dir}/consent.db")
|
|
58
|
+
self.intents = IntentStore(f"{self.config.data_dir}/intent.db")
|
|
59
|
+
|
|
60
|
+
# Health
|
|
61
|
+
self.watchdog = Watchdog(interval=15.0)
|
|
62
|
+
|
|
63
|
+
# Discovery
|
|
64
|
+
self.scanner = Scanner()
|
|
65
|
+
|
|
66
|
+
# State
|
|
67
|
+
self._nodes: dict[str, NRPDriver] = {}
|
|
68
|
+
self._manifests: dict[str, NRPManifest] = {}
|
|
69
|
+
self._running = False
|
|
70
|
+
self._emergency_stop = False
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_config(cls, path: str = "") -> ControlPlane:
|
|
74
|
+
config = HalynConfig.load(path)
|
|
75
|
+
cp = cls(config)
|
|
76
|
+
|
|
77
|
+
# Load domain policies from config
|
|
78
|
+
for name, domain_cfg in config.domains.items():
|
|
79
|
+
if isinstance(domain_cfg, dict):
|
|
80
|
+
cp.autonomy.add_domain(DomainPolicy(
|
|
81
|
+
name=name,
|
|
82
|
+
level=Level(domain_cfg.get("level", 1)),
|
|
83
|
+
node_patterns=domain_cfg.get("nodes", ["*"]),
|
|
84
|
+
confirm_commands=domain_cfg.get("confirm", []),
|
|
85
|
+
blocked_commands=domain_cfg.get("blocked", []),
|
|
86
|
+
max_actions_per_hour=domain_cfg.get("max_actions_per_hour", 1000),
|
|
87
|
+
))
|
|
88
|
+
elif name in PRESET_DOMAINS:
|
|
89
|
+
cp.autonomy.add_domain(PRESET_DOMAINS[name])
|
|
90
|
+
|
|
91
|
+
return cp
|
|
92
|
+
|
|
93
|
+
async def start(self) -> None:
|
|
94
|
+
"""Start all services."""
|
|
95
|
+
self._running = True
|
|
96
|
+
|
|
97
|
+
# Register watchdog checks
|
|
98
|
+
self.watchdog.register("event_bus", lambda: check_event_bus(self.event_bus))
|
|
99
|
+
self.watchdog.register("disk", lambda: check_disk_space("/"))
|
|
100
|
+
self.watchdog.on_failsafe(self._failsafe)
|
|
101
|
+
|
|
102
|
+
# Start background tasks
|
|
103
|
+
asyncio.create_task(self.event_bus.process_loop())
|
|
104
|
+
asyncio.create_task(self.watchdog.run())
|
|
105
|
+
|
|
106
|
+
# Auto-connect nodes from config
|
|
107
|
+
for node_cfg in self.config.nodes:
|
|
108
|
+
try:
|
|
109
|
+
await self._connect_from_config(node_cfg)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
log.error("control_plane.auto_connect_failed node=%s error=%s",
|
|
112
|
+
node_cfg.get("id", "?"), exc)
|
|
113
|
+
|
|
114
|
+
log.info("control_plane.started nodes=%d tools=%d",
|
|
115
|
+
len(self._nodes), len(self.engine.registry.tool_names))
|
|
116
|
+
|
|
117
|
+
async def stop(self) -> None:
|
|
118
|
+
"""Graceful shutdown."""
|
|
119
|
+
self._running = False
|
|
120
|
+
self.watchdog.stop()
|
|
121
|
+
|
|
122
|
+
for nrp_id, driver in self._nodes.items():
|
|
123
|
+
try:
|
|
124
|
+
await driver.disconnect()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
self.audit.close()
|
|
129
|
+
self.consent.close()
|
|
130
|
+
self.intents.close()
|
|
131
|
+
log.info("control_plane.stopped")
|
|
132
|
+
|
|
133
|
+
async def execute(
|
|
134
|
+
self,
|
|
135
|
+
tool: str,
|
|
136
|
+
args: dict[str, Any] | None = None,
|
|
137
|
+
user_id: str = "",
|
|
138
|
+
llm_model: str = "",
|
|
139
|
+
intent_text: str = "",
|
|
140
|
+
) -> Result:
|
|
141
|
+
"""
|
|
142
|
+
Execute an action through the full pipeline:
|
|
143
|
+
Intent → Autonomy Check → Engine Execute → Audit
|
|
144
|
+
"""
|
|
145
|
+
args = args or {}
|
|
146
|
+
action = Action(tool=tool, args=args)
|
|
147
|
+
|
|
148
|
+
# Emergency stop check
|
|
149
|
+
if self._emergency_stop:
|
|
150
|
+
return Result(status=ActionStatus.DENIED, error="EMERGENCY STOP ACTIVE")
|
|
151
|
+
|
|
152
|
+
# Build intent chain
|
|
153
|
+
chain = IntentChain(
|
|
154
|
+
user_id=user_id, llm_model=llm_model,
|
|
155
|
+
node=action.node, domain="",
|
|
156
|
+
)
|
|
157
|
+
if intent_text:
|
|
158
|
+
chain.request(intent_text)
|
|
159
|
+
|
|
160
|
+
# Get tool spec for autonomy check
|
|
161
|
+
spec = self.engine.registry.get_spec(tool)
|
|
162
|
+
if not spec:
|
|
163
|
+
chain.blocked(f"Tool not found: {tool}")
|
|
164
|
+
self.intents.save(chain)
|
|
165
|
+
return Result(status=ActionStatus.FAILED, error=f"Tool not found: {tool}")
|
|
166
|
+
|
|
167
|
+
tool_dangerous = getattr(spec, 'dangerous', False)
|
|
168
|
+
|
|
169
|
+
# Autonomy check
|
|
170
|
+
decision, reason = self.autonomy.check(action, spec.category, tool_dangerous)
|
|
171
|
+
chain.add("autonomy", f"{decision}: {reason}", level=decision)
|
|
172
|
+
|
|
173
|
+
if decision == "deny":
|
|
174
|
+
chain.blocked(reason)
|
|
175
|
+
self.intents.save(chain)
|
|
176
|
+
self.audit.record(
|
|
177
|
+
tool=tool, node=action.node, args=args,
|
|
178
|
+
status="denied", decision=decision,
|
|
179
|
+
user_id=user_id, llm_model=llm_model,
|
|
180
|
+
intent=intent_text, domain=chain.domain,
|
|
181
|
+
)
|
|
182
|
+
return Result(status=ActionStatus.DENIED, error=reason)
|
|
183
|
+
|
|
184
|
+
if decision == "confirm":
|
|
185
|
+
# Create confirmation request
|
|
186
|
+
req_id = f"req-{int(time.time()*1000)}"
|
|
187
|
+
self.autonomy.request_confirmation(req_id, action, reason)
|
|
188
|
+
chain.add("confirm_required", f"Waiting for approval: {reason}", request_id=req_id)
|
|
189
|
+
self.intents.save(chain)
|
|
190
|
+
return Result(
|
|
191
|
+
status=ActionStatus.DENIED,
|
|
192
|
+
error=f"Confirmation required: {reason}",
|
|
193
|
+
data={"request_id": req_id, "reason": reason},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Execute
|
|
197
|
+
chain.action(f"Executing {tool}", tool=tool)
|
|
198
|
+
t0 = time.time()
|
|
199
|
+
result = await self.engine.execute(action)
|
|
200
|
+
duration = (time.time() - t0) * 1000
|
|
201
|
+
|
|
202
|
+
# Record result
|
|
203
|
+
if result.ok:
|
|
204
|
+
chain.result(str(result.data)[:200], success=True)
|
|
205
|
+
else:
|
|
206
|
+
chain.result(f"Failed: {result.error}", success=False)
|
|
207
|
+
|
|
208
|
+
# Save intent + audit
|
|
209
|
+
self.intents.save(chain)
|
|
210
|
+
self.audit.record(
|
|
211
|
+
tool=tool, node=action.node, args=args,
|
|
212
|
+
result=str(result.data or result.error)[:500],
|
|
213
|
+
status="ok" if result.ok else "error",
|
|
214
|
+
duration_ms=duration,
|
|
215
|
+
user_id=user_id, llm_model=llm_model,
|
|
216
|
+
intent=intent_text, domain=chain.domain,
|
|
217
|
+
decision=decision,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
# ─── Node Management ────────────────────────
|
|
223
|
+
|
|
224
|
+
async def connect_node(
|
|
225
|
+
self,
|
|
226
|
+
nrp_id: str,
|
|
227
|
+
driver: NRPDriver,
|
|
228
|
+
require_consent: bool = True,
|
|
229
|
+
) -> NRPManifest | None:
|
|
230
|
+
"""Connect a node with consent check."""
|
|
231
|
+
# Check consent
|
|
232
|
+
if require_consent:
|
|
233
|
+
record = self.consent.check(nrp_id)
|
|
234
|
+
if record is None or record.level == ConsentLevel.PENDING:
|
|
235
|
+
# Request consent
|
|
236
|
+
self.consent.request_consent(nrp_id, "New device")
|
|
237
|
+
log.info("control_plane.consent_pending nrp_id=%s", nrp_id)
|
|
238
|
+
return None
|
|
239
|
+
if record.level == ConsentLevel.DENY:
|
|
240
|
+
log.info("control_plane.consent_denied nrp_id=%s", nrp_id)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
# Register via NRP bridge
|
|
244
|
+
manifest = await register_nrp_node(
|
|
245
|
+
self.engine, nrp_id, driver, self.event_bus,
|
|
246
|
+
)
|
|
247
|
+
self._nodes[nrp_id] = driver
|
|
248
|
+
self._manifests[nrp_id] = manifest
|
|
249
|
+
|
|
250
|
+
# Register watchdog
|
|
251
|
+
async def check_node() -> Health:
|
|
252
|
+
try:
|
|
253
|
+
hb = await asyncio.wait_for(driver.heartbeat(), timeout=10)
|
|
254
|
+
return Health.GREEN if hb.get("alive") else Health.RED
|
|
255
|
+
except Exception:
|
|
256
|
+
return Health.RED
|
|
257
|
+
self.watchdog.register(f"node:{nrp_id}", check_node)
|
|
258
|
+
|
|
259
|
+
log.info("control_plane.connected nrp_id=%s tools=%d",
|
|
260
|
+
nrp_id, len(manifest.act) + 4)
|
|
261
|
+
return manifest
|
|
262
|
+
|
|
263
|
+
async def scan(self, config: dict[str, Any] | None = None) -> list[DiscoveredNode]:
|
|
264
|
+
"""Scan the network for new devices."""
|
|
265
|
+
nodes = await self.scanner.scan_all(config)
|
|
266
|
+
log.info("control_plane.scan found=%d", len(nodes))
|
|
267
|
+
return nodes
|
|
268
|
+
|
|
269
|
+
# ─── Emergency Stop ─────────────────────────
|
|
270
|
+
|
|
271
|
+
async def emergency_stop(self) -> None:
|
|
272
|
+
"""STOP EVERYTHING. Immediately."""
|
|
273
|
+
self._emergency_stop = True
|
|
274
|
+
log.critical("EMERGENCY STOP ACTIVATED")
|
|
275
|
+
|
|
276
|
+
await self.event_bus.emit_simple(
|
|
277
|
+
"halyn.control_plane", "emergency_stop",
|
|
278
|
+
Severity.EMERGENCY,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Tell all nodes to stop
|
|
282
|
+
for nrp_id, driver in self._nodes.items():
|
|
283
|
+
try:
|
|
284
|
+
await driver.act("emergency_stop", {})
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
self.audit.record(
|
|
289
|
+
tool="EMERGENCY_STOP", status="executed",
|
|
290
|
+
intent="Emergency stop activated",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def resume(self) -> None:
|
|
294
|
+
"""Resume after emergency stop."""
|
|
295
|
+
self._emergency_stop = False
|
|
296
|
+
log.info("control_plane.resumed")
|
|
297
|
+
self.audit.record(tool="RESUME", status="executed")
|
|
298
|
+
|
|
299
|
+
# ─── Status ─────────────────────────────────
|
|
300
|
+
|
|
301
|
+
def status(self) -> dict[str, Any]:
|
|
302
|
+
"""Complete system status."""
|
|
303
|
+
return {
|
|
304
|
+
"running": self._running,
|
|
305
|
+
"emergency_stop": self._emergency_stop,
|
|
306
|
+
"nodes": len(self._nodes),
|
|
307
|
+
"tools": len(self.engine.registry.tool_names),
|
|
308
|
+
"audit_entries": self.audit.count,
|
|
309
|
+
"audit_chain_valid": self.audit.verify_chain()[0],
|
|
310
|
+
"pending_consents": self.consent.pending_count(),
|
|
311
|
+
"pending_confirmations": len(self.autonomy.get_pending()),
|
|
312
|
+
"watchdog": self.watchdog.status_report(),
|
|
313
|
+
"event_bus": {
|
|
314
|
+
"total": self.event_bus.total,
|
|
315
|
+
"pending": self.event_bus.pending,
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# ─── Internal ───────────────────────────────
|
|
320
|
+
|
|
321
|
+
async def _connect_from_config(self, node_cfg: dict[str, Any]) -> None:
|
|
322
|
+
"""Connect a node from YAML config."""
|
|
323
|
+
nrp_id = node_cfg.get("id", "")
|
|
324
|
+
driver_type = node_cfg.get("driver", "")
|
|
325
|
+
|
|
326
|
+
if driver_type == "ssh":
|
|
327
|
+
from .drivers.ssh import SSHDriver
|
|
328
|
+
driver = SSHDriver(
|
|
329
|
+
host=node_cfg.get("host", ""),
|
|
330
|
+
user=node_cfg.get("user", ""),
|
|
331
|
+
key_path=node_cfg.get("key", ""),
|
|
332
|
+
)
|
|
333
|
+
elif driver_type == "http_auto":
|
|
334
|
+
from .drivers.http_auto import HTTPAutoDriver
|
|
335
|
+
driver = HTTPAutoDriver(
|
|
336
|
+
base_url=node_cfg.get("base_url", ""),
|
|
337
|
+
auth_token=node_cfg.get("auth_token", ""),
|
|
338
|
+
)
|
|
339
|
+
elif driver_type == "mqtt":
|
|
340
|
+
from .drivers.mqtt import MQTTDriver
|
|
341
|
+
driver = MQTTDriver(
|
|
342
|
+
broker=node_cfg.get("broker", ""),
|
|
343
|
+
topic=node_cfg.get("topic", "#"),
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
log.warning("control_plane.unknown_driver type=%s", driver_type)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
await self.connect_node(nrp_id, driver, require_consent=False)
|
|
350
|
+
|
|
351
|
+
async def _failsafe(self) -> None:
|
|
352
|
+
"""Failsafe handler — called when watchdog detects critical failure."""
|
|
353
|
+
log.critical("FAILSAFE ACTIVATED — putting all nodes in safe mode")
|
|
354
|
+
await self.emergency_stop()
|
halyn/discovery.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Discovery — Find nodes on the network automatically.
|
|
5
|
+
|
|
6
|
+
Network scanner for NRP-compatible devices.
|
|
7
|
+
Probes SSH, MQTT, HTTP, Docker, and OPC-UA endpoints.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import socket
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("halyn.discovery")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class DiscoveredNode:
|
|
25
|
+
"""A node found during scan. Not yet connected."""
|
|
26
|
+
address: str # IP or hostname
|
|
27
|
+
port: int = 0
|
|
28
|
+
protocol: str = "" # ssh, mqtt, http, ros2, opcua, docker, mdns
|
|
29
|
+
name: str = "" # Human-readable name if available
|
|
30
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def suggested_nrp_id(self) -> str:
|
|
34
|
+
host = self.address.replace(".", "-")
|
|
35
|
+
kind = self.protocol or "unknown"
|
|
36
|
+
return f"nrp://local/{kind}/{host}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Scanner:
|
|
40
|
+
"""
|
|
41
|
+
Network scanner. Discovers potential NRP nodes.
|
|
42
|
+
|
|
43
|
+
Probes common ports and protocols to find devices
|
|
44
|
+
that could be connected via NRP drivers.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# Common ports and what they indicate
|
|
48
|
+
PORT_MAP: dict[int, str] = {
|
|
49
|
+
22: "ssh",
|
|
50
|
+
80: "http",
|
|
51
|
+
443: "https",
|
|
52
|
+
1883: "mqtt",
|
|
53
|
+
8883: "mqtt-tls",
|
|
54
|
+
4840: "opcua",
|
|
55
|
+
2375: "docker",
|
|
56
|
+
2376: "docker-tls",
|
|
57
|
+
7400: "ros2-dds",
|
|
58
|
+
8080: "http",
|
|
59
|
+
8443: "https",
|
|
60
|
+
8935: "halyn",
|
|
61
|
+
9090: "prometheus",
|
|
62
|
+
3000: "grafana",
|
|
63
|
+
5432: "postgres",
|
|
64
|
+
6379: "redis",
|
|
65
|
+
11311: "ros-master",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def __init__(self, timeout: float = 1.0, max_concurrent: int = 100) -> None:
|
|
69
|
+
self._timeout = timeout
|
|
70
|
+
self._max_concurrent = max_concurrent
|
|
71
|
+
|
|
72
|
+
async def scan_host(self, host: str, ports: list[int] | None = None) -> list[DiscoveredNode]:
|
|
73
|
+
"""Scan a single host for open ports."""
|
|
74
|
+
ports = ports or list(self.PORT_MAP.keys())
|
|
75
|
+
nodes: list[DiscoveredNode] = []
|
|
76
|
+
sem = asyncio.Semaphore(self._max_concurrent)
|
|
77
|
+
|
|
78
|
+
async def probe(port: int) -> DiscoveredNode | None:
|
|
79
|
+
async with sem:
|
|
80
|
+
try:
|
|
81
|
+
_, writer = await asyncio.wait_for(
|
|
82
|
+
asyncio.open_connection(host, port),
|
|
83
|
+
timeout=self._timeout,
|
|
84
|
+
)
|
|
85
|
+
writer.close()
|
|
86
|
+
await writer.wait_closed()
|
|
87
|
+
protocol = self.PORT_MAP.get(port, "tcp")
|
|
88
|
+
return DiscoveredNode(
|
|
89
|
+
address=host, port=port, protocol=protocol,
|
|
90
|
+
name=f"{host}:{port}",
|
|
91
|
+
metadata={"port": port, "protocol": protocol},
|
|
92
|
+
)
|
|
93
|
+
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
results = await asyncio.gather(*[probe(p) for p in ports])
|
|
97
|
+
return [n for n in results if n is not None]
|
|
98
|
+
|
|
99
|
+
async def scan_subnet(self, subnet: str, ports: list[int] | None = None) -> list[DiscoveredNode]:
|
|
100
|
+
"""
|
|
101
|
+
Scan a subnet (e.g. '192.168.1.0/24') for live hosts.
|
|
102
|
+
Uses ping sweep first, then port scan on responders.
|
|
103
|
+
"""
|
|
104
|
+
# Parse subnet
|
|
105
|
+
if "/" not in subnet:
|
|
106
|
+
subnet += "/24"
|
|
107
|
+
base, prefix = subnet.rsplit("/", 1)
|
|
108
|
+
prefix = int(prefix)
|
|
109
|
+
|
|
110
|
+
if prefix < 24:
|
|
111
|
+
log.warning("discovery.subnet too large: %s (max /24)", subnet)
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
# Generate host list
|
|
115
|
+
parts = base.split(".")
|
|
116
|
+
hosts = []
|
|
117
|
+
if prefix == 24:
|
|
118
|
+
for i in range(1, 255):
|
|
119
|
+
hosts.append(f"{parts[0]}.{parts[1]}.{parts[2]}.{i}")
|
|
120
|
+
else:
|
|
121
|
+
hosts = [base]
|
|
122
|
+
|
|
123
|
+
# Ping sweep (fast)
|
|
124
|
+
live_hosts = await self._ping_sweep(hosts)
|
|
125
|
+
log.info("discovery.ping_sweep found=%d/%d", len(live_hosts), len(hosts))
|
|
126
|
+
|
|
127
|
+
# Port scan live hosts
|
|
128
|
+
all_nodes: list[DiscoveredNode] = []
|
|
129
|
+
for host in live_hosts:
|
|
130
|
+
nodes = await self.scan_host(host, ports)
|
|
131
|
+
all_nodes.extend(nodes)
|
|
132
|
+
|
|
133
|
+
return all_nodes
|
|
134
|
+
|
|
135
|
+
async def scan_ssh(self, hosts: list[str], user: str = "", key_path: str = "") -> list[DiscoveredNode]:
|
|
136
|
+
"""Quick SSH reachability check on a list of hosts."""
|
|
137
|
+
nodes: list[DiscoveredNode] = []
|
|
138
|
+
for host in hosts:
|
|
139
|
+
try:
|
|
140
|
+
cmd = ["ssh", "-o", "StrictHostKeyChecking=no",
|
|
141
|
+
"-o", "ConnectTimeout=3", "-o", "BatchMode=yes"]
|
|
142
|
+
if key_path:
|
|
143
|
+
cmd += ["-i", key_path]
|
|
144
|
+
target = f"{user}@{host}" if user else host
|
|
145
|
+
cmd += [target, "hostname"]
|
|
146
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
147
|
+
if r.returncode == 0:
|
|
148
|
+
hostname = r.stdout.strip()
|
|
149
|
+
nodes.append(DiscoveredNode(
|
|
150
|
+
address=host, port=22, protocol="ssh",
|
|
151
|
+
name=hostname,
|
|
152
|
+
metadata={"hostname": hostname, "user": user},
|
|
153
|
+
))
|
|
154
|
+
except (subprocess.TimeoutExpired, Exception) as e:
|
|
155
|
+
log.debug("discovery.ssh_failed host=%s error=%s", host, e)
|
|
156
|
+
return nodes
|
|
157
|
+
|
|
158
|
+
async def scan_mqtt(self, broker: str = "localhost", port: int = 1883) -> list[DiscoveredNode]:
|
|
159
|
+
"""Check if an MQTT broker is reachable and list topics."""
|
|
160
|
+
try:
|
|
161
|
+
_, writer = await asyncio.wait_for(
|
|
162
|
+
asyncio.open_connection(broker, port),
|
|
163
|
+
timeout=self._timeout,
|
|
164
|
+
)
|
|
165
|
+
writer.close()
|
|
166
|
+
await writer.wait_closed()
|
|
167
|
+
return [DiscoveredNode(
|
|
168
|
+
address=broker, port=port, protocol="mqtt",
|
|
169
|
+
name=f"MQTT Broker {broker}",
|
|
170
|
+
metadata={"broker": broker, "port": port},
|
|
171
|
+
)]
|
|
172
|
+
except Exception:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
async def scan_docker(self, host: str = "localhost") -> list[DiscoveredNode]:
|
|
176
|
+
"""Discover Docker containers on a host."""
|
|
177
|
+
nodes: list[DiscoveredNode] = []
|
|
178
|
+
try:
|
|
179
|
+
cmd = ["docker", "ps", "--format", '{{.Names}}\t{{.Image}}\t{{.Status}}']
|
|
180
|
+
if host != "localhost":
|
|
181
|
+
cmd = ["docker", "-H", f"tcp://{host}:2375"] + cmd[1:]
|
|
182
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
183
|
+
if r.returncode == 0:
|
|
184
|
+
for line in r.stdout.strip().split("\n"):
|
|
185
|
+
if not line:
|
|
186
|
+
continue
|
|
187
|
+
parts = line.split("\t")
|
|
188
|
+
name = parts[0] if parts else "unknown"
|
|
189
|
+
image = parts[1] if len(parts) > 1 else ""
|
|
190
|
+
status = parts[2] if len(parts) > 2 else ""
|
|
191
|
+
nodes.append(DiscoveredNode(
|
|
192
|
+
address=host, port=2375, protocol="docker",
|
|
193
|
+
name=name,
|
|
194
|
+
metadata={"image": image, "status": status, "container": name},
|
|
195
|
+
))
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log.debug("discovery.docker_failed host=%s error=%s", host, e)
|
|
198
|
+
return nodes
|
|
199
|
+
|
|
200
|
+
async def scan_http(self, urls: list[str]) -> list[DiscoveredNode]:
|
|
201
|
+
"""Check HTTP endpoints for OpenAPI/health."""
|
|
202
|
+
nodes: list[DiscoveredNode] = []
|
|
203
|
+
try:
|
|
204
|
+
import aiohttp
|
|
205
|
+
except ImportError:
|
|
206
|
+
return nodes
|
|
207
|
+
|
|
208
|
+
async with aiohttp.ClientSession() as session:
|
|
209
|
+
for url in urls:
|
|
210
|
+
try:
|
|
211
|
+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
|
|
212
|
+
if resp.status < 500:
|
|
213
|
+
# Try to find OpenAPI spec
|
|
214
|
+
has_openapi = False
|
|
215
|
+
for spec_path in ["/openapi.json", "/swagger.json"]:
|
|
216
|
+
try:
|
|
217
|
+
spec_url = url.rstrip("/") + spec_path
|
|
218
|
+
async with session.get(spec_url, timeout=aiohttp.ClientTimeout(total=3)) as sr:
|
|
219
|
+
if sr.status == 200:
|
|
220
|
+
has_openapi = True
|
|
221
|
+
break
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
from urllib.parse import urlparse
|
|
226
|
+
parsed = urlparse(url)
|
|
227
|
+
nodes.append(DiscoveredNode(
|
|
228
|
+
address=parsed.hostname or url,
|
|
229
|
+
port=parsed.port or (443 if parsed.scheme == "https" else 80),
|
|
230
|
+
protocol="http",
|
|
231
|
+
name=parsed.hostname or url,
|
|
232
|
+
metadata={"url": url, "status": resp.status,
|
|
233
|
+
"has_openapi": has_openapi},
|
|
234
|
+
))
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
return nodes
|
|
238
|
+
|
|
239
|
+
async def scan_all(self, config: dict[str, Any] | None = None) -> list[DiscoveredNode]:
|
|
240
|
+
"""
|
|
241
|
+
Run all scanners based on config.
|
|
242
|
+
Returns deduplicated list of discovered nodes.
|
|
243
|
+
"""
|
|
244
|
+
config = config or {}
|
|
245
|
+
all_nodes: list[DiscoveredNode] = []
|
|
246
|
+
|
|
247
|
+
# SSH hosts
|
|
248
|
+
ssh_hosts = config.get("ssh_hosts", [])
|
|
249
|
+
if ssh_hosts:
|
|
250
|
+
nodes = await self.scan_ssh(
|
|
251
|
+
ssh_hosts,
|
|
252
|
+
user=config.get("ssh_user", ""),
|
|
253
|
+
key_path=config.get("ssh_key", ""),
|
|
254
|
+
)
|
|
255
|
+
all_nodes.extend(nodes)
|
|
256
|
+
log.info("discovery.ssh found=%d", len(nodes))
|
|
257
|
+
|
|
258
|
+
# Subnet scan
|
|
259
|
+
subnets = config.get("subnets", [])
|
|
260
|
+
for subnet in subnets:
|
|
261
|
+
nodes = await self.scan_subnet(subnet)
|
|
262
|
+
all_nodes.extend(nodes)
|
|
263
|
+
log.info("discovery.subnet %s found=%d", subnet, len(nodes))
|
|
264
|
+
|
|
265
|
+
# MQTT brokers
|
|
266
|
+
mqtt_brokers = config.get("mqtt_brokers", [])
|
|
267
|
+
for broker in mqtt_brokers:
|
|
268
|
+
nodes = await self.scan_mqtt(broker)
|
|
269
|
+
all_nodes.extend(nodes)
|
|
270
|
+
|
|
271
|
+
# Docker
|
|
272
|
+
docker_hosts = config.get("docker_hosts", ["localhost"])
|
|
273
|
+
for host in docker_hosts:
|
|
274
|
+
nodes = await self.scan_docker(host)
|
|
275
|
+
all_nodes.extend(nodes)
|
|
276
|
+
log.info("discovery.docker %s found=%d", host, len(nodes))
|
|
277
|
+
|
|
278
|
+
# HTTP APIs
|
|
279
|
+
http_urls = config.get("http_urls", [])
|
|
280
|
+
if http_urls:
|
|
281
|
+
nodes = await self.scan_http(http_urls)
|
|
282
|
+
all_nodes.extend(nodes)
|
|
283
|
+
log.info("discovery.http found=%d", len(nodes))
|
|
284
|
+
|
|
285
|
+
log.info("discovery.complete total=%d", len(all_nodes))
|
|
286
|
+
return all_nodes
|
|
287
|
+
|
|
288
|
+
async def _ping_sweep(self, hosts: list[str]) -> list[str]:
|
|
289
|
+
"""Fast ping sweep using asyncio."""
|
|
290
|
+
live: list[str] = []
|
|
291
|
+
sem = asyncio.Semaphore(50)
|
|
292
|
+
|
|
293
|
+
async def ping(host: str) -> str | None:
|
|
294
|
+
async with sem:
|
|
295
|
+
try:
|
|
296
|
+
proc = await asyncio.create_subprocess_exec(
|
|
297
|
+
"ping", "-c", "1", "-W", "1", host,
|
|
298
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
299
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
300
|
+
)
|
|
301
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
302
|
+
return host if proc.returncode == 0 else None
|
|
303
|
+
except Exception:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
results = await asyncio.gather(*[ping(h) for h in hosts])
|
|
307
|
+
return [h for h in results if h is not None]
|
|
308
|
+
|
|
309
|
+
def format_results(self, nodes: list[DiscoveredNode]) -> str:
|
|
310
|
+
"""Human-readable scan results."""
|
|
311
|
+
if not nodes:
|
|
312
|
+
return "No nodes discovered."
|
|
313
|
+
lines = [f"Discovered {len(nodes)} node(s):\n"]
|
|
314
|
+
for n in nodes:
|
|
315
|
+
meta = ""
|
|
316
|
+
if n.metadata.get("hostname"):
|
|
317
|
+
meta = f" ({n.metadata['hostname']})"
|
|
318
|
+
elif n.metadata.get("has_openapi"):
|
|
319
|
+
meta = " (OpenAPI detected)"
|
|
320
|
+
elif n.metadata.get("container"):
|
|
321
|
+
meta = f" [{n.metadata.get('image', '')}]"
|
|
322
|
+
lines.append(f" {n.suggested_nrp_id:45s} {n.protocol:8s} {n.address}:{n.port}{meta}")
|
|
323
|
+
return "\n".join(lines)
|
|
File without changes
|