source: server.py@ a762898

Last change on this file since a762898 was e4c61dd, checked in by istevanoska <ilinastevanoska@…>, 6 months ago

Prototype 1.1

  • Property mode set to 100644
File size: 54.3 KB
Line 
1#!/usr/bin/env python3
2"""
3netIntel Flask Server (multi-tenant + Google login + JWT cookie session + env tokens)
4
5Auth:
6- POST /api/auth/google -> sets HttpOnly cookie "session"
7- GET /api/me -> returns current user claims
8- POST /api/auth/logout -> clears cookie
9
10Tenant admin (JWT cookie; admin role):
11- GET/POST /api/admin/environments
12- POST /api/admin/tokens
13
14Agent (X-Env-Token):
15- POST /receive
16
17Tenant-scoped dashboard (JWT cookie):
18- GET /api/stats
19- GET /api/computers
20- GET /api/computer/<name>
21- GET /api/export/<name>
22- POST /api/chat
23"""
24
25import os
26import json
27import time
28import sqlite3
29import traceback
30import threading
31import secrets
32from datetime import datetime, timedelta
33from functools import wraps
34
35from flask import Flask, request, jsonify, Response
36from dotenv import load_dotenv
37
38import jwt
39from google.oauth2 import id_token
40from google.auth.transport import requests as grequests
41
42# Optional OpenAI
43try:
44 from openai import OpenAI
45except Exception:
46 OpenAI = None
47
48from flask_cors import CORS
49from dotenv import load_dotenv
50from pathlib import Path
51
52BASE_DIR = Path(__file__).resolve().parent
53load_dotenv(BASE_DIR / ".env")
54
55print("GRAFANA_API_TOKEN =", os.environ.get("GRAFANA_API_TOKEN"))
56
57
58# ----------------------------
59# Config
60# ----------------------------
61DB_FILE = os.environ.get("DB_FILE", "lan_logs_sysmon.db")
62LOG_DIR = os.environ.get("LOG_DIR", "received_data_sysmon")
63
64OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
65GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
66JWT_SECRET = os.environ.get("JWT_SECRET", "dev-change-me")
67JWT_ISSUER = os.environ.get("JWT_ISSUER", "netintel")
68COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "0") == "1" # set 1 only on HTTPS
69GRAFANA_TOKEN = os.environ.get("GRAFANA_TOKEN", "")
70
71# CORS origins (add your LAN origins if needed)
72EXTRA_ORIGINS = [o.strip() for o in os.environ.get("CORS_ORIGINS", "").split(",") if o.strip()]
73DEFAULT_ORIGINS = [
74 "http://localhost:5173",
75 "http://127.0.0.1:5173",
76]
77ALLOWED_ORIGINS = list(dict.fromkeys(DEFAULT_ORIGINS + EXTRA_ORIGINS))
78
79client = OpenAI(api_key=OPENAI_API_KEY, timeout=15) if (OPENAI_API_KEY and OpenAI) else None
80
81# ----------------------------
82# App
83# ----------------------------
84app = Flask(__name__)
85os.makedirs(LOG_DIR, exist_ok=True)
86
87CORS(
88 app,
89 supports_credentials=True,
90 origins=ALLOWED_ORIGINS,
91 allow_headers=[
92 "Content-Type",
93 "X-Admin-Session",
94 "X-Env",
95 "X-Env-Token",
96 ],
97 methods=["GET", "POST", "OPTIONS"],
98)
99
100
101# ----------------------------
102# DB helpers
103# ----------------------------
104def db():
105 conn = sqlite3.connect(DB_FILE)
106 conn.row_factory = sqlite3.Row
107 return conn
108
109
110def init_db():
111 """Create tables if missing + light migrations."""
112 conn = db()
113 c = conn.cursor()
114
115 c.execute("PRAGMA foreign_keys = ON;")
116
117 # Tenants / users / memberships
118 c.execute("""
119 CREATE TABLE IF NOT EXISTS tenants(
120 id INTEGER PRIMARY KEY AUTOINCREMENT,
121 name TEXT NOT NULL,
122 owner_email TEXT NOT NULL,
123 created_at TEXT
124 )
125 """)
126
127 c.execute("""
128 CREATE TABLE IF NOT EXISTS users(
129 id INTEGER PRIMARY KEY AUTOINCREMENT,
130 email TEXT UNIQUE NOT NULL,
131 name TEXT,
132 picture TEXT,
133 created_at TEXT
134 )
135 """)
136
137 c.execute("""
138 CREATE TABLE IF NOT EXISTS memberships(
139 user_id INTEGER NOT NULL,
140 tenant_id INTEGER NOT NULL,
141 role TEXT DEFAULT 'admin',
142 created_at TEXT,
143 PRIMARY KEY(user_id, tenant_id),
144 FOREIGN KEY(user_id) REFERENCES users(id),
145 FOREIGN KEY(tenant_id) REFERENCES tenants(id)
146 )
147 """)
148
149 # Environments (unique per tenant)
150 c.execute("""
151 CREATE TABLE IF NOT EXISTS environments(
152 id INTEGER PRIMARY KEY AUTOINCREMENT,
153 tenant_id INTEGER NOT NULL,
154 name TEXT NOT NULL,
155 created_at TEXT,
156 UNIQUE(tenant_id, name),
157 FOREIGN KEY(tenant_id) REFERENCES tenants(id)
158 )
159 """)
160
161 # Env tokens (agent tokens)
162 c.execute("""
163 CREATE TABLE IF NOT EXISTS env_tokens(
164 id INTEGER PRIMARY KEY AUTOINCREMENT,
165 tenant_id INTEGER NOT NULL,
166 env_name TEXT NOT NULL,
167 token TEXT UNIQUE NOT NULL,
168 created_at TEXT,
169 expires_at TEXT,
170 FOREIGN KEY(tenant_id) REFERENCES tenants(id)
171 )
172 """)
173
174 # Computers
175 c.execute("""
176 CREATE TABLE IF NOT EXISTS computers(
177 id INTEGER PRIMARY KEY AUTOINCREMENT,
178 tenant_id INTEGER NOT NULL,
179 env_name TEXT DEFAULT 'default',
180 name TEXT NOT NULL,
181 user TEXT,
182 ip TEXT,
183 os TEXT,
184 first_seen TEXT,
185 last_seen TEXT,
186 sysmon_available INTEGER DEFAULT 0,
187 UNIQUE(tenant_id, name)
188 )
189 """)
190
191 # History
192 c.execute("""
193 CREATE TABLE IF NOT EXISTS computer_history(
194 id INTEGER PRIMARY KEY AUTOINCREMENT,
195 computer_id INTEGER,
196 cpu_usage REAL,
197 ram_usage REAL,
198 disk_usage REAL,
199 network_sent_mb REAL,
200 network_recv_mb REAL,
201 timestamp TEXT,
202 FOREIGN KEY(computer_id) REFERENCES computers(id)
203 )
204 """)
205
206 # Processes
207 c.execute("""
208 CREATE TABLE IF NOT EXISTS computer_processes(
209 id INTEGER PRIMARY KEY AUTOINCREMENT,
210 computer_id INTEGER,
211 pid INTEGER,
212 name TEXT,
213 cpu_percent REAL,
214 memory_mb REAL,
215 username TEXT,
216 cmdline TEXT,
217 timestamp TEXT,
218 FOREIGN KEY(computer_id) REFERENCES computers(id)
219 )
220 """)
221
222 # Sysmon events
223 c.execute("""
224 CREATE TABLE IF NOT EXISTS sysmon_events(
225 id INTEGER PRIMARY KEY AUTOINCREMENT,
226 computer_id INTEGER,
227 event_id INTEGER,
228 event_type TEXT,
229 message TEXT,
230 timestamp TEXT,
231 details TEXT,
232 FOREIGN KEY(computer_id) REFERENCES computers(id)
233 )
234 """)
235
236 # Network connections
237 c.execute("""
238 CREATE TABLE IF NOT EXISTS network_connections(
239 id INTEGER PRIMARY KEY AUTOINCREMENT,
240 computer_id INTEGER,
241 pid INTEGER,
242 local_address TEXT,
243 remote_address TEXT,
244 status TEXT,
245 process_name TEXT,
246 timestamp TEXT,
247 FOREIGN KEY(computer_id) REFERENCES computers(id)
248 )
249 """)
250
251 # Security alerts
252 c.execute("""
253 CREATE TABLE IF NOT EXISTS security_alerts(
254 id INTEGER PRIMARY KEY AUTOINCREMENT,
255 computer_id INTEGER,
256 alert_type TEXT,
257 severity TEXT,
258 description TEXT,
259 timestamp TEXT,
260 resolved INTEGER DEFAULT 0,
261 FOREIGN KEY(computer_id) REFERENCES computers(id)
262 )
263 """)
264
265 # Environment settings (switch-ови по env)
266 c.execute("""
267 CREATE TABLE IF NOT EXISTS env_settings(
268 tenant_id INTEGER NOT NULL,
269 env_name TEXT NOT NULL,
270 save_process_history INTEGER DEFAULT 0,
271 created_at TEXT,
272 updated_at TEXT,
273 PRIMARY KEY(tenant_id, env_name),
274 FOREIGN KEY(tenant_id) REFERENCES tenants(id)
275 )
276 """)
277
278 # Latest processes (only current snapshot)
279 c.execute("""
280 CREATE TABLE IF NOT EXISTS computer_processes_current(
281 id INTEGER PRIMARY KEY AUTOINCREMENT,
282 computer_id INTEGER NOT NULL,
283 pid INTEGER,
284 name TEXT,
285 cpu_percent REAL,
286 memory_mb REAL,
287 username TEXT,
288 cmdline TEXT,
289 timestamp TEXT,
290 FOREIGN KEY(computer_id) REFERENCES computers(id)
291 )
292 """)
293
294 # History processes (optional, controlled by switch)
295 c.execute("""
296 CREATE TABLE IF NOT EXISTS computer_processes_history(
297 id INTEGER PRIMARY KEY AUTOINCREMENT,
298 computer_id INTEGER NOT NULL,
299 pid INTEGER,
300 name TEXT,
301 cpu_percent REAL,
302 memory_mb REAL,
303 username TEXT,
304 cmdline TEXT,
305 timestamp TEXT,
306 FOREIGN KEY(computer_id) REFERENCES computers(id)
307 )
308 """)
309
310
311 conn.commit()
312 conn.close()
313 print(f"✅ DB ready: {DB_FILE}")
314
315
316# ----------------------------
317# JWT helpers
318# ----------------------------
319def make_jwt(payload: dict, minutes=60 * 24):
320 exp = datetime.utcnow() + timedelta(minutes=minutes)
321 data = {**payload, "iss": JWT_ISSUER, "exp": exp}
322 return jwt.encode(data, JWT_SECRET, algorithm="HS256")
323
324
325def read_jwt(token: str):
326 return jwt.decode(token, JWT_SECRET, algorithms=["HS256"], issuer=JWT_ISSUER)
327
328
329def require_user():
330 def deco(fn):
331 @wraps(fn)
332 def wrap(*args, **kwargs):
333 tok = request.cookies.get("session") or ""
334 if not tok:
335 return jsonify({"error": "Unauthorized"}), 401
336 try:
337 claims = read_jwt(tok)
338 except Exception:
339 return jsonify({"error": "Unauthorized"}), 401
340 request.user = claims
341 return fn(*args, **kwargs)
342
343 return wrap
344
345 return deco
346def is_process_history_enabled(tenant_id: int, env_name: str) -> bool:
347 conn = db()
348 c = conn.cursor()
349 c.execute("""
350 SELECT save_process_history
351 FROM env_settings
352 WHERE tenant_id=? AND env_name=?
353 LIMIT 1
354 """, (tenant_id, env_name))
355 row = c.fetchone()
356 conn.close()
357
358 # default: OFF ако нема запис
359 return bool(row["save_process_history"]) if row else False
360
361
362def require_tenant_admin():
363 def deco(fn):
364 @wraps(fn)
365 def wrap(*args, **kwargs):
366 tok = request.cookies.get("session") or ""
367 if not tok:
368 return jsonify({"error": "Unauthorized"}), 401
369 try:
370 claims = read_jwt(tok)
371 except Exception:
372 return jsonify({"error": "Unauthorized"}), 401
373 if claims.get("role") != "admin":
374 return jsonify({"error": "Forbidden"}), 403
375 request.user = claims
376 return fn(*args, **kwargs)
377
378 return wrap
379
380 return deco
381
382
383# ----------------------------
384# Env-token helpers (agents)
385# ----------------------------
386def get_env_from_token(req):
387 tok = req.headers.get("X-Env-Token", "")
388 if not tok:
389 return (None, None)
390
391 conn = db()
392 c = conn.cursor()
393 c.execute("""
394 SELECT env_name, tenant_id
395 FROM env_tokens
396 WHERE token = ?
397 AND (expires_at IS NULL OR expires_at > datetime('now'))
398 LIMIT 1
399 """, (tok,))
400 row = c.fetchone()
401 conn.close()
402
403 if not row:
404 return (None, None)
405 return (row["env_name"], row["tenant_id"])
406
407
408# ----------------------------
409# Utility
410# ----------------------------
411def save_json_file(data):
412 info = data.get("info") or {}
413 computer_name = info.get("computer_name", "unknown")
414 date_str = datetime.now().strftime("%Y%m%d_%H")
415
416 computer_dir = os.path.join(LOG_DIR, computer_name)
417 os.makedirs(computer_dir, exist_ok=True)
418 filepath = os.path.join(computer_dir, f"{date_str}.json")
419
420 if os.path.exists(filepath):
421 try:
422 with open(filepath, "r", encoding="utf-8") as f:
423 existing_data = json.load(f)
424 except Exception:
425 existing_data = []
426 else:
427 existing_data = []
428
429 existing_data.append({
430 "timestamp": info.get("timestamp"),
431 "info": info,
432 "processes_count": len(data.get("processes", [])),
433 "sysmon_events_count": len((data.get("security_data") or {}).get("sysmon_events", [])),
434 })
435
436 with open(filepath, "w", encoding="utf-8") as f:
437 json.dump(existing_data, f, indent=2, ensure_ascii=False)
438
439
440def cleanup_old_data():
441 """Deletes old rows (30 days) every hour."""
442 while True:
443 time.sleep(3600)
444 try:
445 conn = db()
446 c = conn.cursor()
447 cutoff = (datetime.now() - timedelta(days=30)).isoformat()
448
449 c.execute("DELETE FROM computer_history WHERE timestamp < ?", (cutoff,))
450 c.execute("DELETE FROM sysmon_events WHERE timestamp < ?", (cutoff,))
451 c.execute("DELETE FROM network_connections WHERE timestamp < ?", (cutoff,))
452 c.execute("DELETE FROM computer_processes_history WHERE timestamp < ?", (cutoff,))
453
454 conn.commit()
455 conn.close()
456 print(f"[Cleanup] Deleted entries older than {cutoff}")
457 except Exception as e:
458 print("[Cleanup] Error:", e)
459
460
461# ----------------------------
462# Auth endpoints
463# ----------------------------
464@app.route("/api/auth/google", methods=["POST"])
465def auth_google():
466 data = request.get_json(force=True, silent=True) or {}
467 credential = (data.get("credential") or "").strip()
468 if not credential:
469 return jsonify({"error": "Missing credential"}), 400
470 if not GOOGLE_CLIENT_ID:
471 return jsonify({"error": "Server missing GOOGLE_CLIENT_ID"}), 500
472
473 try:
474 idinfo = id_token.verify_oauth2_token(
475 credential,
476 grequests.Request(),
477 GOOGLE_CLIENT_ID,
478 )
479 email = idinfo.get("email")
480 name = idinfo.get("name") or ""
481 picture = idinfo.get("picture") or ""
482
483 if not email:
484 return jsonify({"error": "No email in token"}), 400
485
486 conn = db()
487 c = conn.cursor()
488
489 # upsert user
490 c.execute("SELECT id FROM users WHERE email=?", (email,))
491 row = c.fetchone()
492 if row:
493 user_id = row["id"]
494 c.execute("UPDATE users SET name=?, picture=? WHERE id=?", (name, picture, user_id))
495 else:
496 c.execute(
497 "INSERT INTO users(email, name, picture, created_at) VALUES(?,?,?, datetime('now'))",
498 (email, name, picture),
499 )
500 user_id = c.lastrowid
501
502 # membership -> tenant (create tenant if first time)
503 c.execute("SELECT tenant_id, role FROM memberships WHERE user_id=? LIMIT 1", (user_id,))
504 m = c.fetchone()
505
506 if not m:
507 tenant_name = email.split("@")[0]
508 c.execute(
509 "INSERT INTO tenants(name, owner_email, created_at) VALUES(?,?, datetime('now'))",
510 (tenant_name, email),
511 )
512 tenant_id = c.lastrowid
513 role = "admin"
514
515 c.execute(
516 "INSERT INTO memberships(user_id, tenant_id, role, created_at) VALUES(?,?, 'admin', datetime('now'))",
517 (user_id, tenant_id),
518 )
519
520 # default env for this tenant
521 c.execute(
522 "INSERT OR IGNORE INTO environments(tenant_id, name, created_at) VALUES(?, 'default', datetime('now'))",
523 (tenant_id,),
524 )
525 else:
526 tenant_id = m["tenant_id"]
527 role = m["role"] or "admin"
528
529 conn.commit()
530 conn.close()
531
532 session = make_jwt(
533 {
534 "uid": user_id,
535 "email": email,
536 "name": name,
537 "role": role,
538 "tenant_id": tenant_id,
539 },
540 minutes=60 * 24,
541 )
542
543 resp = jsonify({"ok": True, "user": {"email": email, "name": name, "role": role, "tenant_id": tenant_id}})
544 resp.set_cookie(
545 "session",
546 session,
547 httponly=True,
548 samesite="Lax",
549 secure=COOKIE_SECURE,
550 max_age=60 * 60 * 24,
551 )
552 return resp
553
554 except Exception as e:
555 return jsonify({"error": "Invalid Google token", "details": str(e)}), 401
556
557
558@app.route("/api/me", methods=["GET"])
559@require_user()
560def api_me():
561 return jsonify({"user": request.user})
562
563
564@app.route("/api/auth/logout", methods=["POST"])
565def auth_logout():
566 resp = jsonify({"ok": True})
567 resp.set_cookie("session", "", expires=0)
568 return resp
569
570
571# ----------------------------
572# Admin endpoints
573# ----------------------------
574@app.route("/api/admin/environments", methods=["GET"])
575@require_tenant_admin()
576def admin_list_envs():
577 tenant_id = request.user["tenant_id"]
578 conn = db()
579 c = conn.cursor()
580 c.execute("SELECT name FROM environments WHERE tenant_id=? ORDER BY name", (tenant_id,))
581 envs = [r["name"] for r in c.fetchall()]
582 conn.close()
583 if not envs:
584 envs = ["default"]
585 return jsonify({"environments": envs})
586
587
588@app.route("/api/admin/environments", methods=["POST"])
589@require_tenant_admin()
590def admin_create_env():
591 tenant_id = request.user["tenant_id"]
592 data = request.get_json(force=True, silent=True) or {}
593 name = (data.get("name") or "").strip()
594 if not name:
595 return jsonify({"error": "Missing env name"}), 400
596
597 conn = db()
598 c = conn.cursor()
599 try:
600 c.execute(
601 "INSERT INTO environments(tenant_id, name, created_at) VALUES(?, ?, datetime('now'))",
602 (tenant_id, name),
603 )
604 conn.commit()
605 except Exception as e:
606 conn.close()
607 return jsonify({"error": str(e)}), 400
608 conn.close()
609 return jsonify({"ok": True, "name": name})
610
611
612@app.route("/api/admin/tokens", methods=["POST"])
613@require_tenant_admin()
614def admin_generate_token():
615 tenant_id = request.user["tenant_id"]
616 data = request.get_json(force=True, silent=True) or {}
617 env = (data.get("env") or "").strip()
618 if not env:
619 return jsonify({"error": "Missing env"}), 400
620
621 conn = db()
622 c = conn.cursor()
623 c.execute("SELECT 1 FROM environments WHERE tenant_id=? AND name=? LIMIT 1", (tenant_id, env))
624 if not c.fetchone():
625 conn.close()
626 return jsonify({"error": "Environment not found"}), 404
627
628 token = secrets.token_urlsafe(32)
629 c.execute("""
630 INSERT INTO env_tokens(tenant_id, env_name, token, created_at, expires_at)
631 VALUES(?, ?, ?, datetime('now'), datetime('now', '+90 days'))
632 """, (tenant_id, env, token))
633 conn.commit()
634 conn.close()
635
636 return jsonify({"ok": True, "env": env, "token": token})
637
638@app.route("/api/admin/env-settings/<env_name>", methods=["GET"])
639@require_tenant_admin()
640def admin_get_env_settings(env_name):
641 tenant_id = request.user["tenant_id"]
642
643 conn = db()
644 c = conn.cursor()
645 c.execute("""
646 SELECT save_process_history
647 FROM env_settings
648 WHERE tenant_id=? AND env_name=?
649 LIMIT 1
650 """, (tenant_id, env_name))
651 row = c.fetchone()
652 conn.close()
653
654 return jsonify({
655 "env": env_name,
656 "save_process_history": bool(row["save_process_history"]) if row else False
657 })
658
659@app.route("/api/admin/env-settings/<env_name>", methods=["POST"])
660@require_tenant_admin()
661def admin_set_env_settings(env_name):
662 tenant_id = request.user["tenant_id"]
663 data = request.get_json(force=True, silent=True) or {}
664 enabled = 1 if bool(data.get("save_process_history")) else 0
665
666 conn = db()
667 c = conn.cursor()
668 c.execute("""
669 INSERT INTO env_settings(tenant_id, env_name, save_process_history, created_at, updated_at)
670 VALUES(?, ?, ?, datetime('now'), datetime('now'))
671 ON CONFLICT(tenant_id, env_name) DO UPDATE SET
672 save_process_history=excluded.save_process_history,
673 updated_at=datetime('now')
674 """, (tenant_id, env_name, enabled))
675 conn.commit()
676 conn.close()
677
678 return jsonify({"ok": True, "env": env_name, "save_process_history": bool(enabled)})
679
680# ----------------------------
681# Agent endpoint
682# ----------------------------
683@app.route("/receive", methods=["POST"])
684def receive_data():
685 try:
686 data = request.get_json(force=True, silent=True) or {}
687 if not data:
688 return jsonify({"error": "No data"}), 400
689
690 env_name, tenant_id = get_env_from_token(request)
691 if not env_name or not tenant_id:
692 return jsonify({"error": "Missing or invalid X-Env-Token"}), 401
693
694 info = data.get("info") or {}
695 computer_name = info.get("computer_name")
696 if not computer_name:
697 return jsonify({"error": "Missing info.computer_name"}), 400
698
699 now_iso = datetime.now().isoformat()
700 security_data = data.get("security_data") or {}
701
702 conn = db()
703 c = conn.cursor()
704
705 # Find by (tenant_id + name)
706 c.execute(
707 "SELECT id FROM computers WHERE tenant_id=? AND name=? LIMIT 1",
708 (tenant_id, computer_name),
709 )
710 row = c.fetchone()
711
712 if row:
713 computer_id = row["id"]
714 c.execute("""
715 UPDATE computers
716 SET user=?, ip=?, os=?, last_seen=?, sysmon_available=?, env_name=?
717 WHERE id=? AND tenant_id=?
718 """, (
719 info.get("user"),
720 info.get("ip_address"),
721 info.get("os"),
722 now_iso,
723 int(info.get("is_sysmon_available", 0) or 0),
724 env_name,
725 computer_id,
726 tenant_id,
727 ))
728 else:
729 c.execute("""
730 INSERT INTO computers(
731 tenant_id, env_name, name, user, ip, os,
732 first_seen, last_seen, sysmon_available
733 )
734 VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
735 """, (
736 tenant_id,
737 env_name,
738 computer_name,
739 info.get("user"),
740 info.get("ip_address"),
741 info.get("os"),
742 now_iso,
743 now_iso,
744 int(info.get("is_sysmon_available", 0) or 0),
745 ))
746 computer_id = c.lastrowid
747
748 # history row
749 c.execute("""
750 INSERT INTO computer_history(
751 computer_id, cpu_usage, ram_usage, disk_usage,
752 network_sent_mb, network_recv_mb, timestamp
753 )
754 VALUES(?, ?, ?, ?, ?, ?, ?)
755 """, (
756 computer_id,
757 float(info.get("cpu_usage") or 0),
758 float(info.get("ram_usage") or 0),
759 float(info.get("disk_usage") or 0),
760 float(info.get("network_sent_mb") or 0),
761 float(info.get("network_recv_mb") or 0),
762 info.get("timestamp") or now_iso,
763 ))
764
765 # ----------------------------
766 # processes: CURRENT (always)
767 # ----------------------------
768 # Clear old current snapshot for this computer
769 c.execute("DELETE FROM computer_processes_current WHERE computer_id=?", (computer_id,))
770
771 # Insert new snapshot
772 for proc in (data.get("processes") or []):
773 c.execute("""
774 INSERT INTO computer_processes_current(
775 computer_id, pid, name, cpu_percent, memory_mb, username, cmdline, timestamp
776 )
777 VALUES(?, ?, ?, ?, ?, ?, ?, ?)
778 """, (
779 computer_id,
780 proc.get("pid"),
781 proc.get("name"),
782 float(proc.get("cpu_percent") or 0),
783 float(proc.get("memory_mb") or 0),
784 proc.get("user") or proc.get("username"),
785 proc.get("cmdline"),
786 info.get("timestamp") or now_iso,
787 ))
788
789 # ----------------------------
790 # processes: HISTORY (optional)
791 # ----------------------------
792 if is_process_history_enabled(tenant_id, env_name):
793 for proc in (data.get("processes") or []):
794 c.execute("""
795 INSERT INTO computer_processes_history(
796 computer_id, pid, name, cpu_percent, memory_mb, username, cmdline, timestamp
797 )
798 VALUES(?, ?, ?, ?, ?, ?, ?, ?)
799 """, (
800 computer_id,
801 proc.get("pid"),
802 proc.get("name"),
803 float(proc.get("cpu_percent") or 0),
804 float(proc.get("memory_mb") or 0),
805 proc.get("user") or proc.get("username"),
806 proc.get("cmdline"),
807 info.get("timestamp") or now_iso,
808 ))
809
810 # sysmon events
811 sysmon_count = 0
812 for ev in (security_data.get("sysmon_events") or []):
813 c.execute("""
814 INSERT INTO sysmon_events(
815 computer_id, event_id, event_type, message, timestamp, details
816 )
817 VALUES(?, ?, ?, ?, ?, ?)
818 """, (
819 computer_id,
820 ev.get("event_id"),
821 ev.get("event_type", "Unknown"),
822 ev.get("message", ""),
823 ev.get("timestamp") or (info.get("timestamp") or now_iso),
824 json.dumps(ev, ensure_ascii=False),
825 ))
826 sysmon_count += 1
827
828 # network connections
829 for nc in (security_data.get("network_connections") or []):
830 c.execute("""
831 INSERT INTO network_connections(
832 computer_id, pid, local_address, remote_address, status, process_name, timestamp
833 )
834 VALUES(?, ?, ?, ?, ?, ?, ?)
835 """, (
836 computer_id,
837 nc.get("pid"),
838 nc.get("local_address"),
839 nc.get("remote_address"),
840 nc.get("status"),
841 nc.get("process_name"),
842 info.get("timestamp") or now_iso,
843 ))
844
845 conn.commit()
846 conn.close()
847
848 # optional JSON archive
849 try:
850 save_json_file(data)
851 except Exception:
852 pass
853
854 return jsonify({
855 "ok": True,
856 "computer_id": computer_id,
857 "env": env_name,
858 "tenant_id": tenant_id,
859 "sysmon_events_saved": sysmon_count,
860 "server_time": now_iso,
861 "process_history_enabled": bool(is_process_history_enabled(tenant_id, env_name)),
862 })
863
864 except Exception as e:
865 traceback.print_exc()
866 return jsonify({"error": str(e)}), 500
867
868
869# ----------------------------
870# Dashboard API (tenant scoped)
871# ----------------------------
872@app.route("/api/computers", methods=["GET"])
873@require_user()
874def api_computers():
875 tenant_id = request.user["tenant_id"]
876 env = request.headers.get("X-Env", "default")
877
878 conn = db()
879 c = conn.cursor()
880 c.execute("""
881 SELECT id, name, user, ip, os, first_seen, last_seen, sysmon_available
882 FROM computers
883 WHERE tenant_id=? AND env_name=?
884 ORDER BY last_seen DESC
885 """, (tenant_id, env))
886 rows = c.fetchall()
887
888 computers = []
889 for r in rows:
890 cid = r["id"]
891
892 c.execute("""
893 SELECT COUNT(*) AS n
894 FROM computer_history
895 WHERE computer_id=? AND timestamp > datetime('now','-24 hours')
896 """, (cid,))
897 recent_logs = int(c.fetchone()["n"] or 0)
898
899 c.execute("""
900 SELECT COUNT(*) AS n
901 FROM sysmon_events
902 WHERE computer_id=? AND timestamp > datetime('now','-24 hours')
903 """, (cid,))
904 recent_sysmon = int(c.fetchone()["n"] or 0)
905
906 c.execute("""
907 SELECT cpu_usage, ram_usage, timestamp
908 FROM computer_history
909 WHERE computer_id=?
910 ORDER BY timestamp DESC
911 LIMIT 5
912 """, (cid,))
913 metrics = c.fetchall()
914 avg_cpu = round(sum(m["cpu_usage"] for m in metrics) / len(metrics), 1) if metrics else 0.0
915 avg_ram = round(sum(m["ram_usage"] for m in metrics) / len(metrics), 1) if metrics else 0.0
916
917 status = "offline"
918 status_color = "gray"
919 try:
920 last_seen = r["last_seen"]
921 if last_seen:
922 dt = datetime.fromisoformat(
923 last_seen.replace("Z", "+00:00")) if "T" in last_seen else datetime.fromisoformat(last_seen)
924 diff = (datetime.now() - dt).total_seconds()
925 if diff < 60:
926 status, status_color = "online", "green"
927 elif diff < 300:
928 status, status_color = "idle", "orange"
929 except Exception:
930 pass
931
932 computers.append({
933 "name": r["name"],
934 "user": r["user"],
935 "ip": r["ip"],
936 "os": r["os"],
937 "first_seen": r["first_seen"],
938 "last_seen": r["last_seen"],
939 "sysmon_available": bool(r["sysmon_available"]),
940 "recent_logs": recent_logs,
941 "recent_sysmon": recent_sysmon,
942 "avg_cpu": avg_cpu,
943 "avg_ram": avg_ram,
944 "status": status,
945 "status_color": status_color,
946 })
947
948 conn.close()
949 return jsonify(computers)
950
951
952@app.route("/api/stats", methods=["GET"])
953@require_user()
954def api_stats():
955 tenant_id = request.user["tenant_id"]
956
957 conn = db()
958 c = conn.cursor()
959
960 c.execute("""
961 SELECT COUNT(*) AS n
962 FROM sysmon_events s
963 JOIN computers c2 ON c2.id = s.computer_id
964 WHERE s.timestamp > datetime('now','-24 hours')
965 AND c2.tenant_id = ?
966 """, (tenant_id,))
967 total_sysmon = int(c.fetchone()["n"] or 0)
968
969 c.execute("""
970 SELECT COUNT(*) AS n
971 FROM network_connections n
972 JOIN computers c2 ON c2.id = n.computer_id
973 WHERE n.timestamp > datetime('now','-24 hours')
974 AND c2.tenant_id = ?
975 """, (tenant_id,))
976 total_net = int(c.fetchone()["n"] or 0)
977
978 conn.close()
979 return jsonify({
980 "total_sysmon_events": total_sysmon,
981 "total_network_connections": total_net,
982 "server_time": datetime.now().isoformat(),
983 })
984
985
986@app.route("/api/computer/<computer_name>", methods=["GET"])
987@require_user()
988def api_computer_details(computer_name):
989 tenant_id = request.user["tenant_id"]
990
991 conn = db()
992 c = conn.cursor()
993 c.execute("""
994 SELECT id, name, user, ip, os, first_seen, last_seen, env_name
995 FROM computers
996 WHERE tenant_id=? AND name=?
997 LIMIT 1
998 """, (tenant_id, computer_name))
999 comp = c.fetchone()
1000 if not comp:
1001 conn.close()
1002 return jsonify({"error": "Computer not found"}), 404
1003
1004 computer_id = comp["id"]
1005
1006 c.execute("SELECT COUNT(*) AS n FROM computer_history WHERE computer_id=?", (computer_id,))
1007 total_logs = int(c.fetchone()["n"] or 0)
1008
1009 c.execute("""
1010 SELECT cpu_usage, ram_usage, disk_usage, network_sent_mb, network_recv_mb, timestamp
1011 FROM computer_history
1012 WHERE computer_id=?
1013 ORDER BY timestamp DESC
1014 LIMIT 100
1015 """, (computer_id,))
1016 history = [dict(r) for r in c.fetchall()]
1017 current_metrics = history[0] if history else {}
1018
1019 c.execute("""
1020 SELECT pid, name, username, cpu_percent, memory_mb, cmdline, timestamp
1021 FROM computer_processes_current
1022 WHERE computer_id=?
1023 ORDER BY timestamp DESC
1024 LIMIT 200
1025 """, (computer_id,))
1026 processes = [dict(r) for r in c.fetchall()]
1027
1028 c.execute("""
1029 SELECT event_id, event_type, message, timestamp, details
1030 FROM sysmon_events
1031 WHERE computer_id=?
1032 ORDER BY timestamp DESC
1033 LIMIT 200
1034 """, (computer_id,))
1035 sysmon = []
1036 for r in c.fetchall():
1037 d = {}
1038 try:
1039 d = json.loads(r["details"]) if r["details"] else {}
1040 except Exception:
1041 d = {}
1042 sysmon.append({
1043 "event_id": r["event_id"],
1044 "event_type": r["event_type"],
1045 "message": r["message"],
1046 "timestamp": r["timestamp"],
1047 "details": d,
1048 })
1049
1050 c.execute("""
1051 SELECT pid, process_name, local_address, remote_address, status, timestamp
1052 FROM network_connections
1053 WHERE computer_id=?
1054 ORDER BY timestamp DESC
1055 LIMIT 100
1056 """, (computer_id,))
1057 net = [dict(r) for r in c.fetchall()]
1058
1059 conn.close()
1060
1061 return jsonify({
1062 "computer": {
1063 "id": comp["id"],
1064 "name": comp["name"],
1065 "user": comp["user"],
1066 "ip": comp["ip"],
1067 "os": comp["os"],
1068 "first_seen": comp["first_seen"],
1069 "last_seen": comp["last_seen"],
1070 "env_name": comp["env_name"],
1071 "total_logs": total_logs,
1072 },
1073 "history": history,
1074 "current_metrics": current_metrics,
1075 "recent_processes": processes,
1076 "sysmon_events": sysmon,
1077 "network_connections": net,
1078 })
1079
1080
1081@app.route("/api/export/<computer_name>", methods=["GET"])
1082@require_user()
1083def api_export(computer_name):
1084 tenant_id = request.user["tenant_id"]
1085
1086 conn = db()
1087 c = conn.cursor()
1088 c.execute("""
1089 SELECT *
1090 FROM computers
1091 WHERE tenant_id=? AND name=?
1092 LIMIT 1
1093 """, (tenant_id, computer_name))
1094 comp = c.fetchone()
1095 if not comp:
1096 conn.close()
1097 return jsonify({"error": "Computer not found"}), 404
1098
1099 computer_id = comp["id"]
1100 out = {"computer": dict(comp)}
1101
1102 c.execute("SELECT * FROM computer_history WHERE computer_id=? ORDER BY timestamp", (computer_id,))
1103 out["history"] = [dict(r) for r in c.fetchall()]
1104
1105 c.execute("SELECT * FROM sysmon_events WHERE computer_id=? ORDER BY timestamp", (computer_id,))
1106 out["sysmon_events"] = [dict(r) for r in c.fetchall()]
1107
1108 c.execute("SELECT * FROM computer_processes_current WHERE computer_id=? ORDER BY timestamp", (computer_id,))
1109 out["processes_current"] = [dict(r) for r in c.fetchall()]
1110
1111 c.execute("SELECT * FROM computer_processes_history WHERE computer_id=? ORDER BY timestamp", (computer_id,))
1112 out["processes_history"] = [dict(r) for r in c.fetchall()]
1113
1114 c.execute("SELECT * FROM network_connections WHERE computer_id=? ORDER BY timestamp", (computer_id,))
1115 out["network_connections"] = [dict(r) for r in c.fetchall()]
1116
1117 conn.close()
1118
1119 return Response(
1120 json.dumps(out, indent=2, default=str, ensure_ascii=False),
1121 mimetype="application/json",
1122 headers={"Content-Disposition": f"attachment; filename={computer_name}_export.json"},
1123 )
1124
1125
1126# ----------------------------
1127# Simple RAG chat
1128# ----------------------------
1129# def build_rag_context(tenant_id: int, question: str, computer_name=None, limit_per_table=10):
1130# conn = db()
1131# c = conn.cursor()
1132#
1133# params = [tenant_id]
1134# comp_clause = ""
1135# if computer_name:
1136# comp_clause = "AND c2.name = ?"
1137# params.append(computer_name)
1138#
1139# # sysmon
1140# c.execute(f"""
1141# SELECT s.timestamp, s.event_id, s.event_type, s.message
1142# FROM sysmon_events s
1143# JOIN computers c2 ON c2.id = s.computer_id
1144# WHERE c2.tenant_id = ? {comp_clause}
1145# ORDER BY s.timestamp DESC
1146# LIMIT ?
1147# """, (*params, limit_per_table))
1148# sysmon_rows = c.fetchall()
1149#
1150# # perf
1151# c.execute(f"""
1152# SELECT h.timestamp, h.cpu_usage, h.ram_usage, h.disk_usage, h.network_sent_mb, h.network_recv_mb
1153# FROM computer_history h
1154# JOIN computers c2 ON c2.id = h.computer_id
1155# WHERE c2.tenant_id = ? {comp_clause}
1156# ORDER BY h.timestamp DESC
1157# LIMIT ?
1158# """, (*params, limit_per_table))
1159# perf_rows = c.fetchall()
1160#
1161# # proc
1162# c.execute(f"""
1163# SELECT p.timestamp, p.pid, p.name, p.cpu_percent, p.memory_mb
1164# FROM computer_processes_current p
1165# JOIN computers c2 ON c2.id = p.computer_id
1166# WHERE c2.tenant_id = ? {comp_clause}
1167# ORDER BY p.timestamp DESC
1168# LIMIT ?
1169# """, (*params, limit_per_table))
1170# proc_rows = c.fetchall()
1171#
1172# conn.close()
1173#
1174# lines = []
1175# for r in sysmon_rows:
1176# lines.append(f"[SYSMON] {r['timestamp']} | Event {r['event_id']} ({r['event_type']}): {r['message']}")
1177# for r in perf_rows:
1178# lines.append(
1179# f"[PERF] {r['timestamp']} | CPU {r['cpu_usage']}% | RAM {r['ram_usage']}% | Disk {r['disk_usage']}% | "
1180# f"Net sent {r['network_sent_mb']}MB / recv {r['network_recv_mb']}MB"
1181# )
1182# for r in proc_rows:
1183# lines.append(
1184# f"[PROC] {r['timestamp']} | PID {r['pid']} | {r['name']} | CPU {r['cpu_percent']}% | RAM {r['memory_mb']}MB"
1185# )
1186#
1187# return "\n".join(lines)
1188def ask_llm_with_context(question, context):
1189 if not client:
1190 return "Немаш поставено OPENAI_API_KEY на серверот."
1191
1192 prompt = f"""Ти си асистент за безбедност и систем мониторинг.
1193Одговарај базирано ИСКЛУЧИВО на дадените логови.
1194Ако нема доволно податоци, кажи: 'Нема доволно информации во логовите.'.
1195
1196Прашање: {question}
1197
1198Логови:
1199{context or 'Нема логови.'}
1200"""
1201
1202 resp = client.responses.create(
1203 model="gpt-4.1-mini",
1204 input=[{"role": "user", "content": [{"type": "input_text", "text": prompt}]}],
1205 )
1206
1207 text = getattr(resp, "output_text", "") or ""
1208 return text.strip() or "Нема доволно информации во логовите."
1209
1210def require_grafana():
1211 def deco(fn):
1212 @wraps(fn)
1213 def wrap(*args, **kwargs):
1214 tok = request.headers.get("X-Grafana-Token", "")
1215 if not GRAFANA_TOKEN or tok != GRAFANA_TOKEN:
1216 return jsonify({"error": "Unauthorized"}), 401
1217 return fn(*args, **kwargs)
1218 return wrap
1219 return deco
1220@app.route("/api/public/stats", methods=["GET"])
1221@require_grafana()
1222def grafana_stats():
1223 # ако сакаш само една околина:
1224 env = request.args.get("env", "default")
1225
1226 conn = db()
1227 c = conn.cursor()
1228
1229 # вкупно sysmon во 24h (за сите tenants, или ако сакаш само 1 tenant додади филтер)
1230 c.execute("""
1231 SELECT COUNT(*) AS n
1232 FROM sysmon_events s
1233 JOIN computers c2 ON c2.id = s.computer_id
1234 WHERE s.timestamp > datetime('now','-24 hours')
1235 AND c2.env_name = ?
1236 """, (env,))
1237 total_sysmon = int(c.fetchone()["n"] or 0)
1238
1239 c.execute("""
1240 SELECT COUNT(*) AS n
1241 FROM network_connections n
1242 JOIN computers c2 ON c2.id = n.computer_id
1243 WHERE n.timestamp > datetime('now','-24 hours')
1244 AND c2.env_name = ?
1245 """, (env,))
1246 total_net = int(c.fetchone()["n"] or 0)
1247
1248 conn.close()
1249 return jsonify({
1250 "total_sysmon_events": total_sysmon,
1251 "total_network_connections": total_net,
1252 "server_time": datetime.now().isoformat(),
1253 "env": env,
1254 })
1255
1256def require_grafana_token(fn):
1257 @wraps(fn)
1258 def wrapper(*args, **kwargs):
1259 expected = os.environ.get("GRAFANA_API_TOKEN") or ""
1260
1261 # 1) пробај Authorization: Bearer <token>
1262 auth = request.headers.get("Authorization", "")
1263 token = ""
1264 if auth.startswith("Bearer "):
1265 token = auth.replace("Bearer ", "").strip()
1266
1267 # 2) ако нема header, пробај query param ?token=
1268 if not token:
1269 token = (request.args.get("token") or "").strip()
1270
1271 if not expected or token != expected:
1272 return jsonify({"error": "Invalid Grafana token"}), 401
1273
1274 return fn(*args, **kwargs)
1275 return wrapper
1276
1277@app.route("/api/public/ips", methods=["GET"])
1278@require_grafana_token
1279def api_public_ips():
1280 tenant_id = request.args.get("tenant_id", type=int)
1281 env = request.args.get("env", "default")
1282 hours = int(request.args.get("hours", "24"))
1283 limit = int(request.args.get("limit", "200"))
1284
1285 if not tenant_id:
1286 return jsonify({"error": "Missing tenant_id"}), 400
1287
1288 conn = db()
1289 c = conn.cursor()
1290
1291 c.execute("""
1292 SELECT DISTINCT
1293 substr(nc.remote_address, 1,
1294 CASE
1295 WHEN instr(nc.remote_address, ':') > 0
1296 THEN instr(nc.remote_address, ':') - 1
1297 ELSE length(nc.remote_address)
1298 END
1299 ) AS ip
1300 FROM network_connections nc
1301 JOIN computers c2 ON c2.id = nc.computer_id
1302 WHERE c2.tenant_id = ?
1303 AND c2.env_name = ?
1304 AND nc.timestamp > datetime('now', ?)
1305 AND nc.remote_address IS NOT NULL
1306 AND nc.remote_address != ''
1307 ORDER BY ip
1308 LIMIT ?
1309 """, (tenant_id, env, f"-{hours} hours", limit))
1310
1311 ips = [r["ip"] for r in c.fetchall()]
1312 conn.close()
1313
1314 return jsonify({"ips": ips})
1315
1316
1317
1318@app.route("/api/public/sankey", methods=["GET"])
1319@require_grafana_token
1320def api_public_sankey():
1321 tenant_id = int(request.args.get("tenant_id", "1"))
1322 env = request.args.get("env", "office1")
1323 hours = int(request.args.get("hours", "24"))
1324 limit = int(request.args.get("limit", "20"))
1325
1326 computers = (request.args.get("computers") or "all").strip()
1327 ips = (request.args.get("ips") or "all").strip()
1328
1329 # normalize grafana All values
1330 if computers in ("", "__all"):
1331 computers = "all"
1332 if ips in ("", "__all"):
1333 ips = "all"
1334
1335 comp_list = [c.strip() for c in computers.split(",") if c.strip()] if computers != "all" else []
1336 ip_list = [i.strip() for i in ips.split(",") if i.strip()] if ips != "all" else []
1337
1338 comp_clause = ""
1339 ip_clause = ""
1340 params = [tenant_id, env, f"-{hours} hours"]
1341
1342 # ✅ target без port за IPv4: "1.2.3.4:443" -> "1.2.3.4"
1343 # ⚠️ за IPv6 ќе го остави како што е (за да не го расипеме со split по ':')
1344 target_expr = """
1345 CASE
1346 WHEN nc.remote_address LIKE '%:%' AND nc.remote_address NOT LIKE '%:%:%'
1347 THEN substr(nc.remote_address, 1, instr(nc.remote_address, ':') - 1)
1348 ELSE nc.remote_address
1349 END
1350 """
1351
1352 if comp_list:
1353 comp_clause = "AND comp.name IN (" + ",".join(["?"] * len(comp_list)) + ")"
1354 params.extend(comp_list)
1355
1356 # ✅ Филтер по IP без порт (истиот expr)
1357 if ip_list:
1358 ip_clause = f"AND ({target_expr}) IN (" + ",".join(["?"] * len(ip_list)) + ")"
1359 params.extend(ip_list)
1360
1361 # LIMIT на крај
1362 params.append(limit)
1363
1364 conn = db()
1365 c = conn.cursor()
1366
1367 c.execute(f"""
1368 SELECT
1369 comp.name AS source,
1370 ({target_expr}) AS target,
1371 COUNT(*) AS value
1372 FROM network_connections nc
1373 JOIN computers comp ON comp.id = nc.computer_id
1374 WHERE comp.tenant_id = ?
1375 AND comp.env_name = ?
1376 AND nc.timestamp > datetime('now', ?)
1377 AND nc.remote_address IS NOT NULL
1378 AND nc.remote_address != ''
1379
1380 -- ✅ тргни loopback spam (овие прават да изгледа како да нема линии)
1381 AND nc.remote_address NOT LIKE '127.0.0.1%'
1382 AND nc.remote_address NOT LIKE '::1%'
1383
1384 {comp_clause}
1385 {ip_clause}
1386 GROUP BY comp.name, target
1387 ORDER BY value DESC
1388 LIMIT ?
1389 """, params)
1390
1391 rows = [dict(r) for r in c.fetchall()]
1392 conn.close()
1393 return jsonify(rows)
1394
1395from urllib.parse import urlparse
1396
1397def _ip_only(addr: str):
1398 # addr може да е "1.2.3.4:443" или "hostname:port"
1399 if not addr:
1400 return None
1401 # ако има повеќе ':' (ipv6), ќе го оставиме целото; за ipv4 split е ок
1402 if addr.count(":") == 1:
1403 return addr.split(":")[0]
1404 return addr
1405
1406@app.route("/api/graph/net", methods=["GET"])
1407@require_user()
1408def api_graph_net():
1409 tenant_id = request.user["tenant_id"]
1410 env = request.args.get("env") or request.headers.get("X-Env", "default")
1411 computer = request.args.get("computer") # optional
1412 since_hours = int(request.args.get("since_hours") or 24)
1413
1414 conn = db()
1415 c = conn.cursor()
1416
1417 params = [tenant_id, env, since_hours]
1418 comp_clause = ""
1419 if computer:
1420 comp_clause = "AND c2.name = ?"
1421 params.append(computer)
1422
1423 # Вади top remote IP по број на конекции
1424 c.execute(f"""
1425 SELECT
1426 c2.name AS computer_name,
1427 n.remote_address AS remote_address,
1428 COUNT(*) AS cnt
1429 FROM network_connections n
1430 JOIN computers c2 ON c2.id = n.computer_id
1431 WHERE c2.tenant_id = ?
1432 AND c2.env_name = ?
1433 AND n.timestamp > datetime('now', '-' || ? || ' hours')
1434 {comp_clause}
1435 AND n.remote_address IS NOT NULL
1436 AND n.remote_address <> ''
1437 GROUP BY c2.name, n.remote_address
1438 ORDER BY cnt DESC
1439 LIMIT 200
1440 """, params)
1441
1442 rows = c.fetchall()
1443 conn.close()
1444
1445 # Build NodeGraph-like structure
1446 nodes = {}
1447 edges = []
1448
1449 def add_node(node_id, title, ntype):
1450 if node_id not in nodes:
1451 nodes[node_id] = {"id": node_id, "title": title, "subTitle": ntype}
1452
1453 for r in rows:
1454 comp_name = r["computer_name"]
1455 remote = _ip_only(r["remote_address"]) or r["remote_address"]
1456 cnt = int(r["cnt"] or 0)
1457
1458 comp_id = f"comp:{comp_name}"
1459 ip_id = f"ip:{remote}"
1460
1461 add_node(comp_id, comp_name, "computer")
1462 add_node(ip_id, remote, "remote_ip")
1463
1464 edges.append({
1465 "id": f"{comp_id}->{ip_id}",
1466 "source": comp_id,
1467 "target": ip_id,
1468 "mainStat": str(cnt),
1469 })
1470
1471 return jsonify({
1472 "nodes": list(nodes.values()),
1473 "edges": edges,
1474 "meta": {"env": env, "computer": computer, "since_hours": since_hours}
1475 })
1476
1477@app.route("/api/public/netgraph", methods=["GET"])
1478@require_grafana_token
1479def api_public_netgraph():
1480 tenant_id = int(request.args.get("tenant_id", "1"))
1481 env = request.args.get("env", "default")
1482 hours = int(request.args.get("hours", "6"))
1483 limit_edges = int(request.args.get("limit", "200"))
1484 computer = request.args.get("computer") # <-- NEW (optional)
1485
1486 conn = db()
1487 c = conn.cursor()
1488
1489 comp_clause = ""
1490 params = [tenant_id, env, f"-{hours} hours"]
1491
1492 if computer and computer != "all":
1493 comp_clause = "AND comp.name = ?"
1494 params.append(computer)
1495
1496 params.append(limit_edges)
1497
1498 c.execute(f"""
1499 SELECT
1500 comp.name AS src,
1501 nc.remote_address AS remote,
1502 COUNT(*) AS cnt
1503 FROM network_connections nc
1504 JOIN computers comp ON comp.id = nc.computer_id
1505 WHERE comp.tenant_id = ?
1506 AND comp.env_name = ?
1507 AND nc.timestamp > datetime('now', ?)
1508 {comp_clause}
1509 AND nc.remote_address IS NOT NULL
1510 AND nc.remote_address != ''
1511 GROUP BY comp.name, nc.remote_address
1512 ORDER BY cnt DESC
1513 LIMIT ?
1514 """, params)
1515
1516 rows = c.fetchall()
1517 conn.close()
1518
1519 def ip_only(addr: str) -> str:
1520 if not addr:
1521 return ""
1522 if addr.startswith("[") and "]" in addr:
1523 return addr[1:addr.index("]")]
1524 if ":" in addr:
1525 return addr.split(":")[0]
1526 return addr
1527
1528 nodes = {}
1529 edges = []
1530
1531 for r in rows:
1532 src = r["src"]
1533 remote_ip = ip_only(r["remote"])
1534 cnt = int(r["cnt"] or 0)
1535 if not remote_ip:
1536 continue
1537
1538 nodes[src] = {"id": src, "title": src, "subTitle": env, "mainStat": "PC"}
1539 ip_id = f"IP:{remote_ip}"
1540 if ip_id not in nodes:
1541 nodes[ip_id] = {"id": ip_id, "title": remote_ip, "subTitle": "remote", "mainStat": "IP"}
1542
1543 edges.append({
1544 "id": f"{src}->{ip_id}",
1545 "source": src,
1546 "target": ip_id,
1547 "mainStat": cnt
1548 })
1549
1550 return jsonify({"nodes": list(nodes.values()), "edges": edges})
1551@app.route("/api/public/computers", methods=["GET"])
1552@require_grafana_token
1553def api_public_computers():
1554 tenant_id = int(request.args.get("tenant_id", "1"))
1555 env = request.args.get("env", "default")
1556
1557 conn = db()
1558 c = conn.cursor()
1559 c.execute("""
1560 SELECT name
1561 FROM computers
1562 WHERE tenant_id = ? AND env_name = ?
1563 ORDER BY name
1564 """, (tenant_id, env))
1565 names = [r["name"] for r in c.fetchall()]
1566 conn.close()
1567
1568 return jsonify({"computers": names})
1569
1570@app.route("/api/public/stats", methods=["GET"])
1571@require_grafana_token
1572def public_stats():
1573 # ⚠️ ако сакаш tenant по tenant, ќе мора да додадеш tenant_id како query param,
1574 # или да имаш 1 tenant во проект за сега.
1575 tenant_id = request.args.get("tenant_id", type=int)
1576 if not tenant_id:
1577 return jsonify({"error": "Missing tenant_id"}), 400
1578
1579 conn = db()
1580 c = conn.cursor()
1581
1582 c.execute("""
1583 SELECT COUNT(*) AS n
1584 FROM sysmon_events s
1585 JOIN computers c2 ON c2.id = s.computer_id
1586 WHERE s.timestamp > datetime('now','-24 hours')
1587 AND c2.tenant_id = ?
1588 """, (tenant_id,))
1589 total_sysmon = int(c.fetchone()["n"] or 0)
1590
1591 c.execute("""
1592 SELECT COUNT(*) AS n
1593 FROM network_connections n
1594 JOIN computers c2 ON c2.id = n.computer_id
1595 WHERE n.timestamp > datetime('now','-24 hours')
1596 AND c2.tenant_id = ?
1597 """, (tenant_id,))
1598 total_net = int(c.fetchone()["n"] or 0)
1599
1600 conn.close()
1601
1602 return jsonify({
1603 "total_sysmon_events": total_sysmon,
1604 "total_network_connections": total_net,
1605 "server_time": datetime.now().isoformat(),
1606 })
1607
1608
1609def ask_llm_sql_generator(question: str, tenant_id: int, env: str, computer_name: str | None):
1610 """
1611 Returns dict: { "queries": [...], "notes": "...", "scope": {...} }
1612 Only SELECT/WITH queries, no modifications.
1613 """
1614 if not client:
1615 return {
1616 "queries": [],
1617 "notes": "Немаш поставено OPENAI_API_KEY на серверот.",
1618 "scope": {"tenant_id": tenant_id, "env": env, "computer_name": computer_name},
1619 }
1620
1621 schema = """
1622SQLite schema (важни табели):
1623- computers(id, tenant_id, env_name, name, user, ip, os, first_seen, last_seen, sysmon_available)
1624- computer_history(computer_id, cpu_usage, ram_usage, disk_usage, network_sent_mb, network_recv_mb, timestamp)
1625- computer_processes_current(computer_id, pid, name, cpu_percent, memory_mb, username, cmdline, timestamp)
1626- computer_processes_history(computer_id, pid, name, cpu_percent, memory_mb, username, cmdline, timestamp)
1627- sysmon_events(computer_id, event_id, event_type, message, timestamp, details)
1628- network_connections(computer_id, pid, local_address, remote_address, status, process_name, timestamp)
1629
1630Правила:
1631- Врати САМО валиден JSON.
1632- JSON формат: {"queries":[...], "notes":"..."}.
1633- queries мора да бидат само SELECT или WITH (никако INSERT/UPDATE/DELETE/ALTER/DROP).
1634- максимум 3 queries.
1635- користи placeholders: :tenant_id, :env, :computer_name (ако е дадено).
1636- ако нема доволно инфо -> queries празно и notes објаснува.
1637""".strip()
1638
1639 user_scope = {
1640 "tenant_id": tenant_id,
1641 "env": env,
1642 "computer_name": computer_name,
1643 }
1644
1645 user_msg = f"""
1646Прашање од корисник: {question}
1647
1648Scope (вметни во WHERE):
1649- tenant_id = :tenant_id
1650- env = :env
1651- computer_name = :computer_name (ако не е null)
1652
1653Врати 1-3 SELECT/WITH queries за да се добијат потребните податоци.
1654""".strip()
1655
1656 completion = client.chat.completions.create(
1657 model="gpt-4.1-mini",
1658 messages=[
1659 {"role": "system", "content": schema},
1660 {"role": "user", "content": user_msg},
1661 ],
1662 temperature=0.1,
1663 )
1664
1665 raw = completion.choices[0].message.content or ""
1666
1667 # Safe parse JSON
1668 try:
1669 out = json.loads(raw)
1670 if not isinstance(out, dict):
1671 raise ValueError("Not an object")
1672 queries = out.get("queries") or []
1673 notes = out.get("notes") or ""
1674
1675 # hard safety filter: keep only SELECT/WITH
1676 safe_queries = []
1677 for q in queries:
1678 if not isinstance(q, str):
1679 continue
1680 qs = q.strip().lower()
1681 if qs.startswith("select") or qs.startswith("with"):
1682 # block obvious multi-statement
1683 if ";" in q.strip().rstrip(";"):
1684 continue
1685 safe_queries.append(q.strip())
1686
1687 return {"queries": safe_queries[:3], "notes": notes, "scope": user_scope}
1688
1689 except Exception:
1690 # fallback ако AI не врати JSON
1691 return {
1692 "queries": [],
1693 "notes": "AI не врати валиден JSON. Обиди се повторно (или намали прашање).",
1694 "scope": user_scope,
1695 "raw": raw[:1200],
1696 }
1697
1698
1699@app.route("/api/chat", methods=["POST"])
1700@require_user()
1701def api_chat():
1702 try:
1703 tenant_id = request.user["tenant_id"]
1704 env = request.headers.get("X-Env", "default") # важно!
1705 data = request.get_json(force=True, silent=True) or {}
1706
1707 question = (data.get("question") or "").strip()
1708 computer_name = data.get("computer_name") or None
1709
1710 if not question:
1711 return jsonify({"error": "No question provided"}), 400
1712
1713 out = ask_llm_sql_generator(
1714 question=question,
1715 tenant_id=tenant_id,
1716 env=env,
1717 computer_name=computer_name,
1718 )
1719
1720 # Тука не извршуваме SQL, само враќаме query за копирање.
1721 return jsonify({
1722 "ok": True,
1723 "queries": out.get("queries", []),
1724 "notes": out.get("notes", ""),
1725 "scope": out.get("scope", {}),
1726 # "raw": out.get("raw") # ако сакаш debug
1727 }), 200
1728
1729 except Exception as e:
1730 traceback.print_exc()
1731 return jsonify({"error": str(e)}), 500
1732
1733
1734
1735# ----------------------------
1736# Misc
1737# ----------------------------
1738@app.route("/ping")
1739def ping():
1740 return jsonify({
1741 "status": "alive",
1742 "server_time": datetime.now().isoformat(),
1743 "database": DB_FILE,
1744 "cors_origins": ALLOWED_ORIGINS,
1745 })
1746
1747
1748@app.route("/")
1749def root():
1750 return jsonify({
1751 "ok": True,
1752 "service": "netIntel server",
1753 "hint": "frontend should call /api/* endpoints",
1754 })
1755
1756
1757if __name__ == "__main__":
1758 init_db()
1759
1760 cleanup_thread = threading.Thread(target=cleanup_old_data, daemon=True)
1761 cleanup_thread.start()
1762
1763 print(" netIntel server running on 0.0.0.0:5555")
1764 # debug=False recommended; if you need debug, set True temporarily
1765 app.run(host="0.0.0.0", port=5555, debug=False, threaded=True)
Note: See TracBrowser for help on using the repository browser.