| Version 2 (modified by , 4 days ago) ( diff ) |
|---|
Advanced Application Development
Transactions
Трансакциите во системот се имплементирани на апликациско ниво преку SQLite конекции. Секоја операција која вклучува повеќе табели се извршува атомарно — доколку некоја од операциите не успее, се повикува conn.rollback() и ниедна промена не се зачувува.
Прием на податоци од агент (/receive)
Најкритичната трансакција во системот се извршува при прием на податоци од агентите преку POST /receive. Во рамките на една конекција (v04) се извршуваат следните операции:
- Пронаоѓање или креирање на компјутер во
computers - Ажурирање на
last_seen,ip,os,user,sysmon_available - Внесување на системски метрики во
computer_history(историја) и освежување на тековниот snapshot воcomputer_history_current - Бришење и повторно внесување на тековните процеси во
computer_processes_current - Условно внесување на историски процеси во
computer_processes_history(акоsave_process_historyе овозможено воenv_settings) - Внесување на Sysmon настани во
sysmon_events - Освежување на тековните мрежни конекции во
network_connections_currentи условно воnetwork_connections_history(акоsave_network_historyе овозможено)
Сите операции се завршуваат со conn.commit(). Доколку се случи грешка во кој било чекор, се извршува conn.rollback() и ниедна промена не е трајно зачувана:
@app.route("/receive", methods=["POST"])
def receive_data():
conn = None
try:
data = request.get_json(force=True, silent=True) or {}
env_name, tenant_id = get_env_from_token(request)
conn = db()
c = conn.cursor()
# Чекор 1: Пронаоѓање или креирање на компјутер
c.execute(
"SELECT id FROM computers WHERE tenant_id=? AND name=? LIMIT 1",
(tenant_id, computer_name),
)
row = c.fetchone()
if row:
computer_id = row["id"]
c.execute("""
UPDATE computers
SET user=?, ip=?, os=?, last_seen=?, sysmon_available=?, env_name=?
WHERE id=? AND tenant_id=?
""", (info.get("user"), info.get("ip_address"), info.get("os"),
now_iso, int(info.get("is_sysmon_available", 0) or 0),
env_name, computer_id, tenant_id))
else:
c.execute("""
INSERT INTO computers(tenant_id, env_name, name, user, ip, os,
first_seen, last_seen, sysmon_available)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (tenant_id, env_name, computer_name, info.get("user"),
info.get("ip_address"), info.get("os"), now_iso, now_iso,
int(info.get("is_sysmon_available", 0) or 0)))
computer_id = c.lastrowid
# Чекор 2: Системски метрики — историја + тековен snapshot [v04]
c.execute("""
INSERT INTO computer_history(
computer_id, cpu_usage, ram_usage, disk_usage,
network_sent_mb, network_recv_mb, timestamp)
VALUES(?, ?, ?, ?, ?, ?, ?)
""", (computer_id, float(info.get("cpu_usage") or 0),
float(info.get("ram_usage") or 0), float(info.get("disk_usage") or 0),
float(info.get("network_sent_mb") or 0),
float(info.get("network_recv_mb") or 0),
info.get("timestamp") or now_iso))
c.execute("DELETE FROM computer_history_current WHERE computer_id=?", (computer_id,))
c.execute("""
INSERT INTO computer_history_current(
computer_id, cpu_usage, ram_usage, disk_usage,
network_sent_mb, network_recv_mb, timestamp)
VALUES(?, ?, ?, ?, ?, ?, ?)
""", (computer_id, float(info.get("cpu_usage") or 0),
float(info.get("ram_usage") or 0), float(info.get("disk_usage") or 0),
float(info.get("network_sent_mb") or 0),
float(info.get("network_recv_mb") or 0),
info.get("timestamp") or now_iso))
# Чекор 3: Тековни процеси (бриши и внеси повторно)
c.execute("DELETE FROM computer_processes_current WHERE computer_id=?", (computer_id,))
for proc in (data.get("processes") or []):
c.execute("""
INSERT INTO computer_processes_current(
computer_id, pid, name, cpu_percent,
memory_mb, username, cmdline, timestamp)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
""", (computer_id, proc.get("pid"), proc.get("name"),
float(proc.get("cpu_percent") or 0),
float(proc.get("memory_mb") or 0),
proc.get("user") or proc.get("username"),
proc.get("cmdline"), info.get("timestamp") or now_iso))
# Чекор 4: Историски процеси (само ако е овозможено во env_settings)
if is_process_history_enabled(tenant_id, env_name):
for proc in (data.get("processes") or []):
c.execute("""
INSERT INTO computer_processes_history(
computer_id, pid, name, cpu_percent,
memory_mb, username, cmdline, timestamp)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
""", (computer_id, proc.get("pid"), proc.get("name"),
float(proc.get("cpu_percent") or 0),
float(proc.get("memory_mb") or 0),
proc.get("user") or proc.get("username"),
proc.get("cmdline"), info.get("timestamp") or now_iso))
# Чекор 5: Sysmon настани
for ev in (security_data.get("sysmon_events") or []):
c.execute("""
INSERT INTO sysmon_events(
computer_id, event_id, event_type, message, timestamp, details)
VALUES(?, ?, ?, ?, ?, ?)
""", (computer_id, ev.get("event_id"), ev.get("event_type", "Unknown"),
ev.get("message", ""),
ev.get("timestamp") or (info.get("timestamp") or now_iso),
json.dumps(ev, ensure_ascii=False)))
# Чекор 6: Мрежни конекции — тековен snapshot + условна историја [v04]
c.execute("DELETE FROM network_connections_current WHERE computer_id=?", (computer_id,))
net_hist = is_network_history_enabled(tenant_id, env_name)
for nc in (security_data.get("network_connections") or []):
vals = (computer_id, nc.get("pid"), nc.get("local_address"),
nc.get("remote_address"), nc.get("status"),
nc.get("process_name"), info.get("timestamp") or now_iso)
c.execute("""
INSERT INTO network_connections_current(
computer_id, pid, local_address, remote_address,
status, process_name, timestamp)
VALUES(?, ?, ?, ?, ?, ?, ?)
""", vals)
if net_hist:
c.execute("""
INSERT INTO network_connections_history(
computer_id, pid, local_address, remote_address,
status, process_name, timestamp)
VALUES(?, ?, ?, ?, ?, ?, ?)
""", vals)
# Сите операции успешни — зачувај
conn.commit()
return jsonify({"ok": True, "computer_id": computer_id})
except Exception as e:
if conn:
conn.rollback() # Врати ги сите промени
return jsonify({"error": str(e)}), 500
finally:
if conn:
conn.close()
Клучни карактеристики на оваа трансакција:
conn.rollback()се повикува воexceptблокот — ниедна табела нема делумни податоциconn.close()е воfinallyблокот — конекцијата секогаш се затвора- Условното снимање историја (процеси/мрежа) е дел од истата трансакција и се контролира по околина преку знамињата во
env_settings
Google автентикација и автоматско креирање на tenant (/api/auth/google)
При прва најава на нов корисник, во рамките на една конекција атомарно се извршуваат:
- Внесување или ажурирање на корисникот во
users - Проверка за постојно членство во
memberships - Доколку нов корисник: креирање на
tenant,membershipи defaultenvironment
@app.route("/api/auth/google", methods=["POST"])
def auth_google():
try:
idinfo = id_token.verify_oauth2_token(credential, grequests.Request(), GOOGLE_CLIENT_ID)
email = idinfo.get("email")
conn = db()
c = conn.cursor()
# Чекор 1: Upsert корисник
c.execute("SELECT id FROM users WHERE email=?", (email,))
row = c.fetchone()
if row:
user_id = row["id"]
c.execute("UPDATE users SET name=?, picture=? WHERE id=?", (name, picture, user_id))
else:
c.execute(
"INSERT INTO users(email, name, picture, created_at) VALUES(?,?,?,datetime('now'))",
(email, name, picture))
user_id = c.lastrowid
# Чекор 2: Проверка за membership
c.execute("SELECT tenant_id, role FROM memberships WHERE user_id=? LIMIT 1", (user_id,))
m = c.fetchone()
if not m:
# Чекор 3: Нов корисник — креирај tenant, membership и default environment атомарно
c.execute(
"INSERT INTO tenants(name, owner_email, created_at) VALUES(?,?,datetime('now'))",
(tenant_name, email))
tenant_id = c.lastrowid
c.execute(
"INSERT INTO memberships(user_id, tenant_id, role, created_at) VALUES(?,?,'admin',datetime('now'))",
(user_id, tenant_id))
c.execute(
"INSERT OR IGNORE INTO environments(tenant_id, name, created_at) VALUES(?,'default',datetime('now'))",
(tenant_id,))
else:
tenant_id = m["tenant_id"]
role = m["role"] or "admin"
conn.commit()
conn.close()
session = make_jwt({"uid": user_id, "email": email, "role": role, "tenant_id": tenant_id})
...
except Exception as e:
return jsonify({"error": "Invalid Google token", "details": str(e)}), 401
Доколку некоја од INSERT операциите не успее, целата автентикација се откажува и корисникот добива грешка.
Генерирање на environment токен (/api/admin/tokens)
При генерирање на нов токен за агент, системот прво верификува дека environment постои, а потоа внесува токен. Токенот се генерира со secrets.token_urlsafe(32) и е валиден 90 дена:
@app.route("/api/admin/tokens", methods=["POST"])
@require_tenant_admin()
def admin_generate_token():
tenant_id = request.user["tenant_id"]
env = (data.get("env") or "").strip()
conn = db()
c = conn.cursor()
# Верификација дека environment постои
c.execute("SELECT 1 FROM environments WHERE tenant_id=? AND name=? LIMIT 1", (tenant_id, env))
if not c.fetchone():
conn.close()
return jsonify({"error": "Environment not found"}), 404
# Генерирање и зачувување на токенот
token = secrets.token_urlsafe(32)
c.execute("""
INSERT INTO env_tokens(tenant_id, env_name, token, created_at, expires_at)
VALUES(?, ?, ?, datetime('now'), datetime('now', '+90 days'))
""", (tenant_id, env, token))
conn.commit()
conn.close()
return jsonify({"ok": True, "env": env, "token": token})
Токенот се проверува при секој повик на агентот преку get_env_from_token():
def get_env_from_token(req):
tok = req.headers.get("X-Env-Token", "")
if not tok:
return (None, None)
conn = db()
c = conn.cursor()
c.execute("""
SELECT env_name, tenant_id
FROM env_tokens
WHERE token = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
LIMIT 1
""", (tok,))
row = c.fetchone()
conn.close()
if not row:
return (None, None)
return (row["env_name"], row["tenant_id"])
Ажурирање на поставки за околина (/api/admin/env-settings/<env_name>)
Поставките за околина се запишуваат атомарно преку upsert (ON CONFLICT DO UPDATE), со цел да се избегне race condition. Во v04 се поддржани трите знамиња (save_process_history, save_metrics_history, save_network_history); незададените во барањето ги задржуваат тековните вредности:
@app.route("/api/admin/env-settings/<env_name>", methods=["POST"])
@require_tenant_admin()
def admin_set_env_settings(env_name):
tenant_id = request.user["tenant_id"]
data = request.get_json(force=True, silent=True) or {}
conn = db()
c = conn.cursor()
# прочитај ги тековните вредности за да ги задржиш незададените знамиња
c.execute("""
SELECT save_process_history, save_metrics_history, save_network_history
FROM env_settings WHERE tenant_id=? AND env_name=? LIMIT 1
""", (tenant_id, env_name))
cur = c.fetchone()
def pick(key):
if data.get(key) is not None:
return 1 if bool(data.get(key)) else 0
return int(cur[key]) if cur else 0
p = pick("save_process_history")
m = pick("save_metrics_history")
n = pick("save_network_history")
c.execute("""
INSERT INTO env_settings(tenant_id, env_name, save_process_history,
save_metrics_history, save_network_history,
created_at, updated_at)
VALUES(?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(tenant_id, env_name) DO UPDATE SET
save_process_history=excluded.save_process_history,
save_metrics_history=excluded.save_metrics_history,
save_network_history=excluded.save_network_history,
updated_at=datetime('now')
""", (tenant_id, env_name, p, m, n))
conn.commit()
conn.close()
return jsonify({"ok": True, "env": env_name,
"save_process_history": bool(p),
"save_metrics_history": bool(m),
"save_network_history": bool(n)})
Database Connection Pooling
Во моменталната имплементација (прототип) системот користи SQLite база преку sqlite3 библиотеката. Конекцијата се воспоставува преку помошната функција db():
DB_FILE = os.environ.get("DB_FILE", "lan_logs_sysmon.db")
def db():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
conn.row_factory = sqlite3.Row овозможува пристап до колоните по име (на пр. row["email"]). При секое барање се отвора нова конекција која се затвора по завршување — секогаш во finally блокот:
conn = db()
try:
c = conn.cursor()
c.execute("SELECT ...")
result = c.fetchall()
conn.commit()
finally:
conn.close()
Поради природата на SQLite (file-based база со writer lock), Connection Pooling не е применливо во тековната прототип верзија.
Забелешка: официјалната проектна база (од претходната фаза) е PostgreSQL. Прототипот користи SQLite со истата структура, а миграцијата кон PostgreSQL со pool е опишана подолу.
Позадинска задача за автоматско чистење (cleanup_old_data)
Системот стартува посебна daemon нишка која на секои 3600 секунди (1 час) автоматски ги брише записите постари од 30 дена од историските табели. Нишката работи со своја независна конекција:
def cleanup_old_data():
"""Deletes old rows (30 days) every hour."""
while True:
time.sleep(3600)
try:
conn = db()
c = conn.cursor()
cutoff = (datetime.now() - timedelta(days=30)).isoformat()
c.execute("DELETE FROM computer_history WHERE timestamp < ?", (cutoff,))
c.execute("DELETE FROM sysmon_events WHERE timestamp < ?", (cutoff,))
c.execute("DELETE FROM network_connections WHERE timestamp < ?", (cutoff,))
c.execute("DELETE FROM computer_processes_history WHERE timestamp < ?", (cutoff,))
# [v04] нова history табела
c.execute("DELETE FROM network_connections_history WHERE timestamp < ?", (cutoff,))
conn.commit()
conn.close()
print(f"[Cleanup] Deleted entries older than {cutoff}")
except Exception as e:
print("[Cleanup] Error:", e)
# Стартување при иницијализација на серверот
cleanup_thread = threading.Thread(target=cleanup_old_data, daemon=True)
cleanup_thread.start()
Нишката е daemon=True — автоматски се гасне кога се гасне главниот Flask процес.
(Тековните snapshot табели *_current не се чистат бидејќи се препишуваат при секое известување.)
Миграција кон PostgreSQL со Connection Pooling
Официјалната проектна база е веќе PostgreSQL. При поврзување на апликацискиот слој кон неа, db() функцијата би се заменила со SQLAlchemy engine со connection pool:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from contextlib import contextmanager
DATABASE_URL = "postgresql://app_user:password@localhost:5432/netintel_db"
engine = create_engine(
DATABASE_URL,
pool_size=10, # Број на постојни конекции во pool-от
max_overflow=20, # Дополнителни конекции при врв на оптоварување
pool_timeout=30, # Максимално чекање за слободна конекција (секунди)
pool_recycle=1800, # Освежување на конекции на секои 30 минути
pool_pre_ping=True # Проверка дали конекцијата е активна пред употреба
)
SessionLocal = sessionmaker(bind=engine)
@contextmanager
def db():
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close() # Конекцијата се враќа во pool-от, не се затвора физички
Пример на употреба во /receive endpoint со новиот pool:
@app.route("/receive", methods=["POST"])
def receive_data():
with db() as session:
result = session.execute(
text("SELECT id FROM computers WHERE tenant_id=:tid AND name=:name LIMIT 1"),
{"tid": tenant_id, "name": computer_name}
).fetchone()
# Конекцијата автоматски се враќа во pool-от по излегување од with блокот
Предности на оваа миграција:
- Повторно користење на активни конекции наместо отворање нова при секое барање
- Намалување на латенцијата — воспоставувањето нова PostgreSQL конекција трае ~50-100ms
pool_pre_ping=Trueавтоматски ги отстранува прекинатите конекции од pool-от- Подобри перформанси при голем број истовремени агенти кои праќаат податоци паралелно
threaded=Trueе веќе поставено на Flask серверот (app.run(..., threaded=True)), па pooling директно го подобрува паралелизмот
