wiki:AdvancedApplicationDevelopment

Version 2 (modified by 231118, 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 и default environment
@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 директно го подобрува паралелизмот
Note: See TracWiki for help on using the wiki.