Josef Straßl 3 hete
szülő
commit
d383deaeb6
10 módosított fájl, 213 hozzáadás és 15 törlés
  1. 5 0
      .gitignore
  2. 13 0
      README.md
  3. 16 3
      app.py
  4. 26 0
      certs/README.md
  5. 21 0
      certs/client.crt
  6. 6 4
      config.example.toml
  7. 34 1
      static/app.js
  8. 2 2
      static/style.css
  9. 9 0
      templates/index.html
  10. 81 5
      worker.py

+ 5 - 0
.gitignore

@@ -1,7 +1,12 @@
 config.toml
 *.db
 *.db-journal
+*.log
+*.log.*
 venv/
 __pycache__/
 *.pyc
 .DS_Store
+# Private key material — never commit. The public cert (certs/client.crt) is safe to keep.
+certs/client.key
+certs/client.pem

+ 13 - 0
README.md

@@ -35,6 +35,19 @@ for manual false-positive submission while the mail is delivered. If this PPS de
 `release` relocates the message to the deleted folder (like the admin GUI does), the worker
 re-finds it there by its stable `guid` and moves it from there — no configuration needed.
 
+## Operations log (audit trail)
+
+Every message the background worker acts on is recorded in a dedicated, rotating log file
+(`worker_log`, default `worker.log` — 5 MB × 5 files). One line per message, e.g.:
+
+```
+2026-07-14 17:39:56 job=#12 action=delete result=ok src='Quarantine' dst='Deleted' localguid=6:6:1 guid=g-aaa from='sender@spam.example' rcpt='victim@corp.com' subject='You won a prize'
+```
+
+`result` is `ok` or `FAILED` (failures also carry `error=...`). Fields are `key=value` for
+easy `grep`. This is separate from the general application log (stdout, controlled by
+`log_level`), which covers requests, job lifecycle, and PPS API calls.
+
 ## Setup
 
 ```bash

+ 16 - 3
app.py

@@ -62,7 +62,12 @@ pps = PPSClient(
     client_key=_pps_cfg.get("client_key") or None,
 )
 
-queue = JobQueue(_app_cfg.get("db_path", "jobs.db"), pps, _q_cfg)
+queue = JobQueue(
+    _app_cfg.get("db_path", "jobs.db"),
+    pps,
+    _q_cfg,
+    ops_log_path=_app_cfg.get("worker_log", "worker.log"),
+)
 queue.start()
 
 app = Flask(__name__)
@@ -155,6 +160,8 @@ def api_messages():
         for r in records
         if r.get("localguid") not in hidden
     ]
+    # Ordering is handled client-side (the toolbar Sort control); the API returns
+    # newest-first by date.
     return jsonify({"folder": folder, "count": len(messages), "messages": messages})
 
 
@@ -174,7 +181,7 @@ def api_actions():
     body = request.get_json(silent=True) or {}
     action = body.get("action")
     folder = body.get("folder")
-    messages = body.get("messages", [])  # [{localguid, guid}, ...]
+    messages = body.get("messages", [])  # [{localguid, guid, subject, sender, recipient}, ...]
     targetfolder = body.get("targetfolder")
 
     if action not in VALID_ACTIONS:
@@ -187,7 +194,13 @@ def api_actions():
         return jsonify({"error": "move requires a targetfolder"}), 400
 
     items = [
-        {"localguid": m.get("localguid"), "guid": m.get("guid")}
+        {
+            "localguid": m.get("localguid"),
+            "guid": m.get("guid"),
+            "subject": m.get("subject"),
+            "sender": m.get("sender"),
+            "recipient": m.get("recipient"),
+        }
         for m in messages
         if m.get("localguid")
     ]

+ 26 - 0
certs/README.md

@@ -0,0 +1,26 @@
+# Client certificate (mutual TLS)
+
+A self-signed client certificate for the PoC, used when PPS/nginx requires a client
+cert (otherwise requests fail with `400 No required SSL certificate was sent`).
+
+| File          | What it is                        | Where it goes                              |
+|---------------|-----------------------------------|--------------------------------------------|
+| `client.crt`  | Public certificate                | **Upload to PPS** (System > Certificates > Client Certificates) |
+| `client.key`  | Private key (secret)              | Stays with this app only — gitignored       |
+| `client.pem`  | Combined cert + key               | Referenced by `config.toml` `client_cert`   |
+
+`config.toml` points `client_cert` at `certs/client.pem` (combined, so `client_key` is
+left empty). The app presents this cert on every PPS request.
+
+## Regenerate
+
+```bash
+openssl req -x509 -newkey rsa:2048 -keyout certs/client.key -out certs/client.crt \
+  -days 3650 -nodes -subj "/CN=pps-quarantine-poc-client/O=PPS Quarantine PoC" \
+  -addext "extendedKeyUsage=clientAuth" -addext "keyUsage=critical,digitalSignature"
+cat certs/client.crt certs/client.key > certs/client.pem
+chmod 600 certs/client.key certs/client.pem
+```
+
+Self-signed is fine for a PoC: PPS/nginx trusts the uploaded `client.crt` directly as its
+own CA. Not for production.

+ 21 - 0
certs/client.crt

@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDiDCCAnCgAwIBAgIUMVGCrta6eY7BnhzcP3Fz2PRpnhIwDQYJKoZIhvcNAQEL
+BQAwQTEiMCAGA1UEAwwZcHBzLXF1YXJhbnRpbmUtcG9jLWNsaWVudDEbMBkGA1UE
+CgwSUFBTIFF1YXJhbnRpbmUgUG9DMB4XDTI2MDcxNDEzNTI1OFoXDTM2MDcxMTEz
+NTI1OFowQTEiMCAGA1UEAwwZcHBzLXF1YXJhbnRpbmUtcG9jLWNsaWVudDEbMBkG
+A1UECgwSUFBTIFF1YXJhbnRpbmUgUG9DMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEA3Km7TuXWmlzFrqPZ9bIXm3EFfUTsrD2UKKyl/Ibb4vBIk8pp+qHX
+W5aU57G4OKwLmcpEXW6QnJi0vt0uAlW8RQu2X1/yIIvl3HeBDB11bccdTIn8yeyX
+kEQsRbuiPNm/EQsjbTnXm5WTfSSFrYdzBNd9VI3o/ea83TFSCEltM6NZ+ryvXE4Q
+KL6b2Hkm4U1GtAf2s4kl6+EUYloLSvIdPje53mgiPAXq8WRCMlKGaXspeBFTt0ti
+15IH2fJvk2vLlQNuMoqVb7lpaME2w2uj7JJBu4h5ewe0o98YlDyoBQMRfHMsjdu9
+nzMmD6jwyG5L0SV6m+61livxfFzDzQ/nwQIDAQABo3gwdjAdBgNVHQ4EFgQUklrq
+wHCnhLEWI5CmfwcXJdQBemcwHwYDVR0jBBgwFoAUklrqwHCnhLEWI5CmfwcXJdQB
+emcwDwYDVR0TAQH/BAUwAwEB/zATBgNVHSUEDDAKBggrBgEFBQcDAjAOBgNVHQ8B
+Af8EBAMCB4AwDQYJKoZIhvcNAQELBQADggEBAERap/QJlL3qLMMDpz0ymkyVGz65
+DtJZ4d5sOM2RpaZHNnacf86AK71mYVUdayQvCmcd/ONQ7FQ0DYj4bX1gBAE42KbQ
+kdIMqTPbmK90BWxeizN/jSFO3yU4TlSy4OylDW4Klft6a8TtVBi+7X5N8OVRyu9R
+NBEo4rA2UzcJHuALrrSkq/cuMNz0GdPggakzf5XQIhXzoFzPuls5dvTq8cEhFK0c
+GDR0Oi585olOFG/Ai0L5+JLucgBlztylfcBMUbpk10rAOv1FTl+5soNvDu5waKxR
+0H1GhgooGeWVBilZnccW6zvIoLiGv5nN8VTEOPTgry7YxuxI8hy2/dThgEw=
+-----END CERTIFICATE-----

+ 6 - 4
config.example.toml

@@ -13,10 +13,11 @@ verify_tls = false
 # Seconds to wait on a single PPS API call. Actions can be slow, so this is generous.
 timeout = 120
 # Mutual TLS (client certificate). Required if PPS/nginx returns
-# "400 No required SSL certificate was sent". Point client_cert at a PEM file.
-# Either a combined cert+key PEM (leave client_key empty), or a cert PEM here plus
-# the private key in client_key. Leave both empty if client-cert auth is not used.
-client_cert = ""
+# "400 No required SSL certificate was sent". A ready-made self-signed PoC cert lives
+# in certs/ (upload certs/client.crt to PPS: System > Certificates > Client Certificates).
+# Either a combined cert+key PEM (leave client_key empty), or a cert PEM plus its key.
+# Leave both empty if client-cert auth is not used.
+client_cert = "certs/client.pem"
 client_key = ""
 
 [quarantine]
@@ -41,6 +42,7 @@ listen = "127.0.0.1"
 port = 8080
 db_path = "jobs.db"                        # SQLite job queue file
 log_level = "INFO"                         # DEBUG for full request/response tracing
+worker_log = "worker.log"                  # audit log: one line per message acted on (rotated)
 
 [auth]
 # Static PoC login. Replaced by SAML/OIDC later.

+ 34 - 1
static/app.js

@@ -5,6 +5,8 @@ let CONFIG = null;
 let messages = [];              // current folder's messages
 const selected = new Set();     // selected localguids
 let jobPollTimer = null;
+let sortField = "subject";      // subject | date | from | rcpt
+let sortDir = 1;                // 1 = ascending, -1 = descending
 
 // --- element refs ----------------------------------------------------------
 const $ = (id) => document.getElementById(id);
@@ -48,6 +50,13 @@ function wireEvents() {
   limitInput.onchange = loadMessages;
   selectAll.onchange = () => toggleSelectAll(selectAll.checked);
 
+  $("sort-field").onchange = (e) => { sortField = e.target.value; renderRows(); };
+  $("sort-dir").onclick = () => {
+    sortDir = -sortDir;
+    $("sort-dir").textContent = sortDir === 1 ? "▲" : "▼";
+    renderRows();
+  };
+
   document.querySelectorAll(".btn.action[data-action]").forEach((btn) => {
     btn.onclick = () => runAction(btn.dataset.action);
   });
@@ -79,7 +88,25 @@ async function loadMessages() {
   }
 }
 
+function sortValue(m) {
+  if (sortField === "rcpt") return (m.rcpts || []).join(", ");
+  return m[sortField] || "";  // subject | date | from
+}
+
+function sortMessages() {
+  messages.sort((a, b) => {
+    const va = String(sortValue(a)).trim();
+    const vb = String(sortValue(b)).trim();
+    // Empty values always sort to the bottom, regardless of direction.
+    if (!va && vb) return 1;
+    if (va && !vb) return -1;
+    if (!va && !vb) return 0;
+    return va.localeCompare(vb, undefined, { sensitivity: "base", numeric: true }) * sortDir;
+  });
+}
+
 function renderRows() {
+  sortMessages();
   clearSelection();
   msgBody.innerHTML = "";
   emptyNote.classList.toggle("hidden", messages.length > 0);
@@ -154,7 +181,13 @@ async function runAction(action, targetfolder) {
   const payload = {
     action,
     folder: folderSelect.value,
-    messages: chosen.map((m) => ({ localguid: m.localguid, guid: m.guid })),
+    messages: chosen.map((m) => ({
+      localguid: m.localguid,
+      guid: m.guid,
+      subject: m.subject,
+      sender: m.from,
+      recipient: (m.rcpts || []).join(", "),
+    })),
   };
   if (action === "move") payload.targetfolder = targetfolder;
 

+ 2 - 2
static/style.css

@@ -121,11 +121,11 @@ body {
 .overlay {
   position: fixed; inset: 53px 0 0 0; z-index: 40;
   background: rgba(15, 23, 42, 0.45);
-  display: flex; justify-content: center; align-items: flex-start;
+  display: flex; justify-content: center; align-items: center;
   padding: 24px;
 }
 .overlay-panel {
-  background: var(--panel); width: min(900px, 100%); max-height: 100%;
+  background: var(--panel); width: 80vw; height: 80vh; max-width: 100%; max-height: 100%;
   border-radius: 12px; box-shadow: var(--shadow);
   display: flex; flex-direction: column; overflow: hidden;
 }

+ 9 - 0
templates/index.html

@@ -16,6 +16,15 @@
       <label class="field">Limit
         <input id="limit-input" type="number" min="1" max="1000" step="1">
       </label>
+      <label class="field">Sort
+        <select id="sort-field">
+          <option value="subject">Subject</option>
+          <option value="date">Date</option>
+          <option value="from">Sender</option>
+          <option value="rcpt">Recipient</option>
+        </select>
+      </label>
+      <button id="sort-dir" class="btn" title="Toggle ascending / descending">▲</button>
       <button id="refresh-btn" class="btn">Refresh</button>
       <span class="spacer"></span>
       <button data-action="release" class="btn action" disabled>Release</button>

+ 81 - 5
worker.py

@@ -17,11 +17,28 @@ import threading
 import time
 import traceback
 from datetime import datetime, timezone
+from logging.handlers import RotatingFileHandler
 
 from pps_client import PPSClient, PPSError
 
 log = logging.getLogger("pps.worker")
 
+# Dedicated operations/audit log — one line per message acted on. Written to its own
+# file (see _setup_ops_log) and NOT propagated to the root/stdout logger.
+ops_log = logging.getLogger("pps.ops")
+ops_log.propagate = False
+
+
+def _setup_ops_log(path: str) -> None:
+    """Attach a rotating file handler to the ops logger (idempotent)."""
+    for h in ops_log.handlers:
+        if isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", "").endswith(path.split("/")[-1]):
+            return  # already configured
+    handler = RotatingFileHandler(path, maxBytes=5_000_000, backupCount=5, encoding="utf-8")
+    handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
+    ops_log.addHandler(handler)
+    ops_log.setLevel(logging.INFO)
+
 # Actions understood from the frontend.
 VALID_ACTIONS = {"release", "report_release", "delete", "move"}
 
@@ -33,7 +50,8 @@ def _now() -> str:
 
 
 class JobQueue:
-    def __init__(self, db_path: str, pps: PPSClient, qconfig: dict):
+    def __init__(self, db_path: str, pps: PPSClient, qconfig: dict,
+                 ops_log_path: str = "worker.log"):
         self.pps = pps
         self.cfg = qconfig
         self.chunk_size = int(qconfig.get("chunk_size", 25))
@@ -43,6 +61,8 @@ class JobQueue:
         self._init_db()
         self._recover()
         self._stop = threading.Event()
+        _setup_ops_log(ops_log_path)
+        self.ops_log_path = ops_log_path
 
     # -------------------------------------------------------------- schema/setup
 
@@ -67,12 +87,20 @@ class JobQueue:
                     localguid TEXT NOT NULL,
                     guid      TEXT,
                     folder    TEXT NOT NULL,
+                    subject   TEXT,
+                    sender    TEXT,
+                    recipient TEXT,
                     FOREIGN KEY (job_id) REFERENCES jobs(id)
                 );
                 CREATE INDEX IF NOT EXISTS idx_job_items_folder ON job_items(folder);
                 CREATE INDEX IF NOT EXISTS idx_job_items_job ON job_items(job_id);
                 """
             )
+            # Migrate older DBs that lack the display columns.
+            existing = {r["name"] for r in self._conn.execute("PRAGMA table_info(job_items)")}
+            for col in ("subject", "sender", "recipient"):
+                if col not in existing:
+                    self._conn.execute(f"ALTER TABLE job_items ADD COLUMN {col} TEXT")
             self._conn.commit()
 
     def _recover(self) -> None:
@@ -89,7 +117,8 @@ class JobQueue:
     # -------------------------------------------------------------- public API
 
     def enqueue(self, action: str, folder: str, items: list[dict], extra: dict) -> int:
-        """Insert a job. `items` is a list of {'localguid':..., 'guid':...} dicts."""
+        """Insert a job. `items` is a list of dicts with keys:
+        localguid (required), guid, subject, sender, recipient (all optional)."""
         if action not in VALID_ACTIONS:
             raise ValueError(f"unknown action: {action}")
         now = _now()
@@ -101,10 +130,16 @@ class JobQueue:
             )
             job_id = cur.lastrowid
             self._conn.executemany(
-                "INSERT INTO job_items (job_id, localguid, guid, folder) VALUES (?,?,?,?)",
-                [(job_id, it["localguid"], it.get("guid"), folder) for it in items],
+                "INSERT INTO job_items (job_id, localguid, guid, folder, subject, sender, recipient)"
+                " VALUES (?,?,?,?,?,?,?)",
+                [
+                    (job_id, it["localguid"], it.get("guid"), folder,
+                     it.get("subject"), it.get("sender"), it.get("recipient"))
+                    for it in items
+                ],
             )
             self._conn.commit()
+        log.info("enqueued job #%d: action=%s folder=%r items=%d", job_id, action, folder, len(items))
         return job_id
 
     def acted_localguids(self, folder: str) -> set[str]:
@@ -177,6 +212,7 @@ class JobQueue:
             "job #%d start: action=%s folder=%r total=%d", job_id, action, folder, len(items)
         )
 
+        dst = self._destination(action, extra)
         errors: list[str] = []
         processed = 0
         for chunk in _chunks(items, self.chunk_size):
@@ -184,12 +220,15 @@ class JobQueue:
                 self._run_action(action, folder, chunk, extra)
                 processed += len(chunk)
                 self._set_processed(job_id, processed)
+                self._log_ops(job_id, action, folder, dst, chunk, "ok", None)
             except PPSError as exc:
                 log.error("job #%d chunk failed: %s", job_id, exc)
                 errors.append(f"chunk failed: {exc}")
+                self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
             except Exception as exc:  # noqa: BLE001
                 log.exception("job #%d chunk raised", job_id)
                 errors.append(f"chunk failed: {exc}")
+                self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
 
         if errors:
             log.error("job #%d FAILED: %d/%d processed, %d chunk error(s)",
@@ -199,6 +238,36 @@ class JobQueue:
             log.info("job #%d done: %d/%d processed", job_id, processed, len(items))
             self._mark_done(job_id)
 
+    def _destination(self, action: str, extra: dict) -> str | None:
+        """The folder messages end up in, for the ops log."""
+        if action == "move":
+            return extra.get("targetfolder")
+        if action == "report_release":
+            return self.cfg.get("report_release_folder")
+        if action in ("release", "delete"):
+            return self.cfg.get("deleted_folder")
+        return None
+
+    def _log_ops(self, job_id: int, action: str, src: str, dst: str | None,
+                 chunk: list[dict], result: str, error: str | None) -> None:
+        """Write one audit line per message in the chunk to the operations log."""
+        for it in chunk:
+            fields = [
+                f"job=#{job_id}",
+                f"action={action}",
+                f"result={result}",
+                f"src={src!r}",
+                f"dst={dst!r}",
+                f"localguid={it.get('localguid')}",
+                f"guid={it.get('guid')}",
+                f"from={_clip(it.get('sender'))!r}",
+                f"rcpt={_clip(it.get('recipient'))!r}",
+                f"subject={_clip(it.get('subject'))!r}",
+            ]
+            if error:
+                fields.append(f"error={_clip(error, 300)!r}")
+            ops_log.info(" ".join(fields))
+
     # -------------------------------------------------------------- action logic
 
     def _run_action(self, action: str, folder: str, chunk: list[dict], extra: dict) -> None:
@@ -272,7 +341,8 @@ class JobQueue:
     def _items_for(self, job_id: int) -> list[dict]:
         with self._lock:
             rows = self._conn.execute(
-                "SELECT localguid, guid FROM job_items WHERE job_id=?", (job_id,)
+                "SELECT localguid, guid, subject, sender, recipient"
+                " FROM job_items WHERE job_id=?", (job_id,)
             ).fetchall()
         return [dict(r) for r in rows]
 
@@ -304,3 +374,9 @@ class JobQueue:
 def _chunks(seq: list, size: int):
     for i in range(0, len(seq), size):
         yield seq[i : i + size]
+
+
+def _clip(value, length: int = 120) -> str:
+    """Collapse newlines and truncate a value for a single-line log field."""
+    s = " ".join(str(value or "").split())
+    return s[:length] + ("…" if len(s) > length else "")