halyn 2.2.1__tar.gz → 2.2.3__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.
- {halyn-2.2.1/src/halyn.egg-info → halyn-2.2.3}/PKG-INFO +1 -1
- {halyn-2.2.1 → halyn-2.2.3}/pyproject.toml +1 -1
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/__init__.py +1 -1
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/cli.py +15 -0
- halyn-2.2.3/src/halyn/dashboard.py +561 -0
- halyn-2.2.3/src/halyn/redteam.py +153 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/server.py +9 -0
- {halyn-2.2.1 → halyn-2.2.3/src/halyn.egg-info}/PKG-INFO +1 -1
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/SOURCES.txt +1 -0
- halyn-2.2.1/src/halyn/dashboard.py +0 -209
- {halyn-2.2.1 → halyn-2.2.3}/LICENSE +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/README.md +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/setup.cfg +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/__main__.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/__init__.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/driver.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/events.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/identity.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/manifest.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/audit.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/auth.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/autonomy.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/config.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/consent.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/control_plane.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/discovery.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/__init__.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/browser.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/dds.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/docker.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/http_auto.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/mqtt.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/opcua.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/ros2.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/serial.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/socket_raw.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/ssh.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/unitree.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/websocket.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/engine.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/integrations/__init__.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/intent.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/llm.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/mcp.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/mcp_serve.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/memory/__init__.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/memory/store.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/nrp_bridge.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/py.typed +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/sanitizer.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/__init__.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/audit_guard.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/ebpf_monitor.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/fs_watch.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/process_guard.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/proxy.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/shield.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/types.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn/watchdog.py +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/dependency_links.txt +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/entry_points.txt +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/requires.txt +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/top_level.txt +0 -0
- {halyn-2.2.1 → halyn-2.2.3}/tests/test_halyn.py +0 -0
|
@@ -51,6 +51,13 @@ def main() -> None:
|
|
|
51
51
|
# emergency-stop
|
|
52
52
|
sub.add_parser("emergency-stop", help="STOP ALL NODES IMMEDIATELY")
|
|
53
53
|
|
|
54
|
+
# redteam
|
|
55
|
+
p_red = sub.add_parser("redteam", help="Run 24/7 red team audit loop")
|
|
56
|
+
p_red.add_argument("--url", default="http://localhost:7420")
|
|
57
|
+
p_red.add_argument("--interval", type=float, default=30.0)
|
|
58
|
+
p_red.add_argument("--webhook", default="")
|
|
59
|
+
p_red.add_argument("--verbose", action="store_true")
|
|
60
|
+
|
|
54
61
|
# version
|
|
55
62
|
sub.add_parser("version", help="Show version")
|
|
56
63
|
|
|
@@ -66,6 +73,14 @@ def main() -> None:
|
|
|
66
73
|
_cmd_test()
|
|
67
74
|
elif args.command == "emergency-stop":
|
|
68
75
|
_cmd_emergency_stop(args)
|
|
76
|
+
elif args.command == "redteam":
|
|
77
|
+
from .redteam import run as redteam_run
|
|
78
|
+
redteam_run(
|
|
79
|
+
url=args.url,
|
|
80
|
+
interval=args.interval,
|
|
81
|
+
webhook=args.webhook or None,
|
|
82
|
+
verbose=args.verbose,
|
|
83
|
+
)
|
|
69
84
|
elif args.command == "version":
|
|
70
85
|
from . import __version__
|
|
71
86
|
print(f"Halyn v{__version__}")
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA
|
|
2
|
+
# Licensed under BUSL-1.1. See LICENSE file.
|
|
3
|
+
# Commercial use requires a license — contact@halyn.dev
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Halyn Dashboard — Built-in web UI served on GET /
|
|
7
|
+
|
|
8
|
+
Uses the real REST API endpoints directly:
|
|
9
|
+
/health /nodes /audit /events/query
|
|
10
|
+
/consent/pending /confirm/pending /intents /execute
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="UTF-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
18
|
+
<title>Halyn — AI Governance</title>
|
|
19
|
+
<style>
|
|
20
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
21
|
+
:root{
|
|
22
|
+
--bg:#0b0d10;--bg2:#13161b;--bg3:#1a1e27;
|
|
23
|
+
--fg:#e1e4ea;--fg2:#8b919e;--fg3:#5c6370;
|
|
24
|
+
--green:#3ddc84;--green-bg:#0f2318;--green-border:#1a3a28;
|
|
25
|
+
--red:#ef4444;--red-bg:#2a1215;--red-border:#4a1c20;
|
|
26
|
+
--yellow:#f59e0b;--yellow-bg:#1f1a0f;
|
|
27
|
+
--border:#1e2330;
|
|
28
|
+
--font:'Inter',system-ui,sans-serif;
|
|
29
|
+
--mono:'IBM Plex Mono','Courier New',monospace;
|
|
30
|
+
}
|
|
31
|
+
body{background:var(--bg);color:var(--fg);font-family:var(--font);height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
|
32
|
+
|
|
33
|
+
/* TOP BAR */
|
|
34
|
+
.topbar{
|
|
35
|
+
background:var(--bg2);border-bottom:1px solid var(--border);
|
|
36
|
+
padding:.65rem 1.25rem;display:flex;justify-content:space-between;align-items:center;
|
|
37
|
+
flex-shrink:0;
|
|
38
|
+
}
|
|
39
|
+
.topbar-left{display:flex;align-items:center;gap:.75rem}
|
|
40
|
+
.logo{font-size:.95rem;font-weight:700;letter-spacing:-.5px}
|
|
41
|
+
.logo span{color:var(--green)}
|
|
42
|
+
.version{font-size:.7rem;color:var(--fg3);font-family:var(--mono)}
|
|
43
|
+
.topbar-right{display:flex;align-items:center;gap:1rem}
|
|
44
|
+
.pill{
|
|
45
|
+
display:flex;align-items:center;gap:.35rem;
|
|
46
|
+
font-size:.72rem;font-family:var(--mono);
|
|
47
|
+
background:var(--bg3);border:1px solid var(--border);
|
|
48
|
+
padding:.25rem .6rem;border-radius:20px;
|
|
49
|
+
}
|
|
50
|
+
.pill .dot{width:7px;height:7px;border-radius:50%;background:var(--fg3)}
|
|
51
|
+
.pill.online .dot{background:var(--green);box-shadow:0 0 6px var(--green)}
|
|
52
|
+
.pill.stopped .dot{background:var(--red);box-shadow:0 0 6px var(--red)}
|
|
53
|
+
#estop-btn{
|
|
54
|
+
background:var(--red-bg);border:1px solid var(--red-border);
|
|
55
|
+
color:var(--red);font-size:.72rem;font-family:var(--mono);font-weight:600;
|
|
56
|
+
padding:.25rem .75rem;border-radius:4px;cursor:pointer;
|
|
57
|
+
}
|
|
58
|
+
#estop-btn:hover{background:var(--red);color:#fff}
|
|
59
|
+
|
|
60
|
+
/* STAT STRIP */
|
|
61
|
+
.statstrip{
|
|
62
|
+
display:flex;border-bottom:1px solid var(--border);flex-shrink:0;
|
|
63
|
+
background:var(--bg2);
|
|
64
|
+
}
|
|
65
|
+
.stat{
|
|
66
|
+
flex:1;padding:.6rem 1rem;border-right:1px solid var(--border);
|
|
67
|
+
display:flex;flex-direction:column;gap:.15rem;
|
|
68
|
+
}
|
|
69
|
+
.stat:last-child{border-right:none}
|
|
70
|
+
.stat .v{font-family:var(--mono);font-size:1.3rem;font-weight:700;color:var(--green);line-height:1}
|
|
71
|
+
.stat .l{font-size:.65rem;color:var(--fg3);text-transform:uppercase;letter-spacing:.8px}
|
|
72
|
+
.stat.warn .v{color:var(--yellow)}
|
|
73
|
+
.stat.danger .v{color:var(--red)}
|
|
74
|
+
|
|
75
|
+
/* MAIN GRID */
|
|
76
|
+
.main{display:grid;grid-template-columns:1fr 1fr;flex:1;overflow:hidden}
|
|
77
|
+
.col{display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
|
|
78
|
+
.col:last-child{border-right:none}
|
|
79
|
+
|
|
80
|
+
/* PANEL */
|
|
81
|
+
.panel{flex:1;display:flex;flex-direction:column;overflow:hidden;border-bottom:1px solid var(--border)}
|
|
82
|
+
.panel:last-child{border-bottom:none}
|
|
83
|
+
.panel-head{
|
|
84
|
+
padding:.5rem .9rem;border-bottom:1px solid var(--border);flex-shrink:0;
|
|
85
|
+
display:flex;justify-content:space-between;align-items:center;
|
|
86
|
+
background:var(--bg2);
|
|
87
|
+
}
|
|
88
|
+
.panel-head h2{font-size:.72rem;font-weight:600;color:var(--fg2);text-transform:uppercase;letter-spacing:1px}
|
|
89
|
+
.panel-head .badge{
|
|
90
|
+
font-size:.62rem;font-family:var(--mono);
|
|
91
|
+
background:var(--bg3);color:var(--fg3);
|
|
92
|
+
padding:2px 7px;border-radius:10px;border:1px solid var(--border);
|
|
93
|
+
}
|
|
94
|
+
.panel-body{flex:1;overflow-y:auto;padding:.6rem .9rem}
|
|
95
|
+
|
|
96
|
+
/* AUDIT TABLE */
|
|
97
|
+
.audit-row{
|
|
98
|
+
display:grid;grid-template-columns:55px 1fr 55px 90px;
|
|
99
|
+
gap:.5rem;padding:.3rem 0;border-bottom:1px solid var(--border);
|
|
100
|
+
font-size:.7rem;font-family:var(--mono);align-items:center;
|
|
101
|
+
}
|
|
102
|
+
.audit-row:last-child{border-bottom:none}
|
|
103
|
+
.audit-row .t{color:var(--fg3)}
|
|
104
|
+
.audit-row .tool{color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
105
|
+
.audit-row .hash{color:var(--fg3);font-size:.62rem;text-align:right;overflow:hidden;text-overflow:ellipsis}
|
|
106
|
+
.ok-badge{background:var(--green-bg);color:var(--green);border:1px solid var(--green-border);padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700}
|
|
107
|
+
.fail-badge{background:var(--red-bg);color:var(--red);border:1px solid var(--red-border);padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700}
|
|
108
|
+
|
|
109
|
+
/* EVENTS */
|
|
110
|
+
.event-row{
|
|
111
|
+
padding:.3rem 0;border-bottom:1px solid var(--border);
|
|
112
|
+
font-size:.7rem;font-family:var(--mono);display:flex;gap:.6rem;align-items:baseline;
|
|
113
|
+
}
|
|
114
|
+
.event-row:last-child{border-bottom:none}
|
|
115
|
+
.event-row .sev{font-size:.6rem;font-weight:700;padding:1px 5px;border-radius:3px;flex-shrink:0}
|
|
116
|
+
.sev.info{background:#0f1f2e;color:#60a5fa;border:1px solid #1a3a5c}
|
|
117
|
+
.sev.warning{background:var(--yellow-bg);color:var(--yellow);border:1px solid #3a2a0f}
|
|
118
|
+
.sev.critical,.sev.emergency{background:var(--red-bg);color:var(--red);border:1px solid var(--red-border)}
|
|
119
|
+
.event-row .name{color:var(--fg);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
120
|
+
.event-row .src{color:var(--fg3);font-size:.62rem}
|
|
121
|
+
|
|
122
|
+
/* NODES */
|
|
123
|
+
.node-card{
|
|
124
|
+
background:var(--bg2);border:1px solid var(--border);border-radius:6px;
|
|
125
|
+
padding:.6rem .8rem;margin-bottom:.4rem;font-size:.73rem;
|
|
126
|
+
}
|
|
127
|
+
.node-card .nid{font-family:var(--mono);color:var(--green);font-size:.7rem}
|
|
128
|
+
.node-card .nmeta{color:var(--fg3);margin-top:.2rem;font-size:.65rem}
|
|
129
|
+
.empty{color:var(--fg3);font-size:.78rem;font-style:italic;padding:.5rem 0}
|
|
130
|
+
|
|
131
|
+
/* CONSENT */
|
|
132
|
+
.consent-card{
|
|
133
|
+
background:var(--bg2);border:1px solid var(--yellow-bg);border-radius:6px;
|
|
134
|
+
padding:.6rem .8rem;margin-bottom:.4rem;font-size:.73rem;
|
|
135
|
+
}
|
|
136
|
+
.consent-card .nid{font-family:var(--mono);color:var(--yellow)}
|
|
137
|
+
.consent-actions{display:flex;gap:.4rem;margin-top:.5rem}
|
|
138
|
+
.btn-approve{background:var(--green-bg);border:1px solid var(--green-border);color:var(--green);font-size:.68rem;padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--mono)}
|
|
139
|
+
.btn-deny{background:var(--red-bg);border:1px solid var(--red-border);color:var(--red);font-size:.68rem;padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--mono)}
|
|
140
|
+
.btn-approve:hover{background:var(--green);color:#000}
|
|
141
|
+
.btn-deny:hover{background:var(--red);color:#fff}
|
|
142
|
+
|
|
143
|
+
/* COMMAND */
|
|
144
|
+
.cmd-panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
145
|
+
.cmd-history{flex:1;overflow-y:auto;padding:.6rem .9rem;font-family:var(--mono);font-size:.72rem}
|
|
146
|
+
.cmd-line{padding:.25rem 0;border-bottom:1px solid var(--border);display:flex;gap:.6rem;line-height:1.5}
|
|
147
|
+
.cmd-line:last-child{border-bottom:none}
|
|
148
|
+
.cmd-line .who{color:var(--fg3);flex-shrink:0}
|
|
149
|
+
.cmd-line .txt{flex:1;word-break:break-all}
|
|
150
|
+
.cmd-line.user .who{color:var(--green)}
|
|
151
|
+
.cmd-line.user .txt{color:var(--fg)}
|
|
152
|
+
.cmd-line.res.ok .txt{color:var(--green)}
|
|
153
|
+
.cmd-line.res.err .txt{color:var(--red)}
|
|
154
|
+
.cmd-line.res.info .txt{color:var(--fg2)}
|
|
155
|
+
.cmd-input-row{
|
|
156
|
+
display:flex;gap:.5rem;padding:.6rem .9rem;
|
|
157
|
+
border-top:1px solid var(--border);flex-shrink:0;background:var(--bg2);
|
|
158
|
+
}
|
|
159
|
+
.cmd-input{
|
|
160
|
+
flex:1;background:var(--bg3);border:1px solid var(--border);
|
|
161
|
+
color:var(--fg);padding:.45rem .7rem;border-radius:5px;
|
|
162
|
+
font-size:.78rem;font-family:var(--mono);outline:none;
|
|
163
|
+
}
|
|
164
|
+
.cmd-input:focus{border-color:var(--green)}
|
|
165
|
+
.cmd-send{
|
|
166
|
+
background:var(--green-bg);border:1px solid var(--green-border);
|
|
167
|
+
color:var(--green);font-size:.75rem;font-family:var(--mono);font-weight:600;
|
|
168
|
+
padding:.45rem .9rem;border-radius:5px;cursor:pointer;
|
|
169
|
+
}
|
|
170
|
+
.cmd-send:hover{background:var(--green);color:#000}
|
|
171
|
+
|
|
172
|
+
/* AUDIT VERIFY INDICATOR */
|
|
173
|
+
.chain-status{
|
|
174
|
+
font-size:.65rem;font-family:var(--mono);
|
|
175
|
+
padding:2px 7px;border-radius:3px;
|
|
176
|
+
}
|
|
177
|
+
.chain-status.valid{background:var(--green-bg);color:var(--green);border:1px solid var(--green-border)}
|
|
178
|
+
.chain-status.broken{background:var(--red-bg);color:var(--red);border:1px solid var(--red-border)}
|
|
179
|
+
|
|
180
|
+
/* SCROLLBAR */
|
|
181
|
+
::-webkit-scrollbar{width:4px}
|
|
182
|
+
::-webkit-scrollbar-track{background:transparent}
|
|
183
|
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
|
184
|
+
</style>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
|
|
188
|
+
<div class="topbar">
|
|
189
|
+
<div class="topbar-left">
|
|
190
|
+
<div class="logo">⬡ <span>Halyn</span></div>
|
|
191
|
+
<div class="version" id="version">v—</div>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="topbar-right">
|
|
194
|
+
<div class="pill" id="status-pill">
|
|
195
|
+
<div class="dot"></div>
|
|
196
|
+
<span id="status-txt">connecting</span>
|
|
197
|
+
</div>
|
|
198
|
+
<button id="estop-btn" onclick="emergencyStop()">⚠ STOP</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div class="statstrip">
|
|
203
|
+
<div class="stat" id="st-nodes"><div class="v" id="sv-nodes">—</div><div class="l">Nodes</div></div>
|
|
204
|
+
<div class="stat" id="st-audit"><div class="v" id="sv-audit">—</div><div class="l">Audit</div></div>
|
|
205
|
+
<div class="stat" id="st-events"><div class="v" id="sv-events">—</div><div class="l">Events</div></div>
|
|
206
|
+
<div class="stat" id="st-consent"><div class="v" id="sv-consent">—</div><div class="l">Pending</div></div>
|
|
207
|
+
<div class="stat" id="st-watchdog"><div class="v" id="sv-watchdog">—</div><div class="l">Watchdog</div></div>
|
|
208
|
+
<div class="stat" id="st-chain"><div class="v" id="sv-chain">—</div><div class="l">Chain</div></div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div class="main">
|
|
212
|
+
<!-- LEFT COL -->
|
|
213
|
+
<div class="col">
|
|
214
|
+
|
|
215
|
+
<!-- NODES (top-left) -->
|
|
216
|
+
<div class="panel" style="max-height:35%">
|
|
217
|
+
<div class="panel-head">
|
|
218
|
+
<h2>Nodes</h2>
|
|
219
|
+
<span class="badge" id="badge-nodes">0</span>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="panel-body" id="panel-nodes">
|
|
222
|
+
<div class="empty">No nodes connected. Run <code>halyn scan</code> to discover.</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- CONSENT (bottom-left) -->
|
|
227
|
+
<div class="panel" style="max-height:30%">
|
|
228
|
+
<div class="panel-head">
|
|
229
|
+
<h2>Consent Pending</h2>
|
|
230
|
+
<span class="badge" id="badge-consent">0</span>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="panel-body" id="panel-consent">
|
|
233
|
+
<div class="empty">No pending consent requests.</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<!-- COMMAND (bottom-left) -->
|
|
238
|
+
<div class="panel cmd-panel">
|
|
239
|
+
<div class="panel-head">
|
|
240
|
+
<h2>Execute</h2>
|
|
241
|
+
<span class="badge">tool · node · args</span>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="cmd-history" id="cmd-history">
|
|
244
|
+
<div class="cmd-line res info"><span class="who">sys</span><span class="txt">Halyn console — type help for commands.</span></div>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="cmd-input-row">
|
|
247
|
+
<input class="cmd-input" id="cmd-input" placeholder='{"tool":"my_tool","node":"nrp://scope/kind/name","args":{}}' />
|
|
248
|
+
<button class="cmd-send" onclick="cmdSend()">RUN</button>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<!-- RIGHT COL -->
|
|
255
|
+
<div class="col">
|
|
256
|
+
|
|
257
|
+
<!-- AUDIT (top-right) -->
|
|
258
|
+
<div class="panel" style="flex:1.2">
|
|
259
|
+
<div class="panel-head">
|
|
260
|
+
<h2>Audit Chain</h2>
|
|
261
|
+
<div style="display:flex;gap:.5rem;align-items:center">
|
|
262
|
+
<span class="chain-status valid" id="chain-badge">VALID</span>
|
|
263
|
+
<span class="badge" id="badge-audit">0</span>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="panel-body" id="panel-audit">
|
|
267
|
+
<div class="empty">No audit entries yet.</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<!-- EVENTS (bottom-right) -->
|
|
272
|
+
<div class="panel" style="flex:1">
|
|
273
|
+
<div class="panel-head">
|
|
274
|
+
<h2>Event Stream</h2>
|
|
275
|
+
<span class="badge" id="badge-events">0</span>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="panel-body" id="panel-events">
|
|
278
|
+
<div class="empty">Listening for events via SSE...</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<script>
|
|
286
|
+
const $ = id => document.getElementById(id);
|
|
287
|
+
|
|
288
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
289
|
+
function fmtTime(ts) {
|
|
290
|
+
if (!ts) return '—';
|
|
291
|
+
const d = new Date(typeof ts === 'number' ? ts * 1000 : ts);
|
|
292
|
+
return d.toTimeString().slice(0, 8);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function addCmdLine(cls, who, txt) {
|
|
296
|
+
const h = $('cmd-history');
|
|
297
|
+
const d = document.createElement('div');
|
|
298
|
+
d.className = 'cmd-line ' + cls;
|
|
299
|
+
d.innerHTML = '<span class="who">' + who + '</span><span class="txt">' + txt + '</span>';
|
|
300
|
+
h.appendChild(d);
|
|
301
|
+
h.scrollTop = h.scrollHeight;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Health / Refresh ──────────────────────────────────────
|
|
305
|
+
async function refresh() {
|
|
306
|
+
try {
|
|
307
|
+
const h = await fetch('/health').then(r => r.json());
|
|
308
|
+
|
|
309
|
+
// Status pill
|
|
310
|
+
const pill = $('status-pill');
|
|
311
|
+
if (h.emergency_stop) {
|
|
312
|
+
pill.className = 'pill stopped';
|
|
313
|
+
$('status-txt').textContent = 'EMERGENCY STOP';
|
|
314
|
+
} else {
|
|
315
|
+
pill.className = 'pill online';
|
|
316
|
+
$('status-txt').textContent = 'running';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Version (from cli — not in health, use static)
|
|
320
|
+
$('version').textContent = 'v2.2.2';
|
|
321
|
+
|
|
322
|
+
// Stats strip
|
|
323
|
+
$('sv-nodes').textContent = h.nodes;
|
|
324
|
+
$('sv-audit').textContent = h.audit_entries;
|
|
325
|
+
$('sv-events').textContent = h.event_bus.total;
|
|
326
|
+
$('sv-consent').textContent = h.pending_consents + h.pending_confirmations;
|
|
327
|
+
$('sv-watchdog').textContent = h.watchdog.overall;
|
|
328
|
+
$('sv-chain').textContent = h.audit_chain_valid ? 'OK' : 'BROKEN';
|
|
329
|
+
|
|
330
|
+
const stChain = $('st-chain');
|
|
331
|
+
stChain.className = 'stat ' + (h.audit_chain_valid ? '' : 'danger');
|
|
332
|
+
const stConsent = $('st-consent');
|
|
333
|
+
stConsent.className = 'stat ' + (h.pending_consents + h.pending_confirmations > 0 ? 'warn' : '');
|
|
334
|
+
const stWatch = $('st-watchdog');
|
|
335
|
+
stWatch.className = 'stat ' + (h.watchdog.overall === 'green' ? '' : h.watchdog.overall === 'yellow' ? 'warn' : 'danger');
|
|
336
|
+
|
|
337
|
+
// Chain badge
|
|
338
|
+
const cb = $('chain-badge');
|
|
339
|
+
if (h.audit_chain_valid) { cb.className = 'chain-status valid'; cb.textContent = 'VALID'; }
|
|
340
|
+
else { cb.className = 'chain-status broken'; cb.textContent = 'BROKEN'; }
|
|
341
|
+
|
|
342
|
+
} catch(e) {
|
|
343
|
+
$('status-pill').className = 'pill';
|
|
344
|
+
$('status-txt').textContent = 'offline';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
refreshNodes();
|
|
348
|
+
refreshAudit();
|
|
349
|
+
refreshConsent();
|
|
350
|
+
refreshEvents();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Nodes ─────────────────────────────────────────────────
|
|
354
|
+
async function refreshNodes() {
|
|
355
|
+
try {
|
|
356
|
+
const d = await fetch('/nodes').then(r => r.json());
|
|
357
|
+
$('badge-nodes').textContent = d.count;
|
|
358
|
+
$('sv-nodes').textContent = d.count;
|
|
359
|
+
const panel = $('panel-nodes');
|
|
360
|
+
if (d.count === 0) {
|
|
361
|
+
panel.innerHTML = '<div class="empty">No nodes connected.</div>';
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
panel.innerHTML = Object.entries(d.nodes).map(([id, m]) => {
|
|
365
|
+
const obs = (m.observe || []).map(c => c.name).join(', ') || '—';
|
|
366
|
+
const acts = (m.act || []).map(a => a.name).join(', ') || '—';
|
|
367
|
+
return '<div class="node-card">' +
|
|
368
|
+
'<div class="nid">' + (m.nrp_id || id) + '</div>' +
|
|
369
|
+
'<div class="nmeta">obs: ' + obs + '</div>' +
|
|
370
|
+
'<div class="nmeta">act: ' + acts + '</div>' +
|
|
371
|
+
'</div>';
|
|
372
|
+
}).join('');
|
|
373
|
+
} catch(e) {}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Audit ─────────────────────────────────────────────────
|
|
377
|
+
async function refreshAudit() {
|
|
378
|
+
try {
|
|
379
|
+
const d = await fetch('/audit?limit=30').then(r => r.json());
|
|
380
|
+
$('badge-audit').textContent = d.count;
|
|
381
|
+
$('sv-audit').textContent = d.count;
|
|
382
|
+
const panel = $('panel-audit');
|
|
383
|
+
if (!d.entries || d.entries.length === 0) {
|
|
384
|
+
panel.innerHTML = '<div class="empty">No audit entries yet.</div>';
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
panel.innerHTML = [...d.entries].reverse().map(e => {
|
|
388
|
+
const badge = e.result_ok
|
|
389
|
+
? '<span class="ok-badge">OK</span>'
|
|
390
|
+
: '<span class="fail-badge">FAIL</span>';
|
|
391
|
+
const hash = e.hash ? e.hash.slice(0, 10) + '…' : '—';
|
|
392
|
+
return '<div class="audit-row">' +
|
|
393
|
+
'<span class="t">' + fmtTime(e.timestamp) + '</span>' +
|
|
394
|
+
'<span class="tool">' + (e.tool || '—') + '</span>' +
|
|
395
|
+
badge +
|
|
396
|
+
'<span class="hash">' + hash + '</span>' +
|
|
397
|
+
'</div>';
|
|
398
|
+
}).join('');
|
|
399
|
+
} catch(e) {}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Events ────────────────────────────────────────────────
|
|
403
|
+
async function refreshEvents() {
|
|
404
|
+
try {
|
|
405
|
+
const d = await fetch('/events/query?n=20').then(r => r.json());
|
|
406
|
+
$('badge-events').textContent = d.total;
|
|
407
|
+
$('sv-events').textContent = d.total;
|
|
408
|
+
const panel = $('panel-events');
|
|
409
|
+
if (!d.events || d.events.length === 0) {
|
|
410
|
+
panel.innerHTML = '<div class="empty">No events yet.</div>';
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
panel.innerHTML = [...d.events].reverse().map(e => {
|
|
414
|
+
const sev = e.severity || 'info';
|
|
415
|
+
return '<div class="event-row">' +
|
|
416
|
+
'<span class="sev ' + sev + '">' + sev.toUpperCase() + '</span>' +
|
|
417
|
+
'<span class="name">' + (e.name || '—') + '</span>' +
|
|
418
|
+
'<span class="src">' + (e.source || '') + '</span>' +
|
|
419
|
+
'</div>';
|
|
420
|
+
}).join('');
|
|
421
|
+
} catch(e) {}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Consent ───────────────────────────────────────────────
|
|
425
|
+
async function refreshConsent() {
|
|
426
|
+
try {
|
|
427
|
+
const d = await fetch('/consent/pending').then(r => r.json());
|
|
428
|
+
const count = d.count || 0;
|
|
429
|
+
$('badge-consent').textContent = count;
|
|
430
|
+
$('sv-consent').textContent = count;
|
|
431
|
+
const panel = $('panel-consent');
|
|
432
|
+
if (count === 0) {
|
|
433
|
+
panel.innerHTML = '<div class="empty">No pending consent requests.</div>';
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
panel.innerHTML = d.pending.map(r => {
|
|
437
|
+
const id = r.nrp_id || r.id || '?';
|
|
438
|
+
return '<div class="consent-card">' +
|
|
439
|
+
'<div class="nid">' + id + '</div>' +
|
|
440
|
+
'<div class="consent-actions">' +
|
|
441
|
+
'<button class="btn-approve" onclick="consentApprove(' + JSON.stringify(id) + ')">APPROVE</button>' +
|
|
442
|
+
'<button class="btn-deny" onclick="consentDeny(' + JSON.stringify(id) + ')">DENY</button>' +
|
|
443
|
+
'</div></div>';
|
|
444
|
+
}).join('');
|
|
445
|
+
} catch(e) {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function consentApprove(nrp_id) {
|
|
449
|
+
await fetch('/consent/approve', {
|
|
450
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
451
|
+
body: JSON.stringify({nrp_id, level:'full', user_id:'dashboard'})
|
|
452
|
+
});
|
|
453
|
+
refreshConsent();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function consentDeny(nrp_id) {
|
|
457
|
+
await fetch('/consent/deny', {
|
|
458
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
459
|
+
body: JSON.stringify({nrp_id, reason:'denied via dashboard'})
|
|
460
|
+
});
|
|
461
|
+
refreshConsent();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Execute (command) ──────────────────────────────────────
|
|
465
|
+
async function cmdSend() {
|
|
466
|
+
const inp = $('cmd-input');
|
|
467
|
+
const raw = inp.value.trim();
|
|
468
|
+
if (!raw) return;
|
|
469
|
+
inp.value = '';
|
|
470
|
+
|
|
471
|
+
if (raw === 'help') {
|
|
472
|
+
addCmdLine('res info', 'sys',
|
|
473
|
+
'Examples:<br>' +
|
|
474
|
+
'{"tool":"ping","node":"nrp://local/server/main","args":{}}<br>' +
|
|
475
|
+
'{"tool":"restart","node":"nrp://prod/service/nginx","args":{}}'
|
|
476
|
+
);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
addCmdLine('user', 'you', raw);
|
|
481
|
+
|
|
482
|
+
let body;
|
|
483
|
+
try { body = JSON.parse(raw); }
|
|
484
|
+
catch(e) {
|
|
485
|
+
addCmdLine('res err', 'err', 'Invalid JSON — ' + e.message);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const r = await fetch('/execute', {
|
|
491
|
+
method: 'POST',
|
|
492
|
+
headers: {'Content-Type': 'application/json'},
|
|
493
|
+
body: JSON.stringify({
|
|
494
|
+
tool: body.tool || '',
|
|
495
|
+
args: body.args || {},
|
|
496
|
+
user_id: 'dashboard',
|
|
497
|
+
intent: body.intent || '',
|
|
498
|
+
})
|
|
499
|
+
});
|
|
500
|
+
const d = await r.json();
|
|
501
|
+
if (d.ok) {
|
|
502
|
+
addCmdLine('res ok', 'ok', JSON.stringify(d.data));
|
|
503
|
+
} else {
|
|
504
|
+
addCmdLine('res err', 'err', d.error || d.status || 'failed');
|
|
505
|
+
}
|
|
506
|
+
} catch(e) {
|
|
507
|
+
addCmdLine('res err', 'err', e.message);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
refresh();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Emergency Stop ─────────────────────────────────────────
|
|
514
|
+
async function emergencyStop() {
|
|
515
|
+
if (!confirm('Send EMERGENCY STOP to all nodes?')) return;
|
|
516
|
+
try {
|
|
517
|
+
await fetch('/emergency-stop', {method:'POST'});
|
|
518
|
+
addCmdLine('res err', 'sys', '⚠ EMERGENCY STOP SENT');
|
|
519
|
+
refresh();
|
|
520
|
+
} catch(e) {}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── SSE live events ────────────────────────────────────────
|
|
524
|
+
function connectSSE() {
|
|
525
|
+
const es = new EventSource('/events');
|
|
526
|
+
es.onmessage = function(e) {
|
|
527
|
+
try {
|
|
528
|
+
const ev = JSON.parse(e.data);
|
|
529
|
+
const panel = $('panel-events');
|
|
530
|
+
// Remove empty placeholder
|
|
531
|
+
const empty = panel.querySelector('.empty');
|
|
532
|
+
if (empty) empty.remove();
|
|
533
|
+
const row = document.createElement('div');
|
|
534
|
+
row.className = 'event-row';
|
|
535
|
+
const sev = ev.severity || 'info';
|
|
536
|
+
row.innerHTML =
|
|
537
|
+
'<span class="sev ' + sev + '">' + sev.toUpperCase() + '</span>' +
|
|
538
|
+
'<span class="name">' + (ev.name || '—') + '</span>' +
|
|
539
|
+
'<span class="src">' + (ev.source || '') + '</span>';
|
|
540
|
+
panel.insertBefore(row, panel.firstChild);
|
|
541
|
+
// Keep last 30
|
|
542
|
+
while (panel.children.length > 30) panel.removeChild(panel.lastChild);
|
|
543
|
+
// Update badge
|
|
544
|
+
const cur = parseInt($('badge-events').textContent) || 0;
|
|
545
|
+
$('badge-events').textContent = cur + 1;
|
|
546
|
+
$('sv-events').textContent = cur + 1;
|
|
547
|
+
} catch(e) {}
|
|
548
|
+
};
|
|
549
|
+
es.onerror = function() {
|
|
550
|
+
setTimeout(connectSSE, 3000);
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── Init ───────────────────────────────────────────────────
|
|
555
|
+
$('cmd-input').addEventListener('keydown', e => { if (e.key === 'Enter') cmdSend(); });
|
|
556
|
+
refresh();
|
|
557
|
+
setInterval(refresh, 5000);
|
|
558
|
+
connectSSE();
|
|
559
|
+
</script>
|
|
560
|
+
</body>
|
|
561
|
+
</html>"""
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA
|
|
2
|
+
# Licensed under BUSL-1.1. See LICENSE file.
|
|
3
|
+
# Commercial use requires a license - contact@halyn.dev
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import asyncio, logging, signal, sys, time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("halyn.redteam")
|
|
11
|
+
|
|
12
|
+
PROBES = [
|
|
13
|
+
{"name": "probe.noop", "tool": "halyn.probe.noop",
|
|
14
|
+
"args": {"echo": "ping"}, "expect": "any", "intent": "redteam.noop"},
|
|
15
|
+
{"name": "probe.delete", "tool": "halyn.probe.delete_all",
|
|
16
|
+
"args": {"target": "*"}, "expect": "blocked", "intent": "redteam.dangerous"},
|
|
17
|
+
{"name": "probe.exfil", "tool": "halyn.probe.exfil",
|
|
18
|
+
"args": {"destination": "http://evil.example.com"}, "expect": "blocked", "intent": "redteam.exfil"},
|
|
19
|
+
{"name": "probe.fs_write", "tool": "halyn.probe.write_file",
|
|
20
|
+
"args": {"path": "/etc/passwd", "content": "pwned"}, "expect": "blocked", "intent": "redteam.fs"},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ProbeResult:
|
|
25
|
+
name: str; ok: bool; expected: bool; status: str; duration_ms: float; error: str = ""
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LoopStats:
|
|
29
|
+
cycles: int = 0; probes_sent: int = 0; probes_expected: int = 0
|
|
30
|
+
chain_checks: int = 0; chain_breaks: int = 0; alerts_sent: int = 0
|
|
31
|
+
started_at: float = field(default_factory=time.time)
|
|
32
|
+
def uptime_seconds(self): return time.time() - self.started_at
|
|
33
|
+
|
|
34
|
+
async def _get(session, url):
|
|
35
|
+
import aiohttp
|
|
36
|
+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as r:
|
|
37
|
+
return await r.json()
|
|
38
|
+
|
|
39
|
+
async def _post(session, url, body):
|
|
40
|
+
import aiohttp
|
|
41
|
+
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as r:
|
|
42
|
+
return await r.json()
|
|
43
|
+
|
|
44
|
+
async def run_probe(session, base_url, probe):
|
|
45
|
+
t0 = time.perf_counter()
|
|
46
|
+
try:
|
|
47
|
+
resp = await _post(session, f"{base_url}/execute", {
|
|
48
|
+
"tool": probe["tool"], "args": probe["args"],
|
|
49
|
+
"user_id": "halyn.redteam", "intent": probe["intent"],
|
|
50
|
+
})
|
|
51
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
52
|
+
result_ok = resp.get("ok", False)
|
|
53
|
+
status = resp.get("status", "unknown")
|
|
54
|
+
expected = (not result_ok) if probe["expect"] == "blocked" else True
|
|
55
|
+
return ProbeResult(probe["name"], True, expected, status, ms)
|
|
56
|
+
except asyncio.TimeoutError:
|
|
57
|
+
return ProbeResult(probe["name"], False, False, "timeout", (time.perf_counter()-t0)*1000, "timeout")
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return ProbeResult(probe["name"], False, False, "error", (time.perf_counter()-t0)*1000, str(e)[:200])
|
|
60
|
+
|
|
61
|
+
async def check_chain(session, base_url):
|
|
62
|
+
try:
|
|
63
|
+
r = await _get(session, f"{base_url}/audit/verify")
|
|
64
|
+
return r.get("valid", False), r.get("entries_checked", 0), r.get("message", "")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return False, 0, f"unreachable: {e}"
|
|
67
|
+
|
|
68
|
+
async def send_alert(session, webhook, msg, stats):
|
|
69
|
+
log.critical("ALERT: %s", msg)
|
|
70
|
+
stats.alerts_sent += 1
|
|
71
|
+
if not webhook:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
import aiohttp
|
|
75
|
+
payload = {"text": f":rotating_light: *Halyn Alert*
|
|
76
|
+
{msg}
|
|
77
|
+
Cycle {stats.cycles} | Uptime {stats.uptime_seconds():.0f}s | Chain breaks: {stats.chain_breaks}"}
|
|
78
|
+
async with session.post(webhook, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as r:
|
|
79
|
+
log.info("alert.webhook status=%d", r.status)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
log.error("alert.webhook failed: %s", e)
|
|
82
|
+
|
|
83
|
+
async def redteam_loop(base_url, interval, webhook, verbose):
|
|
84
|
+
import aiohttp
|
|
85
|
+
stats = LoopStats()
|
|
86
|
+
prev_tip = None
|
|
87
|
+
print(f"
|
|
88
|
+
Halyn Red Team")
|
|
89
|
+
print(f" Target: {base_url}")
|
|
90
|
+
print(f" Interval: {interval}s | Probes: {len(PROBES)} | Webhook: {"yes" if webhook else "no"}")
|
|
91
|
+
print(f" Ctrl+C to stop
|
|
92
|
+
")
|
|
93
|
+
print(f" CYC TIME PROBES CHAIN MS")
|
|
94
|
+
print(f" " + "-"*60)
|
|
95
|
+
async with aiohttp.ClientSession() as session:
|
|
96
|
+
while True:
|
|
97
|
+
t0 = time.time()
|
|
98
|
+
stats.cycles += 1
|
|
99
|
+
try:
|
|
100
|
+
h = await _get(session, f"{base_url}/health")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
await send_alert(session, webhook, f"Halyn unreachable: {e}", stats)
|
|
103
|
+
await asyncio.sleep(interval)
|
|
104
|
+
continue
|
|
105
|
+
if not h.get("running", False):
|
|
106
|
+
await send_alert(session, webhook, "running=false", stats)
|
|
107
|
+
results = []
|
|
108
|
+
for probe in PROBES:
|
|
109
|
+
r = await run_probe(session, base_url, probe)
|
|
110
|
+
results.append(r)
|
|
111
|
+
stats.probes_sent += 1
|
|
112
|
+
if r.expected:
|
|
113
|
+
stats.probes_expected += 1
|
|
114
|
+
else:
|
|
115
|
+
await send_alert(session, webhook,
|
|
116
|
+
f"Probe {r.name!r} unexpected: status={r.status} expected={probe["expect"]} error={r.error}", stats)
|
|
117
|
+
if verbose:
|
|
118
|
+
log.info(" %s %s -> %s (%.0fms)", "OK" if r.expected else "FAIL", r.name, r.status, r.duration_ms)
|
|
119
|
+
valid, count, msg = await check_chain(session, base_url)
|
|
120
|
+
stats.chain_checks += 1
|
|
121
|
+
if not valid:
|
|
122
|
+
stats.chain_breaks += 1
|
|
123
|
+
await send_alert(session, webhook, f"CHAIN BROKEN cycle={stats.cycles}: {msg} entries={count}", stats)
|
|
124
|
+
try:
|
|
125
|
+
ar = await _get(session, f"{base_url}/audit?limit=1")
|
|
126
|
+
tip = ar.get("chain_tip", "")
|
|
127
|
+
if prev_tip and tip == "GENESIS" and stats.cycles > 1:
|
|
128
|
+
await send_alert(session, webhook, f"Chain tip reset to GENESIS at cycle {stats.cycles} - log may have been wiped", stats)
|
|
129
|
+
prev_tip = tip
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
n_ok = sum(1 for r in results if r.expected)
|
|
133
|
+
chain_str = f"OK ({count} entries)" if valid else f"BROKEN ({count} entries)"
|
|
134
|
+
ms = (time.time() - t0) * 1000
|
|
135
|
+
print(f" {stats.cycles:>4} {time.strftime("%H:%M:%S")} {n_ok}/{len(results)} probes {chain_str:<26} {ms:>5.0f}ms")
|
|
136
|
+
await asyncio.sleep(max(0.0, interval - (time.time() - t0)))
|
|
137
|
+
|
|
138
|
+
def run(url="http://localhost:7420", interval=30.0, webhook=None, verbose=False):
|
|
139
|
+
try:
|
|
140
|
+
import aiohttp
|
|
141
|
+
except ImportError:
|
|
142
|
+
print("Error: aiohttp required -- pip install halyn"); sys.exit(1)
|
|
143
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
|
|
144
|
+
loop = asyncio.new_event_loop()
|
|
145
|
+
def _stop(sig, frame):
|
|
146
|
+
print("
|
|
147
|
+
Stopping..."); loop.stop()
|
|
148
|
+
signal.signal(signal.SIGINT, _stop)
|
|
149
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
150
|
+
try:
|
|
151
|
+
loop.run_until_complete(redteam_loop(url, interval, webhook, verbose))
|
|
152
|
+
finally:
|
|
153
|
+
loop.close()
|
|
@@ -54,6 +54,7 @@ def create_app(control_plane: Any, api_key: str = "") -> "web.Application":
|
|
|
54
54
|
if not HAS_AIOHTTP:
|
|
55
55
|
raise ImportError("aiohttp required: pip install aiohttp")
|
|
56
56
|
|
|
57
|
+
from .dashboard import DASHBOARD_HTML
|
|
57
58
|
app = web.Application()
|
|
58
59
|
cp = control_plane
|
|
59
60
|
|
|
@@ -72,6 +73,13 @@ def create_app(control_plane: Any, api_key: str = "") -> "web.Application":
|
|
|
72
73
|
|
|
73
74
|
# ─── Health ─────────────────────────────────
|
|
74
75
|
|
|
76
|
+
# --- Dashboard ---
|
|
77
|
+
|
|
78
|
+
async def handle_dashboard(req: web.Request) -> web.Response:
|
|
79
|
+
from . import __version__
|
|
80
|
+
html = DASHBOARD_HTML.replace("v2.2.2", f"v{__version__}")
|
|
81
|
+
return web.Response(text=html, content_type="text/html")
|
|
82
|
+
|
|
75
83
|
async def handle_health(req: web.Request) -> web.Response:
|
|
76
84
|
return _json(cp.status())
|
|
77
85
|
|
|
@@ -272,6 +280,7 @@ def create_app(control_plane: Any, api_key: str = "") -> "web.Application":
|
|
|
272
280
|
|
|
273
281
|
# ─── Register routes ────────────────────────
|
|
274
282
|
|
|
283
|
+
app.router.add_get("/", handle_dashboard)
|
|
275
284
|
app.router.add_get("/health", handle_health)
|
|
276
285
|
app.router.add_get("/nodes", handle_nodes)
|
|
277
286
|
app.router.add_post("/execute", handle_execute)
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2026 Elmadani SALKA
|
|
2
|
-
# Licensed under BUSL-1.1. See LICENSE file.
|
|
3
|
-
# Commercial use requires a license — contact@halyn.dev
|
|
4
|
-
|
|
5
|
-
"""
|
|
6
|
-
Halyn Dashboard — Built-in web UI for the MCP Server.
|
|
7
|
-
|
|
8
|
-
When running halyn-mcp, opening http://localhost:7420 shows this dashboard.
|
|
9
|
-
Real-time device status, shield rules, audit chain — all visual.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
DASHBOARD_HTML = '''<!DOCTYPE html>
|
|
13
|
-
<html lang="en">
|
|
14
|
-
<head>
|
|
15
|
-
<meta charset="UTF-8">
|
|
16
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
17
|
-
<title>Halyn Dashboard</title>
|
|
18
|
-
<style>
|
|
19
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
20
|
-
:root{--bg:#0b0d10;--bg2:#13161b;--fg:#e1e4ea;--fg2:#8b919e;--fg3:#5c6370;--accent:#0a6e3f;--accent2:#3ddc84;--red:#ef4444;--border:#1e2330;--font:system-ui,sans-serif;--mono:'Courier New',monospace}
|
|
21
|
-
body{background:var(--bg);color:var(--fg);font-family:var(--font);min-height:100vh}
|
|
22
|
-
.top{background:var(--bg2);border-bottom:1px solid var(--border);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center}
|
|
23
|
-
.top h1{font-size:1rem;font-weight:600;display:flex;align-items:center;gap:.5rem}
|
|
24
|
-
.top h1 span{color:var(--accent2)}
|
|
25
|
-
.top .status{font-size:.75rem;color:var(--accent2);font-family:var(--mono);display:flex;align-items:center;gap:.4rem}
|
|
26
|
-
.top .dot{width:8px;height:8px;border-radius:50%;background:var(--accent2);animation:pulse 2s infinite}
|
|
27
|
-
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
28
|
-
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);margin:0;height:calc(100vh - 49px)}
|
|
29
|
-
.panel{background:var(--bg);padding:1rem;overflow-y:auto}
|
|
30
|
-
.panel h2{font-size:.8rem;font-weight:600;color:var(--fg2);text-transform:uppercase;letter-spacing:1px;margin-bottom:.75rem;display:flex;align-items:center;gap:.5rem}
|
|
31
|
-
.panel h2 .count{background:var(--bg2);color:var(--fg3);font-size:.65rem;padding:2px 6px;border-radius:8px}
|
|
32
|
-
|
|
33
|
-
/* Chat / Command */
|
|
34
|
-
.chat{display:flex;flex-direction:column;height:100%}
|
|
35
|
-
.messages{flex:1;overflow-y:auto;padding-bottom:.5rem}
|
|
36
|
-
.msg{margin-bottom:.5rem;padding:.5rem .75rem;border-radius:8px;font-size:.82rem;line-height:1.5;max-width:90%}
|
|
37
|
-
.msg.user{background:var(--accent);color:#fff;margin-left:auto;border-bottom-right-radius:2px}
|
|
38
|
-
.msg.sys{background:var(--bg2);color:var(--fg2);border-bottom-left-radius:2px}
|
|
39
|
-
.msg.blocked{background:#2a1215;color:var(--red);border:1px solid #4a1c20}
|
|
40
|
-
.msg.ok{background:#0f2318;color:var(--accent2);border:1px solid #1a3a28}
|
|
41
|
-
.input-row{display:flex;gap:.5rem;padding-top:.5rem;border-top:1px solid var(--border)}
|
|
42
|
-
.input-row input{flex:1;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:.6rem .75rem;border-radius:6px;font-size:.82rem;font-family:var(--font);outline:none}
|
|
43
|
-
.input-row input:focus{border-color:var(--accent)}
|
|
44
|
-
.input-row button{background:var(--accent);color:#fff;border:none;padding:.6rem 1rem;border-radius:6px;font-size:.82rem;cursor:pointer;font-weight:500}
|
|
45
|
-
.input-row button:hover{background:var(--accent2);color:#000}
|
|
46
|
-
|
|
47
|
-
/* Shields */
|
|
48
|
-
.shield{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:.5rem .75rem;margin-bottom:.4rem;font-family:var(--mono);font-size:.75rem;color:var(--fg2);display:flex;align-items:center;gap:.5rem}
|
|
49
|
-
.shield::before{content:"🛡️";font-size:.9rem}
|
|
50
|
-
|
|
51
|
-
/* Audit */
|
|
52
|
-
.audit-entry{display:grid;grid-template-columns:auto 1fr auto auto;gap:.5rem;padding:.4rem 0;border-bottom:1px solid var(--border);font-size:.72rem;font-family:var(--mono);align-items:center}
|
|
53
|
-
.audit-entry .time{color:var(--fg3)}
|
|
54
|
-
.audit-entry .tool{color:var(--fg)}
|
|
55
|
-
.audit-entry .hash{color:var(--fg3);font-size:.65rem}
|
|
56
|
-
.audit-entry .badge{padding:1px 6px;border-radius:4px;font-size:.6rem;font-weight:600}
|
|
57
|
-
.badge.ok{background:#0f2318;color:var(--accent2)}
|
|
58
|
-
.badge.blocked{background:#2a1215;color:var(--red)}
|
|
59
|
-
|
|
60
|
-
/* Stats */
|
|
61
|
-
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-bottom:1rem}
|
|
62
|
-
.stat{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:.75rem;text-align:center}
|
|
63
|
-
.stat .v{font-family:var(--mono);font-size:1.5rem;font-weight:700;color:var(--accent2)}
|
|
64
|
-
.stat .l{font-size:.65rem;color:var(--fg3);margin-top:.2rem}
|
|
65
|
-
|
|
66
|
-
@media(max-width:768px){.grid{grid-template-columns:1fr;height:auto}.panel{min-height:50vh}.stats{grid-template-columns:repeat(2,1fr)}}
|
|
67
|
-
</style>
|
|
68
|
-
</head>
|
|
69
|
-
<body>
|
|
70
|
-
<div class="top">
|
|
71
|
-
<h1>⬡ <span>Halyn</span> Dashboard</h1>
|
|
72
|
-
<div class="status"><div class="dot"></div> <span id="uptime">connecting...</span></div>
|
|
73
|
-
</div>
|
|
74
|
-
<div class="grid">
|
|
75
|
-
<!-- LEFT: Chat + Command -->
|
|
76
|
-
<div class="panel">
|
|
77
|
-
<div class="chat">
|
|
78
|
-
<div class="messages" id="msgs">
|
|
79
|
-
<div class="msg sys">Welcome to Halyn. Type a command or talk naturally.<br><br>Try: <code>observe all</code> · <code>shield deny * delete *</code> · <code>status</code> · <code>audit</code></div>
|
|
80
|
-
</div>
|
|
81
|
-
<div class="input-row">
|
|
82
|
-
<input type="text" id="cmd" placeholder="Type a command... (e.g. 'restart nginx on server-01')" autofocus>
|
|
83
|
-
<button onclick="send()">Send</button>
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
<!-- RIGHT: Status + Shields + Audit -->
|
|
88
|
-
<div class="panel">
|
|
89
|
-
<div class="stats" id="stats">
|
|
90
|
-
<div class="stat"><div class="v" id="s-nodes">0</div><div class="l">Nodes</div></div>
|
|
91
|
-
<div class="stat"><div class="v" id="s-shields">0</div><div class="l">Shields</div></div>
|
|
92
|
-
<div class="stat"><div class="v" id="s-audit">0</div><div class="l">Audit</div></div>
|
|
93
|
-
<div class="stat"><div class="v" id="s-uptime">0s</div><div class="l">Uptime</div></div>
|
|
94
|
-
</div>
|
|
95
|
-
<h2>🛡️ Shield Rules <span class="count" id="shield-count">0</span></h2>
|
|
96
|
-
<div id="shields"></div>
|
|
97
|
-
<h2 style="margin-top:1rem">📜 Audit Chain <span class="count" id="audit-count">0</span></h2>
|
|
98
|
-
<div id="audit"></div>
|
|
99
|
-
</div>
|
|
100
|
-
</div>
|
|
101
|
-
<script>
|
|
102
|
-
const $ = s => document.getElementById(s);
|
|
103
|
-
|
|
104
|
-
async function mcp(name, args={}) {
|
|
105
|
-
const r = await fetch('/mcp', {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
headers: {'Content-Type': 'application/json'},
|
|
108
|
-
body: JSON.stringify({jsonrpc:'2.0',id:Date.now(),method:'tools/call',params:{name,arguments:args}})
|
|
109
|
-
});
|
|
110
|
-
const d = await r.json();
|
|
111
|
-
return JSON.parse(d.result.content[0].text);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function addMsg(text, cls='sys') {
|
|
115
|
-
const d = document.createElement('div');
|
|
116
|
-
d.className = 'msg ' + cls;
|
|
117
|
-
d.innerHTML = text;
|
|
118
|
-
$('msgs').appendChild(d);
|
|
119
|
-
$('msgs').scrollTop = $('msgs').scrollHeight;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function refresh() {
|
|
123
|
-
try {
|
|
124
|
-
const s = await mcp('halyn_status');
|
|
125
|
-
$('s-nodes').textContent = s.nodes;
|
|
126
|
-
$('s-shields').textContent = s.shields;
|
|
127
|
-
$('s-audit').textContent = s.audit_entries;
|
|
128
|
-
$('s-uptime').textContent = s.uptime_seconds + 's';
|
|
129
|
-
$('uptime').textContent = 'v' + s.version + ' · ' + s.uptime_seconds + 's uptime';
|
|
130
|
-
|
|
131
|
-
const sh = await mcp('halyn_shield_list');
|
|
132
|
-
$('shield-count').textContent = sh.count;
|
|
133
|
-
$('shields').innerHTML = sh.shields.map(r => '<div class="shield">' + r + '</div>').join('');
|
|
134
|
-
|
|
135
|
-
const au = await mcp('halyn_audit', {limit: 20});
|
|
136
|
-
$('audit-count').textContent = au.total;
|
|
137
|
-
$('audit').innerHTML = au.entries.reverse().map(e => {
|
|
138
|
-
const badge = e.result_ok ? '<span class="badge ok">OK</span>' : '<span class="badge blocked">BLOCKED</span>';
|
|
139
|
-
return '<div class="audit-entry"><span class="time">' + e.timestamp.split('T')[1].replace('Z','') + '</span><span class="tool">' + e.tool + '</span>' + badge + '<span class="hash">' + e.hash + '</span></div>';
|
|
140
|
-
}).join('');
|
|
141
|
-
} catch(e) {}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function send() {
|
|
145
|
-
const input = $('cmd');
|
|
146
|
-
const cmd = input.value.trim();
|
|
147
|
-
if (!cmd) return;
|
|
148
|
-
input.value = '';
|
|
149
|
-
|
|
150
|
-
addMsg(cmd, 'user');
|
|
151
|
-
|
|
152
|
-
const lower = cmd.toLowerCase();
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
if (lower === 'status' || lower === 'state') {
|
|
156
|
-
const s = await mcp('halyn_status');
|
|
157
|
-
addMsg('Nodes: ' + s.nodes + ' · Shields: ' + s.shields + ' · Audit: ' + s.audit_entries + ' · Uptime: ' + s.uptime_seconds + 's', 'ok');
|
|
158
|
-
} else if (lower.startsWith('shield ') || lower.startsWith('deny ') || lower.startsWith('limit ')) {
|
|
159
|
-
const rule = lower.startsWith('shield ') ? cmd.slice(7) : cmd;
|
|
160
|
-
const r = await mcp('halyn_shield_add', {rule});
|
|
161
|
-
if (r.error) { addMsg('Error: ' + r.error, 'blocked'); }
|
|
162
|
-
else { addMsg('Shield added: ' + r.added + ' (total: ' + r.total_shields + ')', 'ok'); }
|
|
163
|
-
} else if (lower === 'shields' || lower === 'rules') {
|
|
164
|
-
const r = await mcp('halyn_shield_list');
|
|
165
|
-
addMsg(r.shields.length ? r.shields.map(s => '🛡️ ' + s).join('<br>') : 'No shields active.', 'sys');
|
|
166
|
-
} else if (lower === 'audit' || lower === 'log' || lower === 'history') {
|
|
167
|
-
const r = await mcp('halyn_audit', {limit: 10});
|
|
168
|
-
const lines = r.entries.map(e => {
|
|
169
|
-
const s = e.result_ok ? '✓' : '✗';
|
|
170
|
-
return s + ' ' + e.timestamp.split('T')[1].replace('Z','') + ' ' + e.tool;
|
|
171
|
-
});
|
|
172
|
-
addMsg(lines.join('<br>') || 'No audit entries yet.', 'sys');
|
|
173
|
-
} else if (lower.startsWith('observe') || lower.startsWith('watch') || lower.startsWith('read')) {
|
|
174
|
-
const node = cmd.split(' ').slice(1).join(' ') || 'all';
|
|
175
|
-
const r = await mcp('halyn_observe', {node});
|
|
176
|
-
addMsg('<pre>' + JSON.stringify(r, null, 2) + '</pre>', 'sys');
|
|
177
|
-
} else if (lower === 'stop' || lower === 'emergency') {
|
|
178
|
-
const r = await mcp('halyn_emergency_stop');
|
|
179
|
-
addMsg('⚠️ ' + r.status, 'blocked');
|
|
180
|
-
} else if (lower === 'nodes' || lower === 'devices') {
|
|
181
|
-
const r = await mcp('halyn_nodes');
|
|
182
|
-
addMsg('<pre>' + JSON.stringify(r, null, 2) + '</pre>', 'sys');
|
|
183
|
-
} else if (lower === 'help') {
|
|
184
|
-
addMsg('Commands:<br>• <b>status</b> — system overview<br>• <b>observe [node]</b> — read device state<br>• <b>shield deny * delete *</b> — add safety rule<br>• <b>shields</b> — list active rules<br>• <b>audit</b> — view action log<br>• <b>nodes</b> — list devices<br>• <b>stop</b> — emergency stop<br>• Or type any action: <i>restart nginx on server-01</i>', 'sys');
|
|
185
|
-
} else {
|
|
186
|
-
// Treat as action on default node
|
|
187
|
-
const parts = cmd.match(/(.+?)\\s+on\\s+(.+)/i);
|
|
188
|
-
const node = parts ? parts[2] : 'default';
|
|
189
|
-
const command = parts ? parts[1] : cmd;
|
|
190
|
-
const r = await mcp('halyn_act', {node, command});
|
|
191
|
-
if (r.blocked) {
|
|
192
|
-
addMsg('🛡️ BLOCKED: ' + r.reason, 'blocked');
|
|
193
|
-
} else {
|
|
194
|
-
addMsg('✓ Executed: ' + command + (node !== 'default' ? ' on ' + node : ''), 'ok');
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} catch(e) {
|
|
198
|
-
addMsg('Error: ' + e.message, 'blocked');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
refresh();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
$('cmd').addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
|
205
|
-
refresh();
|
|
206
|
-
setInterval(refresh, 5000);
|
|
207
|
-
</script>
|
|
208
|
-
</body>
|
|
209
|
-
</html>'''
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|