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/cli.py ADDED
@@ -0,0 +1,208 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ CLI — halyn serve | scan | status | test | emergency-stop
5
+
6
+ The command line is the cockpit.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import sys
16
+ import time
17
+ from typing import Any
18
+
19
+
20
+ def main() -> None:
21
+ parser = argparse.ArgumentParser(
22
+ prog="halyn",
23
+ description="Halyn — NRP Control Plane",
24
+ )
25
+ sub = parser.add_subparsers(dest="command")
26
+
27
+ # serve
28
+ p_serve = sub.add_parser("serve", help="Start the control plane + HTTP server")
29
+ p_serve.add_argument("--config", "-c", default="", help="Config file path")
30
+ p_serve.add_argument("--host", default="", help="Override host")
31
+ p_serve.add_argument("--port", type=int, default=0, help="Override port")
32
+
33
+ # scan
34
+ p_scan = sub.add_parser("scan", help="Discover nodes on the network")
35
+ p_scan.add_argument("--subnet", default="", help="Subnet to scan (e.g. 192.168.1.0/24)")
36
+ p_scan.add_argument("--ssh", nargs="*", default=[], help="SSH hosts to check")
37
+ p_scan.add_argument("--mqtt", nargs="*", default=[], help="MQTT brokers to check")
38
+ p_scan.add_argument("--http", nargs="*", default=[], help="HTTP URLs to check")
39
+ p_scan.add_argument("--docker", nargs="*", default=[], help="Docker hosts")
40
+ p_scan.add_argument("--json", action="store_true", help="Output as JSON")
41
+
42
+ # status
43
+ p_status = sub.add_parser("status", help="Show control plane status")
44
+ p_status.add_argument("--config", "-c", default="")
45
+
46
+ # test
47
+ p_test = sub.add_parser("test", help="Run test suite")
48
+
49
+ # emergency-stop
50
+ sub.add_parser("emergency-stop", help="STOP ALL NODES IMMEDIATELY")
51
+
52
+ # version
53
+ sub.add_parser("version", help="Show version")
54
+
55
+ args = parser.parse_args()
56
+
57
+ if args.command == "serve":
58
+ _cmd_serve(args)
59
+ elif args.command == "scan":
60
+ _cmd_scan(args)
61
+ elif args.command == "status":
62
+ _cmd_status(args)
63
+ elif args.command == "test":
64
+ _cmd_test()
65
+ elif args.command == "emergency-stop":
66
+ _cmd_emergency_stop(args)
67
+ elif args.command == "version":
68
+ from . import __version__
69
+ print(f"Halyn v{__version__}")
70
+ else:
71
+ parser.print_help()
72
+
73
+
74
+ def _cmd_serve(args: Any) -> None:
75
+ from .config import HalynConfig
76
+ from .control_plane import ControlPlane
77
+
78
+ logging.basicConfig(
79
+ level=logging.INFO,
80
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
81
+ datefmt="%H:%M:%S",
82
+ )
83
+
84
+ config = HalynConfig.load(args.config)
85
+ if args.host:
86
+ config.host = args.host
87
+ if args.port:
88
+ config.port = args.port
89
+
90
+ cp = ControlPlane(config)
91
+
92
+ async def run() -> None:
93
+ await cp.start()
94
+ status = cp.status()
95
+ print(f"\n Halyn v0.1.0 — listening on {config.host}:{config.port}")
96
+ print(f" {status['nodes']} nodes | {status['tools']} tools | MCP ready")
97
+ print(f" Audit: {status['audit_entries']} entries | Chain: {'valid' if status['audit_chain_valid'] else 'BROKEN'}")
98
+ print(f" Watchdog: {status['watchdog']['overall']}")
99
+ print()
100
+
101
+ try:
102
+ from .server import create_app
103
+ from aiohttp import web
104
+ app = create_app(cp, config.api_key)
105
+ from .mcp import mount_mcp
106
+ mount_mcp(app, cp)
107
+ runner = web.AppRunner(app)
108
+ await runner.setup()
109
+ site = web.TCPSite(runner, config.host, config.port)
110
+ await site.start()
111
+ print(f" HTTP: http://{config.host}:{config.port}")
112
+ print(f" MCP: http://{config.host}:{config.port}/mcp")
113
+ print(f" SSE: http://{config.host}:{config.port}/events")
114
+ print()
115
+ await asyncio.Event().wait() # Run forever
116
+ except ImportError:
117
+ print(" aiohttp not installed — running without HTTP server")
118
+ print(" Install: pip install aiohttp")
119
+ await asyncio.Event().wait()
120
+ except KeyboardInterrupt:
121
+ pass
122
+ finally:
123
+ await cp.stop()
124
+
125
+ asyncio.run(run())
126
+
127
+
128
+ def _cmd_scan(args: Any) -> None:
129
+ from .discovery import Scanner
130
+
131
+ logging.basicConfig(level=logging.WARNING)
132
+
133
+ scanner = Scanner(timeout=1.0)
134
+ config: dict[str, Any] = {}
135
+
136
+ if args.ssh:
137
+ config["ssh_hosts"] = args.ssh
138
+ if args.subnet:
139
+ config["subnets"] = [args.subnet]
140
+ if args.mqtt:
141
+ config["mqtt_brokers"] = args.mqtt
142
+ if args.http:
143
+ config["http_urls"] = args.http
144
+ if args.docker:
145
+ config["docker_hosts"] = args.docker
146
+
147
+ if not config:
148
+ # Default: scan localhost
149
+ config = {"docker_hosts": ["localhost"]}
150
+
151
+ print("\n Halyn — Scanning...\n")
152
+ t0 = time.time()
153
+
154
+ nodes = asyncio.run(scanner.scan_all(config))
155
+
156
+ elapsed = time.time() - t0
157
+
158
+ if getattr(args, 'json', False):
159
+ print(json.dumps([{
160
+ "address": n.address, "port": n.port,
161
+ "protocol": n.protocol, "name": n.name,
162
+ "nrp_id": n.suggested_nrp_id,
163
+ "metadata": n.metadata,
164
+ } for n in nodes], indent=2))
165
+ else:
166
+ print(scanner.format_results(nodes))
167
+ print(f"\n Scanned in {elapsed:.1f}s")
168
+ if nodes:
169
+ print(f"\n To connect: halyn serve --config halyn.yml")
170
+
171
+
172
+ def _cmd_status(args: Any) -> None:
173
+ """Query a running Halyn instance."""
174
+ import urllib.request
175
+ try:
176
+ url = "http://localhost:8935/health"
177
+ with urllib.request.urlopen(url, timeout=5) as resp:
178
+ data = json.loads(resp.read())
179
+ print(json.dumps(data, indent=2))
180
+ except Exception as e:
181
+ print(f"Cannot reach Halyn at localhost:8935: {e}")
182
+ print("Is Halyn running? Start with: halyn serve")
183
+
184
+
185
+ def _cmd_test() -> None:
186
+ """Run the test suite."""
187
+ import subprocess
188
+ test_path = __file__.replace("cli.py", "").replace("src/halyn/", "") + "tests/test_halyn.py"
189
+ subprocess.run([sys.executable, test_path])
190
+
191
+
192
+ def _cmd_emergency_stop(args: Any) -> None:
193
+ """Send emergency stop to running instance."""
194
+ import urllib.request
195
+ try:
196
+ req = urllib.request.Request(
197
+ "http://localhost:8935/emergency-stop",
198
+ method="POST",
199
+ )
200
+ with urllib.request.urlopen(req, timeout=5) as resp:
201
+ print("EMERGENCY STOP SENT")
202
+ print(resp.read().decode())
203
+ except Exception as e:
204
+ print(f"Cannot reach Halyn: {e}")
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
halyn/config.py ADDED
@@ -0,0 +1,135 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Configuration — YAML-based Halyn setup.
5
+
6
+ YAML/env-based configuration loader.
7
+ Merges file config, environment variables, and defaults.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ log = logging.getLogger("halyn.config")
19
+
20
+ _DEFAULT_CONFIG = {
21
+ "version": "1",
22
+ "server": {
23
+ "host": "0.0.0.0",
24
+ "port": 8935,
25
+ "api_key": "",
26
+ },
27
+ "llm": {
28
+ "provider": "ollama",
29
+ "model": "",
30
+ "api_key": "",
31
+ },
32
+ "domains": {
33
+ "infrastructure": {
34
+ "level": 2,
35
+ "nodes": ["server/*", "cloud/*", "docker/*"],
36
+ "confirm": ["restart", "deploy", "delete"],
37
+ },
38
+ "monitoring": {
39
+ "level": 4,
40
+ "nodes": ["sensor/*", "monitor/*"],
41
+ },
42
+ },
43
+ "nodes": [],
44
+ "logging": {
45
+ "level": "INFO",
46
+ "file": "",
47
+ },
48
+ }
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class HalynConfig:
53
+ """Parsed configuration."""
54
+ host: str = "0.0.0.0"
55
+ port: int = 8935
56
+ api_key: str = ""
57
+ llm_provider: str = "ollama"
58
+ llm_model: str = ""
59
+ llm_api_key: str = ""
60
+ domains: dict[str, dict[str, Any]] = field(default_factory=dict)
61
+ nodes: list[dict[str, Any]] = field(default_factory=list)
62
+ log_level: str = "INFO"
63
+ log_file: str = ""
64
+ data_dir: str = ""
65
+
66
+ @classmethod
67
+ def load(cls, path: str = "") -> HalynConfig:
68
+ """Load config from YAML file, env vars, or defaults."""
69
+ raw = dict(_DEFAULT_CONFIG)
70
+
71
+ # Try loading YAML
72
+ config_path = path or os.environ.get("HALYN_CONFIG", "")
73
+ if not config_path:
74
+ for candidate in ["halyn.yml", "halyn.yaml", ".halyn.yml",
75
+ str(Path.home() / ".halyn" / "config.yml")]:
76
+ if Path(candidate).is_file():
77
+ config_path = candidate
78
+ break
79
+
80
+ if config_path and Path(config_path).is_file():
81
+ try:
82
+ import yaml
83
+ with open(config_path) as f:
84
+ user_config = yaml.safe_load(f) or {}
85
+ _deep_merge(raw, user_config)
86
+ log.info("config.loaded path=%s", config_path)
87
+ except ImportError:
88
+ # Fallback: try JSON
89
+ import json as json_mod
90
+ if config_path.endswith(".json"):
91
+ with open(config_path) as f:
92
+ user_config = json_mod.load(f)
93
+ _deep_merge(raw, user_config)
94
+ except Exception as exc:
95
+ log.warning("config.load_error path=%s error=%s", config_path, exc)
96
+
97
+ # Environment variable overrides
98
+ server = raw.get("server", {})
99
+ llm = raw.get("llm", {})
100
+
101
+ cfg = cls(
102
+ host=os.environ.get("HALYN_HOST", str(server.get("host", "0.0.0.0"))),
103
+ port=int(os.environ.get("HALYN_PORT", server.get("port", 8935))),
104
+ api_key=os.environ.get("HALYN_API_KEY", server.get("api_key", "")),
105
+ llm_provider=os.environ.get("HALYN_LLM_PROVIDER", llm.get("provider", "ollama")),
106
+ llm_model=os.environ.get("HALYN_LLM_MODEL", llm.get("model", "")),
107
+ llm_api_key=os.environ.get("ANTHROPIC_API_KEY",
108
+ os.environ.get("OPENAI_API_KEY", llm.get("api_key", ""))),
109
+ domains=raw.get("domains", {}),
110
+ nodes=raw.get("nodes", []),
111
+ log_level=os.environ.get("HALYN_LOG_LEVEL",
112
+ raw.get("logging", {}).get("level", "INFO")),
113
+ log_file=raw.get("logging", {}).get("file", ""),
114
+ data_dir=os.environ.get("HALYN_DATA_DIR",
115
+ str(Path.home() / ".halyn")),
116
+ )
117
+ return cfg
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ return {
121
+ "server": {"host": self.host, "port": self.port},
122
+ "llm": {"provider": self.llm_provider, "model": self.llm_model},
123
+ "domains": self.domains,
124
+ "nodes": self.nodes,
125
+ "logging": {"level": self.log_level},
126
+ }
127
+
128
+
129
+ def _deep_merge(base: dict, override: dict) -> None:
130
+ for key, val in override.items():
131
+ if key in base and isinstance(base[key], dict) and isinstance(val, dict):
132
+ _deep_merge(base[key], val)
133
+ else:
134
+ base[key] = val
135
+
halyn/consent.py ADDED
@@ -0,0 +1,243 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Consent — The human decides what connects.
5
+
6
+ Every new node requires explicit operator approval before
7
+ it can observe or act through the control plane.
8
+
9
+ The consent flow:
10
+ 1. Discovery finds a node
11
+ 2. Halyn presents it to the human: "New device found: Unitree G1 at 10.0.1.50"
12
+ 3. Human chooses: ALLOW (full) | READ_ONLY | DENY | TEMPORARY (24h)
13
+ 4. Decision is recorded in the consent store (persistent)
14
+ 5. On reconnection, the stored consent is reused (no repeated prompts)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ log = logging.getLogger("halyn.consent")
30
+
31
+
32
+ class ConsentLevel(str, Enum):
33
+ """What the human allows for a node."""
34
+ FULL = "full" # observe + act (within shield limits)
35
+ READ_ONLY = "read_only" # observe only, no act
36
+ DENY = "deny" # blocked entirely
37
+ TEMPORARY = "temporary" # full access for limited time
38
+ PENDING = "pending" # waiting for human decision
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class ConsentRecord:
43
+ """Persistent record of a consent decision."""
44
+ nrp_id: str
45
+ level: ConsentLevel
46
+ granted_at: float = 0.0
47
+ expires_at: float = 0.0 # 0 = never expires
48
+ granted_by: str = "" # user ID
49
+ device_info: str = "" # manifest summary at time of consent
50
+ reason: str = "" # why this level was chosen
51
+
52
+ @property
53
+ def expired(self) -> bool:
54
+ if self.expires_at == 0.0:
55
+ return False
56
+ return time.time() > self.expires_at
57
+
58
+ @property
59
+ def active(self) -> bool:
60
+ return self.level not in (ConsentLevel.DENY, ConsentLevel.PENDING) and not self.expired
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "nrp_id": self.nrp_id,
65
+ "level": self.level.value,
66
+ "granted_at": self.granted_at,
67
+ "expires_at": self.expires_at,
68
+ "granted_by": self.granted_by,
69
+ "device_info": self.device_info,
70
+ "reason": self.reason,
71
+ "expired": self.expired,
72
+ "active": self.active,
73
+ }
74
+
75
+
76
+ _SCHEMA = """
77
+ CREATE TABLE IF NOT EXISTS consent (
78
+ nrp_id TEXT PRIMARY KEY,
79
+ level TEXT NOT NULL DEFAULT 'pending',
80
+ granted_at REAL NOT NULL DEFAULT 0,
81
+ expires_at REAL NOT NULL DEFAULT 0,
82
+ granted_by TEXT NOT NULL DEFAULT '',
83
+ device_info TEXT NOT NULL DEFAULT '',
84
+ reason TEXT NOT NULL DEFAULT ''
85
+ );
86
+ CREATE INDEX IF NOT EXISTS idx_consent_level ON consent(level);
87
+ """
88
+
89
+
90
+ class ConsentStore:
91
+ """
92
+ Persistent consent decisions.
93
+
94
+ Stored in SQLite. Survives restarts.
95
+ Persisted across restarts.
96
+ """
97
+
98
+ def __init__(self, db_path: str = "") -> None:
99
+ if not db_path:
100
+ data_dir = Path.home() / ".halyn"
101
+ data_dir.mkdir(parents=True, exist_ok=True)
102
+ db_path = str(data_dir / "consent.db")
103
+ self._db_path = db_path
104
+ self._lock = threading.Lock()
105
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
106
+ self._conn.execute("PRAGMA journal_mode=WAL")
107
+ self._conn.executescript(_SCHEMA)
108
+ self._conn.commit()
109
+ log.info("consent.init db=%s", db_path)
110
+
111
+ def check(self, nrp_id: str) -> ConsentRecord | None:
112
+ """Check if consent exists for a node. Returns None if no record."""
113
+ with self._lock:
114
+ row = self._conn.execute(
115
+ "SELECT nrp_id, level, granted_at, expires_at, granted_by, device_info, reason "
116
+ "FROM consent WHERE nrp_id = ?", (nrp_id,)
117
+ ).fetchone()
118
+ if not row:
119
+ return None
120
+ record = ConsentRecord(
121
+ nrp_id=row[0], level=ConsentLevel(row[1]),
122
+ granted_at=row[2], expires_at=row[3],
123
+ granted_by=row[4], device_info=row[5], reason=row[6],
124
+ )
125
+ # Auto-expire temporary consents
126
+ if record.expired and record.level == ConsentLevel.TEMPORARY:
127
+ self.revoke(nrp_id, reason="auto-expired")
128
+ return None
129
+ return record
130
+
131
+ def grant(
132
+ self,
133
+ nrp_id: str,
134
+ level: ConsentLevel,
135
+ granted_by: str = "",
136
+ device_info: str = "",
137
+ reason: str = "",
138
+ duration_hours: float = 0,
139
+ ) -> ConsentRecord:
140
+ """Grant consent for a node."""
141
+ now = time.time()
142
+ expires = now + (duration_hours * 3600) if duration_hours > 0 else 0.0
143
+
144
+ record = ConsentRecord(
145
+ nrp_id=nrp_id, level=level,
146
+ granted_at=now, expires_at=expires,
147
+ granted_by=granted_by, device_info=device_info,
148
+ reason=reason,
149
+ )
150
+
151
+ with self._lock:
152
+ self._conn.execute(
153
+ "INSERT OR REPLACE INTO consent "
154
+ "(nrp_id, level, granted_at, expires_at, granted_by, device_info, reason) "
155
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
156
+ (nrp_id, level.value, now, expires, granted_by, device_info, reason),
157
+ )
158
+ self._conn.commit()
159
+
160
+ log.info("consent.granted nrp_id=%s level=%s by=%s expires=%s",
161
+ nrp_id, level.value, granted_by,
162
+ f"{duration_hours}h" if duration_hours else "never")
163
+ return record
164
+
165
+ def revoke(self, nrp_id: str, reason: str = "") -> bool:
166
+ """Revoke consent for a node."""
167
+ with self._lock:
168
+ cursor = self._conn.execute(
169
+ "UPDATE consent SET level = ?, reason = ? WHERE nrp_id = ?",
170
+ (ConsentLevel.DENY.value, reason or "revoked", nrp_id),
171
+ )
172
+ self._conn.commit()
173
+ revoked = cursor.rowcount > 0
174
+
175
+ if revoked:
176
+ log.info("consent.revoked nrp_id=%s reason=%s", nrp_id, reason)
177
+ return revoked
178
+
179
+ def list_all(self, level: ConsentLevel | None = None) -> list[ConsentRecord]:
180
+ """List all consent records, optionally filtered by level."""
181
+ with self._lock:
182
+ if level:
183
+ rows = self._conn.execute(
184
+ "SELECT nrp_id, level, granted_at, expires_at, granted_by, device_info, reason "
185
+ "FROM consent WHERE level = ? ORDER BY granted_at DESC",
186
+ (level.value,)
187
+ ).fetchall()
188
+ else:
189
+ rows = self._conn.execute(
190
+ "SELECT nrp_id, level, granted_at, expires_at, granted_by, device_info, reason "
191
+ "FROM consent ORDER BY granted_at DESC"
192
+ ).fetchall()
193
+
194
+ return [
195
+ ConsentRecord(
196
+ nrp_id=r[0], level=ConsentLevel(r[1]),
197
+ granted_at=r[2], expires_at=r[3],
198
+ granted_by=r[4], device_info=r[5], reason=r[6],
199
+ )
200
+ for r in rows
201
+ ]
202
+
203
+ def pending_count(self) -> int:
204
+ """How many nodes are waiting for consent."""
205
+ with self._lock:
206
+ r = self._conn.execute(
207
+ "SELECT COUNT(*) FROM consent WHERE level = ?",
208
+ (ConsentLevel.PENDING.value,)
209
+ ).fetchone()
210
+ return r[0] if r else 0
211
+
212
+ def request_consent(self, nrp_id: str, device_info: str = "") -> ConsentRecord:
213
+ """
214
+ Request consent for a new node.
215
+ Creates a PENDING record. The human must approve or deny.
216
+ """
217
+ existing = self.check(nrp_id)
218
+ if existing and existing.active:
219
+ return existing # Already consented
220
+
221
+ return self.grant(
222
+ nrp_id=nrp_id,
223
+ level=ConsentLevel.PENDING,
224
+ device_info=device_info,
225
+ reason="awaiting human approval",
226
+ )
227
+
228
+ def format_request(self, nrp_id: str, device_info: str = "") -> str:
229
+ """Format a consent request for display to the human."""
230
+ return (
231
+ f"New device detected:\n"
232
+ f" Node: {nrp_id}\n"
233
+ f" {device_info}\n"
234
+ f"\n"
235
+ f" [ALLOW] Full access (observe + act within shield limits)\n"
236
+ f" [READ_ONLY] Observe only (no actions)\n"
237
+ f" [TEMPORARY] Full access for 24 hours\n"
238
+ f" [DENY] Block this device\n"
239
+ )
240
+
241
+ def close(self) -> None:
242
+ with self._lock:
243
+ self._conn.close()