cnsl 2.0.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.
cnsl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ CNSL — Correlated Network Security Layer
3
+ A self-hosted SIEM for Linux — correlation, ML, honeypot, and search.
4
+ """
5
+
6
+ __version__ = "2.0.0"
7
+ __author__ = "Rahad Bhuiya"
8
+ __license__ = "MIT"
cnsl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m cnsl"""
2
+ from .engine import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
cnsl/agent.py ADDED
@@ -0,0 +1,352 @@
1
+ """
2
+ cnsl/agent.py — CNSL Log Forwarding Agent.
3
+
4
+ A lightweight agent that runs on remote servers and forwards log events
5
+ to a central CNSL instance via WebSocket.
6
+
7
+ Features:
8
+ - Tails auth.log, nginx, apache, mysql, ufw, syslog (configurable)
9
+ - Forwards parsed events to CNSL server over WebSocket
10
+ - Automatic reconnection with exponential backoff
11
+ - JWT authentication (uses CNSL API token)
12
+ - Backpressure: drops oldest events if queue fills up
13
+ - TLS support (wss://)
14
+ - Agent hostname tagged on every event
15
+
16
+ Usage:
17
+ python -m cnsl.agent --server wss://cnsl.example.com/ws/agent \\
18
+ --token YOUR_JWT_TOKEN \\
19
+ --hostname web-01
20
+
21
+ Config file (~/.cnsl-agent.json or /etc/cnsl/agent.json):
22
+ {
23
+ "server": "wss://cnsl.example.com/ws/agent",
24
+ "token": "your-jwt-token",
25
+ "hostname": "web-01",
26
+ "sources": {
27
+ "auth": "/var/log/auth.log",
28
+ "nginx": "/var/log/nginx/access.log",
29
+ "apache": null,
30
+ "mysql": null,
31
+ "ufw": "/var/log/ufw.log",
32
+ "syslog": "/var/log/syslog"
33
+ },
34
+ "queue_size": 1000,
35
+ "batch_size": 50,
36
+ "flush_interval": 1.0
37
+ }
38
+
39
+ Running as a systemd service:
40
+ See docs/agent.md for a complete setup guide.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ import json
47
+ import os
48
+ import platform
49
+ import socket
50
+ import sys
51
+ import time
52
+ from pathlib import Path
53
+ from typing import Any, Dict, List, Optional
54
+
55
+ from .models import iso_time
56
+
57
+
58
+ # Agent config defaults
59
+
60
+ DEFAULT_AGENT_CONFIG: Dict[str, Any] = {
61
+ "server": "ws://localhost:8765/ws/agent",
62
+ "token": "",
63
+ "hostname": socket.gethostname(),
64
+ "sources": {
65
+ "auth": "/var/log/auth.log",
66
+ "nginx": None,
67
+ "apache": None,
68
+ "mysql": None,
69
+ "ufw": None,
70
+ "syslog": None,
71
+ },
72
+ "queue_size": 1000,
73
+ "batch_size": 50,
74
+ "flush_interval": 1.0,
75
+ "reconnect_min": 2.0,
76
+ "reconnect_max": 60.0,
77
+ }
78
+
79
+ _CONFIG_PATHS = [
80
+ Path.home() / ".cnsl-agent.json",
81
+ Path("/etc/cnsl/agent.json"),
82
+ Path("cnsl-agent.json"),
83
+ ]
84
+
85
+
86
+ def load_agent_config(path: Optional[str] = None) -> Dict[str, Any]:
87
+ """Load agent config from file, merging with defaults."""
88
+ cfg = dict(DEFAULT_AGENT_CONFIG)
89
+ cfg["sources"] = dict(DEFAULT_AGENT_CONFIG["sources"])
90
+
91
+ search = [Path(path)] if path else _CONFIG_PATHS
92
+ for p in search:
93
+ if p.exists():
94
+ try:
95
+ loaded = json.loads(p.read_text())
96
+ cfg.update({k: v for k, v in loaded.items() if k != "sources"})
97
+ if "sources" in loaded:
98
+ cfg["sources"].update(loaded["sources"])
99
+ except Exception:
100
+ pass
101
+ break
102
+
103
+ return cfg
104
+
105
+
106
+ # Event queue
107
+
108
+
109
+ class AgentQueue:
110
+ """Bounded queue with drop-oldest on overflow."""
111
+
112
+ def __init__(self, maxsize: int = 1000):
113
+ self._q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
114
+ self.dropped: int = 0
115
+
116
+ def put_nowait(self, item: Any) -> None:
117
+ try:
118
+ self._q.put_nowait(item)
119
+ except asyncio.QueueFull:
120
+ try:
121
+ self._q.get_nowait() # drop oldest
122
+ self.dropped += 1
123
+ except asyncio.QueueEmpty:
124
+ pass
125
+ self._q.put_nowait(item)
126
+
127
+ async def get_batch(self, max_items: int, timeout: float) -> List[Any]:
128
+ items = []
129
+ deadline = time.monotonic() + timeout
130
+ while len(items) < max_items:
131
+ remaining = deadline - time.monotonic()
132
+ if remaining <= 0:
133
+ break
134
+ try:
135
+ item = await asyncio.wait_for(self._q.get(), timeout=remaining)
136
+ items.append(item)
137
+ except asyncio.TimeoutError:
138
+ break
139
+ return items
140
+
141
+ @property
142
+ def qsize(self) -> int:
143
+ return self._q.qsize()
144
+
145
+
146
+ # Log tailer
147
+
148
+
149
+ async def tail_file(path: str, queue: AgentQueue, parser_fn, hostname: str) -> None:
150
+ """
151
+ Tail a log file and push parsed events to the queue.
152
+ Handles log rotation by re-opening when the file shrinks.
153
+ """
154
+ pos = 0
155
+ while True:
156
+ try:
157
+ if not os.path.exists(path):
158
+ await asyncio.sleep(5)
159
+ continue
160
+
161
+ stat = os.stat(path)
162
+
163
+ # Detect rotation (file shrank or was replaced)
164
+ if stat.st_size < pos:
165
+ pos = 0
166
+
167
+ if stat.st_size == pos:
168
+ await asyncio.sleep(0.5)
169
+ continue
170
+
171
+ with open(path, "r", errors="replace") as f:
172
+ f.seek(pos)
173
+ for line in f:
174
+ line = line.rstrip("\n")
175
+ if not line:
176
+ continue
177
+ try:
178
+ ev = parser_fn(line)
179
+ if ev:
180
+ payload = ev.to_dict() if hasattr(ev, "to_dict") else {"raw": line}
181
+ payload["_agent_host"] = hostname
182
+ payload["_source_file"] = path
183
+ queue.put_nowait(payload)
184
+ except Exception:
185
+ pass
186
+ pos = f.tell()
187
+
188
+ except Exception:
189
+ await asyncio.sleep(2)
190
+
191
+
192
+ # WebSocket sender
193
+
194
+
195
+ async def ws_sender(cfg: Dict[str, Any], queue: AgentQueue) -> None:
196
+ """
197
+ Main WebSocket sender loop.
198
+ Connects to the CNSL server, authenticates, and flushes event batches.
199
+ Reconnects with exponential backoff on failure.
200
+ """
201
+ try:
202
+ import aiohttp
203
+ except ImportError:
204
+ print("ERROR: aiohttp not installed. Run: pip install aiohttp", file=sys.stderr)
205
+ return
206
+
207
+ server = cfg["server"]
208
+ token = cfg["token"]
209
+ hostname = cfg["hostname"]
210
+ batch = int(cfg.get("batch_size", 50))
211
+ interval = float(cfg.get("flush_interval", 1.0))
212
+ backoff = float(cfg.get("reconnect_min", 2.0))
213
+ max_back = float(cfg.get("reconnect_max", 60.0))
214
+
215
+ while True:
216
+ try:
217
+ timeout = aiohttp.ClientTimeout(total=None, connect=10)
218
+ async with aiohttp.ClientSession(timeout=timeout) as session:
219
+ async with session.ws_connect(
220
+ server,
221
+ headers={"Authorization": f"Bearer {token}"},
222
+ heartbeat=30,
223
+ ) as ws:
224
+ # Send agent handshake
225
+ await ws.send_json({
226
+ "type": "agent_hello",
227
+ "hostname": hostname,
228
+ "version": "1.9.0",
229
+ "pid": os.getpid(),
230
+ "platform": platform.system(),
231
+ "time": iso_time(),
232
+ })
233
+
234
+ # Wait for server ack
235
+ msg = await asyncio.wait_for(ws.receive(), timeout=10)
236
+ if msg.type != aiohttp.WSMsgType.TEXT:
237
+ raise ConnectionError("Bad handshake response")
238
+ ack = json.loads(msg.data)
239
+ if not ack.get("ok"):
240
+ raise ConnectionError(f"Server rejected: {ack.get('error')}")
241
+
242
+ print(f"[agent] Connected to {server} as {hostname}", file=sys.stderr)
243
+ backoff = float(cfg.get("reconnect_min", 2.0)) # reset on success
244
+
245
+ # Flush loop
246
+ while True:
247
+ events = await queue.get_batch(batch, interval)
248
+ if events:
249
+ await ws.send_json({
250
+ "type": "agent_events",
251
+ "host": hostname,
252
+ "count": len(events),
253
+ "events": events,
254
+ })
255
+ else:
256
+ # Keepalive ping
257
+ await ws.ping()
258
+
259
+ except (ConnectionError, OSError, asyncio.TimeoutError) as exc:
260
+ print(f"[agent] Disconnected ({exc}). Retry in {backoff:.0f}s", file=sys.stderr)
261
+ except Exception as exc:
262
+ print(f"[agent] Error: {exc}. Retry in {backoff:.0f}s", file=sys.stderr)
263
+
264
+ await asyncio.sleep(backoff)
265
+ backoff = min(backoff * 1.5, max_back)
266
+
267
+
268
+ # Agent entrypoint
269
+
270
+
271
+ async def run_agent(cfg: Dict[str, Any]) -> None:
272
+ """Start all log tailers and the WebSocket sender."""
273
+ from .parsers import parse_auth_event
274
+ from .log_sources import parse_web_access, parse_mysql, parse_ufw, parse_syslog
275
+
276
+ hostname = cfg["hostname"]
277
+ queue = AgentQueue(maxsize=int(cfg.get("queue_size", 1000)))
278
+ sources = cfg.get("sources", {})
279
+
280
+ parsers = {
281
+ "auth": parse_auth_event,
282
+ "nginx": lambda l: parse_web_access(l, "nginx"),
283
+ "apache": lambda l: parse_web_access(l, "apache"),
284
+ "mysql": parse_mysql,
285
+ "ufw": parse_ufw,
286
+ "syslog": parse_syslog,
287
+ }
288
+
289
+ tasks = []
290
+ for name, path in sources.items():
291
+ if not path or not isinstance(path, str):
292
+ continue
293
+ parser = parsers.get(name)
294
+ if not parser:
295
+ continue
296
+ if not os.path.exists(path):
297
+ print(f"[agent] Warning: {path} not found — skipping", file=sys.stderr)
298
+ continue
299
+ tasks.append(asyncio.create_task(
300
+ tail_file(path, queue, parser, hostname),
301
+ name=f"tail_{name}",
302
+ ))
303
+ print(f"[agent] Tailing {path} ({name})", file=sys.stderr)
304
+
305
+ if not tasks:
306
+ print("[agent] Warning: no log sources found", file=sys.stderr)
307
+
308
+ tasks.append(asyncio.create_task(ws_sender(cfg, queue), name="ws_sender"))
309
+
310
+ # Status printer
311
+ async def _status() -> None:
312
+ while True:
313
+ await asyncio.sleep(60)
314
+ print(
315
+ f"[agent] queue={queue.qsize} dropped={queue.dropped}",
316
+ file=sys.stderr,
317
+ )
318
+
319
+ tasks.append(asyncio.create_task(_status(), name="status"))
320
+
321
+ await asyncio.gather(*tasks, return_exceptions=True)
322
+
323
+
324
+ def main() -> None:
325
+ """CLI entrypoint: python -m cnsl.agent"""
326
+ import argparse
327
+
328
+ ap = argparse.ArgumentParser(description="CNSL Log Forwarding Agent")
329
+ ap.add_argument("--config", metavar="FILE", help="Config file path")
330
+ ap.add_argument("--server", metavar="URL", help="CNSL WebSocket URL")
331
+ ap.add_argument("--token", metavar="TOKEN", help="JWT authentication token")
332
+ ap.add_argument("--hostname", metavar="NAME", help="Agent hostname label")
333
+ args = ap.parse_args()
334
+
335
+ cfg = load_agent_config(args.config)
336
+ if args.server:
337
+ cfg["server"] = args.server
338
+ if args.token:
339
+ cfg["token"] = args.token
340
+ if args.hostname:
341
+ cfg["hostname"] = args.hostname
342
+
343
+ if not cfg.get("token"):
344
+ print("ERROR: --token required", file=sys.stderr)
345
+ sys.exit(1)
346
+
347
+ print(f"[agent] Starting CNSL agent v1.9.0 on {cfg['hostname']}", file=sys.stderr)
348
+ asyncio.run(run_agent(cfg))
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
cnsl/api.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ cnsl/api.py — Lightweight REST API (optional, requires aiohttp).
3
+
4
+ Endpoints:
5
+ GET /health — liveness probe
6
+ GET /status — engine summary + tracked IPs
7
+ POST /block {"ip": ...} — manually block an IP
8
+ POST /unblock {"ip": ...} — manually remove a block
9
+
10
+ Enable with --api flag (or api.enabled=true in config).
11
+ Bind only to 127.0.0.1 by default — do NOT expose to the internet.
12
+ Add nginx/auth proxy in front if you need remote access.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from .detector import Detector
23
+ from .blocker import Blocker
24
+ from .logger import JsonLogger
25
+
26
+
27
+ async def start_api(
28
+ host: str,
29
+ port: int,
30
+ detector: "Detector",
31
+ blocker: "Blocker",
32
+ logger: "JsonLogger",
33
+ ) -> None:
34
+ try:
35
+ from aiohttp import web # type: ignore
36
+ except ImportError:
37
+ await logger.log("api_error", {"error": "aiohttp not installed. Run: pip install aiohttp"})
38
+ return
39
+
40
+ router = web.RouteTableDef()
41
+
42
+ @router.get("/health")
43
+ async def health(request: web.Request) -> web.Response:
44
+ return web.json_response({"status": "ok"})
45
+
46
+ @router.get("/status")
47
+ async def status(request: web.Request) -> web.Response:
48
+ return web.json_response({
49
+ "tracked_ips": detector.get_stats(),
50
+ "active_blocks": [
51
+ {"ip": ip, "unblock_at": blocker.active_blocks[ip]}
52
+ for ip in blocker.active_blocks
53
+ ],
54
+ })
55
+
56
+ @router.post("/block")
57
+ async def manual_block(request: web.Request) -> web.Response:
58
+ body = await request.json()
59
+ ip = body.get("ip", "").strip()
60
+ if not ip:
61
+ return web.json_response({"error": "ip required"}, status=400)
62
+ ok = await blocker.block_ip(ip, reason="manual")
63
+ await logger.log("api_manual_block", {"ip": ip, "ok": ok})
64
+ return web.json_response({"blocked": ok, "ip": ip})
65
+
66
+ @router.post("/unblock")
67
+ async def manual_unblock(request: web.Request) -> web.Response:
68
+ body = await request.json()
69
+ ip = body.get("ip", "").strip()
70
+ if not ip:
71
+ return web.json_response({"error": "ip required"}, status=400)
72
+ if ip not in blocker.active_blocks:
73
+ return web.json_response({"error": "not blocked", "ip": ip}, status=404)
74
+ await blocker._unblock_ip(ip)
75
+ await logger.log("api_manual_unblock", {"ip": ip})
76
+ return web.json_response({"unblocked": True, "ip": ip})
77
+
78
+ app = web.Application()
79
+ app.add_routes(router)
80
+
81
+ runner = web.AppRunner(app)
82
+ await runner.setup()
83
+ site = web.TCPSite(runner, host, port)
84
+ await site.start()
85
+ await logger.log("api_started", {"host": host, "port": port})
86
+
87
+ # Block forever (the task is cancelled on shutdown)
88
+ await asyncio.Event().wait()