diff --git a/saikyo-av-web/assets/web/app.js b/saikyo-av-web/assets/web/app.js new file mode 100644 index 0000000..52c39c1 --- /dev/null +++ b/saikyo-av-web/assets/web/app.js @@ -0,0 +1,234 @@ +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +function badgeClass(sev) { + const s = (sev || "").toLowerCase(); + if (["ok", "info", "low"].includes(s)) return "good"; + if (["medium", "warn", "warning"].includes(s)) return "warn"; + if (["high", "critical", "bad"].includes(s)) return "bad"; + return ""; +} + +function fmtTime(ts) { + if (!ts) return "—"; + try { + if (typeof ts === "number") return new Date(ts * 1000).toLocaleString(); + return new Date(ts).toLocaleString(); + } catch { + return String(ts); + } +} + +function toast(msg) { + const t = $("#toast"); + t.textContent = msg; + t.classList.add("show"); + setTimeout(() => t.classList.remove("show"), 3500); +} + +async function api(path, opts) { + const res = await fetch(path, opts); + const ct = res.headers.get("content-type") || ""; + const data = ct.includes("application/json") ? await res.json() : await res.text(); + return { ok: res.ok, status: res.status, data }; +} + +let state = { + view: "dashboard", + reports: [], + selected: null, +}; + +function setView(v) { + state.view = v; + $$(".nav-item").forEach((a) => a.classList.toggle("active", a.dataset.view === v)); + $("#view-dashboard").classList.toggle("hidden", v !== "dashboard"); + $("#view-reports").classList.toggle("hidden", v !== "reports"); + $("#view-settings").classList.toggle("hidden", v !== "settings"); + + if (v === "dashboard") { + $("#page-title").textContent = "Dashboard"; + $("#page-sub").textContent = "Обзор состояния и последние отчёты"; + } + if (v === "reports") { + $("#page-title").textContent = "Reports"; + $("#page-sub").textContent = "Просмотр отчётов и применение действий (по согласию)"; + } + if (v === "settings") { + $("#page-title").textContent = "Settings"; + $("#page-sub").textContent = "Локальные параметры интерфейса"; + } +} + +function renderTable(el, rows, { limit = null } = {}) { + const r = limit ? rows.slice(0, limit) : rows; + const header = ` +
+
Summary
+
Severity
+
Updated
+
+
+ `; + + const body = r + .map((it) => { + const cls = badgeClass(it.severity); + return ` +
+
${escapeHtml(it.summary || "(no summary)")}
+
${escapeHtml(it.severity || "unknown")}
+
${escapeHtml(fmtTime(it.created_utc || it.mtime))}
+
open
+
+ `; + }) + .join(""); + + el.innerHTML = header + body; + el.querySelectorAll(".row.clickable").forEach((row) => { + row.addEventListener("click", () => openReport(row.dataset.id)); + }); +} + +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function refresh() { + const h = await api("/api/health"); + if (h.ok) { + $("#health-pill").textContent = "UI: OK"; + $("#status-value").textContent = "Protected"; + $("#status-hint").textContent = "local UI service running"; + } else { + $("#health-pill").textContent = "UI: error"; + $("#status-value").textContent = "Degraded"; + $("#status-hint").textContent = "health check failed"; + } + + const r = await api("/api/reports"); + if (r.ok) { + state.reports = r.data.reports || []; + $("#reports-count").textContent = String(state.reports.length); + renderTable($("#latest-table"), state.reports, { limit: 6 }); + renderTable($("#reports-table"), state.reports); + } else { + toast("Failed to load reports"); + } +} + +async function openReport(reportId) { + const p = await api(`/api/reports/${encodeURIComponent(reportId)}`); + if (!p.ok) { + toast("Report not found"); + return; + } + const report = p.data.report; + state.selected = { id: reportId, report }; + + $("#report-details").classList.remove("hidden"); + $("#details-title").textContent = `Report: ${reportId}`; + $("#details-sub").textContent = "Read-only details"; + $("#details-summary").textContent = report.summary || "—"; + + const sev = report.severity || "unknown"; + const cls = badgeClass(sev); + const badge = $("#details-severity"); + badge.textContent = sev; + badge.className = `badge ${cls}`; + + $("#details-created").textContent = fmtTime(report.created_utc || "—"); + $("#raw").textContent = JSON.stringify(report, null, 2); + + const fixesEl = $("#fixes"); + fixesEl.classList.add("fixes"); + const fixes = report.suggested_fixes || []; + if (!fixes.length) { + fixesEl.innerHTML = `
No suggested fixes
`; + } else { + fixesEl.innerHTML = fixes + .map((f) => { + return ` +
+
${escapeHtml(f.title || f.id || "Fix")}
+
${escapeHtml(f.description || "")}
+
+ + consent +
+
+ `; + }) + .join(""); + + fixesEl.querySelectorAll("button[data-fix]").forEach((b) => { + b.addEventListener("click", () => applyFix(b.dataset.fix)); + }); + } +} + +async function applyFix(fixId) { + if (!state.selected) return; + const reportId = state.selected.id; + + const confirm1 = window.confirm( + `Apply fix '${fixId}' for report '${reportId}'?\n\nThis action requires explicit operator confirmation.` + ); + if (!confirm1) return; + + const res = await api("/api/apply-fix", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ report_id: reportId, fix_id: fixId, confirm: true }), + }); + + if (res.ok) { + toast("Fix applied"); + } else { + if (res.status === 501) { + toast("Fix runner not implemented yet (MVP UI only)"); + return; + } + if (res.status === 409) { + toast("Confirmation required"); + return; + } + toast("Failed to apply fix"); + } +} + +function bindEvents() { + $$(".nav-item").forEach((a) => { + a.addEventListener("click", (e) => { + e.preventDefault(); + setView(a.dataset.view); + }); + }); + + $("#refresh-btn").addEventListener("click", () => refresh()); + + $("#close-details").addEventListener("click", () => { + $("#report-details").classList.add("hidden"); + state.selected = null; + }); + + $("#filter").addEventListener("input", () => { + const q = $("#filter").value.trim().toLowerCase(); + const rows = !q + ? state.reports + : state.reports.filter((r) => + String(r.summary || "").toLowerCase().includes(q) || String(r.id || "").toLowerCase().includes(q) + ); + renderTable($("#reports-table"), rows); + }); +} + +bindEvents(); +setView("dashboard"); +refresh(); diff --git a/saikyo-av-web/assets/web/index.html b/saikyo-av-web/assets/web/index.html new file mode 100644 index 0000000..192a750 --- /dev/null +++ b/saikyo-av-web/assets/web/index.html @@ -0,0 +1,182 @@ + + + + + + Saikyo Antivirus + + + +
+ + +
+
+
+

Dashboard

+
Обзор состояния и последние отчёты
+
+
+ +
+
+ +
+
+
+
+
Status
+
+
waiting for health check
+
+
+
Reports
+
+
available locally
+
+
+
Mode
+
Consent
+
fixes require confirmation
+
+
+ +
+
+
+
Latest reports
+
Click a report to open details
+
+
+
+
+
+ + + + +
+
+
+ +
+ + + + diff --git a/saikyo-av-web/assets/web/styles.css b/saikyo-av-web/assets/web/styles.css new file mode 100644 index 0000000..d637e2f --- /dev/null +++ b/saikyo-av-web/assets/web/styles.css @@ -0,0 +1,281 @@ +:root{ + --bg:#0b1220; + --panel:#0f1a2e; + --panel2:#0c1628; + --text:#e7eefc; + --muted:#9bb0d1; + --line:rgba(255,255,255,.08); + --good:#2dd4bf; + --warn:#fbbf24; + --bad:#fb7185; + --accent:#60a5fa; + --shadow: 0 20px 60px rgba(0,0,0,.35); + --radius:18px; + --radius2:14px; + --font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; +} + +*{box-sizing:border-box} +html,body{height:100%} +body{ + margin:0; + font-family:var(--font); + color:var(--text); + background: radial-gradient(1200px 800px at 15% 5%, rgba(96,165,250,.25), transparent 60%), + radial-gradient(900px 700px at 80% 20%, rgba(45,212,191,.18), transparent 55%), + radial-gradient(800px 600px at 60% 85%, rgba(251,113,133,.14), transparent 55%), + var(--bg); +} + +a{color:inherit} + +.app{ + height:100%; + display:grid; + grid-template-columns: 320px 1fr; +} + +.sidebar{ + padding:24px; + border-right:1px solid var(--line); + background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 30%), + rgba(15,26,46,.55); + backdrop-filter: blur(10px); +} + +.brand{ + display:flex; + gap:14px; + align-items:center; + padding:14px; + border:1px solid var(--line); + border-radius: var(--radius); + background: rgba(12,22,40,.55); + box-shadow: var(--shadow); +} +.logo{ + width:44px; + height:44px; + border-radius:14px; + display:grid; + place-items:center; + font-weight:800; + background: linear-gradient(135deg, rgba(96,165,250,.8), rgba(45,212,191,.75)); + color:#0b1220; +} +.brand-title{font-size:16px;font-weight:800;letter-spacing:.2px} +.brand-sub{font-size:12px;color:var(--muted)} + +.nav{margin-top:18px;display:flex;flex-direction:column;gap:8px} +.nav-item{ + text-decoration:none; + padding:12px 14px; + border-radius: 14px; + border:1px solid transparent; + color:var(--muted); + background: rgba(12,22,40,.20); +} +.nav-item:hover{border-color:var(--line);color:var(--text)} +.nav-item.active{color:var(--text);border-color:rgba(96,165,250,.45);background: rgba(96,165,250,.12)} + +.sidebar-footer{ + position:absolute; + left:24px; + right:24px; + bottom:24px; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; +} + +.pill{ + padding:8px 10px; + font-size:12px; + border-radius:999px; + border:1px solid var(--line); + background: rgba(12,22,40,.55); + color:var(--muted); +} +.small{font-size:12px;color:var(--muted)} + +.main{padding:26px} +.topbar{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; +} +.topbar h1{margin:0;font-size:26px} +.muted{color:var(--muted);font-size:13px} + +.btn{ + cursor:pointer; + border:1px solid rgba(96,165,250,.45); + background: rgba(96,165,250,.15); + color:var(--text); + padding:10px 14px; + border-radius: 12px; + font-weight:700; +} +.btn:hover{background: rgba(96,165,250,.22)} + +.btn-secondary{ + cursor:pointer; + border:1px solid var(--line); + background: rgba(255,255,255,.04); + color:var(--text); + padding:10px 14px; + border-radius: 12px; + font-weight:700; +} +.btn-secondary:hover{background: rgba(255,255,255,.06)} + +.input{ + border:1px solid var(--line); + background: rgba(12,22,40,.45); + color:var(--text); + padding:10px 12px; + border-radius:12px; + min-width:260px; +} + +.content{margin-top:20px} +.view.hidden{display:none} + +.grid{ + display:grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap:14px; +} + +.card{ + border:1px solid var(--line); + border-radius: var(--radius); + background: rgba(15,26,46,.55); + box-shadow: var(--shadow); + padding:16px; +} +.card-title{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em} +.card-value{font-size:30px;margin-top:8px;font-weight:900} +.card-hint{margin-top:6px;font-size:12px;color:var(--muted)} + +.panel{ + margin-top:14px; + border:1px solid var(--line); + border-radius: var(--radius); + background: rgba(15,26,46,.55); + box-shadow: var(--shadow); + overflow:hidden; +} +.panel.small-panel{margin-top:0} +.panel-head{ + display:flex; + align-items:center; + justify-content:space-between; + padding:14px 16px; + border-bottom:1px solid var(--line); + background: rgba(12,22,40,.35); +} +.panel-title{font-weight:900} +.panel-actions{display:flex;gap:10px;align-items:center} + +.table{display:flex;flex-direction:column} +.row{ + display:grid; + grid-template-columns: 1.2fr .6fr .6fr auto; + gap:10px; + padding:12px 16px; + border-bottom:1px solid var(--line); + align-items:center; +} +.row.header{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em} +.row:last-child{border-bottom:none} +.row.clickable{cursor:pointer} +.row.clickable:hover{background: rgba(255,255,255,.03)} + +.badge{ + display:inline-flex; + align-items:center; + gap:8px; + padding:7px 10px; + border-radius:999px; + font-size:12px; + border:1px solid var(--line); + color:var(--muted); +} +.badge.good{border-color:rgba(45,212,191,.35);color:rgba(45,212,191,.95)} +.badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95)} +.badge.bad{border-color:rgba(251,113,133,.35);color:rgba(251,113,133,.95)} + +.details-grid{ + padding:16px; + display:grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + gap:12px; +} +.details-card{ + border:1px solid var(--line); + border-radius: var(--radius2); + background: rgba(12,22,40,.35); + padding:12px; +} +.details-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em} +.details-value{margin-top:8px;font-weight:800} +.details-badge{margin-top:8px;display:inline-flex} + +.split{ + padding:16px; + display:grid; + grid-template-columns: 1fr 1fr; + gap:12px; +} + +.code{ + margin:0; + padding:14px 16px; + overflow:auto; + max-height:420px; + font-size:12px; + border-top:1px solid var(--line); + background: rgba(8,14,24,.55); +} + +.fixes{padding:12px} +.fix{ + border:1px solid var(--line); + border-radius: var(--radius2); + padding:12px; + background: rgba(12,22,40,.35); + margin-bottom:10px; +} +.fix-title{font-weight:900} +.fix-desc{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.35} +.fix-actions{margin-top:10px;display:flex;gap:10px;align-items:center} + +.settings{padding:16px;display:flex;flex-direction:column;gap:12px} +.setting{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius: var(--radius2);padding:12px;background: rgba(12,22,40,.35)} +.setting-title{font-weight:900} + +.toast{ + position:fixed; + bottom:22px; + right:22px; + padding:12px 14px; + border-radius: 14px; + border:1px solid var(--line); + background: rgba(15,26,46,.85); + box-shadow: var(--shadow); + color:var(--text); + display:none; + max-width:520px; +} +.toast.show{display:block} + +@media (max-width: 980px){ + .app{grid-template-columns:1fr} + .sidebar{position:relative} + .sidebar-footer{position:relative;left:auto;right:auto;bottom:auto;margin-top:16px} + .grid{grid-template-columns:1fr} + .split{grid-template-columns:1fr} +} diff --git a/saikyo-av-web/bin/saikyo-av-web b/saikyo-av-web/bin/saikyo-av-web new file mode 100644 index 0000000..acacea5 --- /dev/null +++ b/saikyo-av-web/bin/saikyo-av-web @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +import argparse +import datetime as _dt +import html +import json +import os +import pathlib +import re +import socketserver +import sys +import urllib.parse +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def _now_utc_iso() -> str: + return _dt.datetime.now(tz=_dt.timezone.utc).isoformat(timespec="seconds") + + +_SAFE_REPORT_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") + + +def _safe_report_id(report_id: str) -> str: + if not _SAFE_REPORT_ID_RE.match(report_id): + raise ValueError("invalid report id") + return report_id + + +def _read_json(path: pathlib.Path) -> dict: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _list_reports(reports_dir: pathlib.Path) -> list[dict]: + items: list[dict] = [] + if not reports_dir.exists(): + return items + for p in sorted(reports_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True): + try: + data = _read_json(p) + except Exception: + data = {} + items.append( + { + "id": p.stem, + "file": p.name, + "mtime": int(p.stat().st_mtime), + "summary": data.get("summary") or "(no summary)", + "severity": data.get("severity") or "unknown", + "created_utc": data.get("created_utc") or None, + } + ) + return items + + +def _read_report(reports_dir: pathlib.Path, report_id: str) -> dict: + report_id = _safe_report_id(report_id) + path = reports_dir / f"{report_id}.json" + if not path.exists(): + raise FileNotFoundError(report_id) + return _read_json(path) + + +def _write_json(path: pathlib.Path, data: dict) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, path) + + +def _append_audit(reports_dir: pathlib.Path, entry: dict) -> None: + audit_dir = reports_dir / "audit" + audit_dir.mkdir(parents=True, exist_ok=True) + day = _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%d") + path = audit_dir / f"actions-{day}.jsonl" + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +def _static_file(root: pathlib.Path, rel: str) -> pathlib.Path: + rel = rel.lstrip("/") + p = (root / rel).resolve() + if not str(p).startswith(str(root.resolve())): + raise ValueError("bad path") + return p + + +class Handler(BaseHTTPRequestHandler): + server_version = "SaikyoAvWeb/0.1" + + def _send_json(self, status: int, obj: dict | list) -> None: + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_text(self, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None: + body = text.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body_json(self) -> dict: + length = int(self.headers.get("Content-Length", "0") or "0") + if length <= 0: + return {} + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return {} + + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + + if path == "/api/health": + self._send_json(200, {"status": "ok", "time_utc": _now_utc_iso()}) + return + + if path == "/api/reports": + reports = _list_reports(self.server.reports_dir) + self._send_json(200, {"reports": reports}) + return + + if path.startswith("/api/reports/"): + report_id = path.removeprefix("/api/reports/").strip("/") + try: + data = _read_report(self.server.reports_dir, report_id) + except FileNotFoundError: + self._send_json(404, {"error": "not_found"}) + return + except ValueError: + self._send_json(400, {"error": "bad_request"}) + return + self._send_json(200, {"id": report_id, "report": data}) + return + + # static + if path == "/": + path = "/index.html" + try: + p = _static_file(self.server.web_root, path) + except ValueError: + self._send_text(400, "bad request") + return + if not p.exists() or not p.is_file(): + self._send_text(404, "not found") + return + + ctype = "application/octet-stream" + if p.name.endswith(".html"): + ctype = "text/html; charset=utf-8" + elif p.name.endswith(".css"): + ctype = "text/css; charset=utf-8" + elif p.name.endswith(".js"): + ctype = "application/javascript; charset=utf-8" + elif p.name.endswith(".svg"): + ctype = "image/svg+xml" + + try: + data = p.read_bytes() + except OSError: + self._send_text(500, "internal error") + return + + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_POST(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + + if path == "/api/apply-fix": + body = self._read_body_json() + report_id = body.get("report_id") + fix_id = body.get("fix_id") + confirm = body.get("confirm") + + if not report_id or not fix_id: + self._send_json(400, {"error": "missing_fields"}) + return + + # Consent gate: without explicit confirmation we do not attempt any fix. + if confirm is not True: + self._send_json( + 409, + { + "error": "confirmation_required", + "message": "Fixes are applied only with explicit operator confirmation.", + }, + ) + return + + # MVP: backend fixer not implemented yet. + entry = { + "time_utc": _now_utc_iso(), + "action": "apply_fix_requested", + "report_id": str(report_id), + "fix_id": str(fix_id), + "result": "not_implemented", + } + try: + _append_audit(self.server.reports_dir, entry) + except Exception: + pass + + self._send_json( + 501, + { + "error": "not_implemented", + "message": "Fix runner is not implemented in saikyo-av-web MVP. Use saikyo-av CLI once available.", + }, + ) + return + + self._send_json(404, {"error": "not_found"}) + + +class Server(ThreadingHTTPServer): + def __init__(self, server_address, RequestHandlerClass, reports_dir: pathlib.Path, web_root: pathlib.Path): + super().__init__(server_address, RequestHandlerClass) + self.reports_dir = reports_dir + self.web_root = web_root + + +def _ensure_sample_report(reports_dir: pathlib.Path) -> None: + # Create a small sample report only if the directory is empty. + try: + reports_dir.mkdir(parents=True, exist_ok=True) + if any(reports_dir.glob("*.json")): + return + except Exception: + return + + sample = { + "created_utc": _now_utc_iso(), + "severity": "info", + "summary": "Saikyo Antivirus Web UI is installed (sample report)", + "details": { + "note": "This is a placeholder report. Real reports will appear here once saikyo-avd is installed and running.", + }, + "suggested_fixes": [ + { + "id": "collect_artifacts", + "title": "Collect diagnostics artifacts", + "description": "Run collect-artifacts.sh and attach the resulting archive to a support ticket.", + "requires_consent": True, + } + ], + } + _write_json(reports_dir / "sample.json", sample) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="saikyo-av-web") + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--reports-dir", default="/var/lib/saikyo-av/reports") + parser.add_argument("--web-root", default="/usr/share/saikyo-av/web") + parser.add_argument("--no-sample", action="store_true") + args = parser.parse_args() + + reports_dir = pathlib.Path(args.reports_dir) + web_root = pathlib.Path(args.web_root) + + if not args.no_sample: + _ensure_sample_report(reports_dir) + + httpd = Server((args.bind, args.port), Handler, reports_dir=reports_dir, web_root=web_root) + print(f"Listening on http://{args.bind}:{args.port}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/dh_installchangelogs.dch.trimmed b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/dh_installchangelogs.dch.trimmed new file mode 100644 index 0000000..29f1fcc --- /dev/null +++ b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/dh_installchangelogs.dch.trimmed @@ -0,0 +1,5 @@ +saikyo-av-web (0.1.0) stable; urgency=medium + + * Initial release: local-only web UI for Saikyo Antivirus reports. + + -- SAIKYO OS Mon, 20 Jan 2026 15:00:00 +0000 diff --git a/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_install b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_install new file mode 100644 index 0000000..76dc076 --- /dev/null +++ b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_install @@ -0,0 +1,4 @@ +./bin/saikyo-av-web +./assets/web +./systemd/saikyo-av-web.service +./desktop/saikyo-av-web.desktop diff --git a/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_installdocs b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_installdocs new file mode 100644 index 0000000..e69de29 diff --git a/saikyo-av-web/debian/changelog b/saikyo-av-web/debian/changelog new file mode 100644 index 0000000..29f1fcc --- /dev/null +++ b/saikyo-av-web/debian/changelog @@ -0,0 +1,5 @@ +saikyo-av-web (0.1.0) stable; urgency=medium + + * Initial release: local-only web UI for Saikyo Antivirus reports. + + -- SAIKYO OS Mon, 20 Jan 2026 15:00:00 +0000 diff --git a/saikyo-av-web/debian/control b/saikyo-av-web/debian/control new file mode 100644 index 0000000..78a6a06 --- /dev/null +++ b/saikyo-av-web/debian/control @@ -0,0 +1,13 @@ +Source: saikyo-av-web +Section: admin +Priority: optional +Maintainer: SAIKYO OS +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 +Rules-Requires-Root: no + +Package: saikyo-av-web +Architecture: all +Depends: ${misc:Depends}, python3, xdg-utils, systemd | systemd-sysv +Description: Saikyo Antivirus local web UI + Local-only web interface for Saikyo Antivirus reports and operator actions. diff --git a/saikyo-av-web/debian/copyright b/saikyo-av-web/debian/copyright new file mode 100644 index 0000000..4805add --- /dev/null +++ b/saikyo-av-web/debian/copyright @@ -0,0 +1,26 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: saikyo-av-web +Source: https://saikyo-os.ru/ + +Files: * +Copyright: 2026 SAIKYO OS +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/saikyo-av-web/debian/debhelper-build-stamp b/saikyo-av-web/debian/debhelper-build-stamp new file mode 100644 index 0000000..bf74cd3 --- /dev/null +++ b/saikyo-av-web/debian/debhelper-build-stamp @@ -0,0 +1 @@ +saikyo-av-web diff --git a/saikyo-av-web/debian/files b/saikyo-av-web/debian/files new file mode 100644 index 0000000..8d379e7 --- /dev/null +++ b/saikyo-av-web/debian/files @@ -0,0 +1,2 @@ +saikyo-av-web_0.1.0_all.deb admin optional +saikyo-av-web_0.1.0_amd64.buildinfo admin optional diff --git a/saikyo-av-web/debian/install b/saikyo-av-web/debian/install new file mode 100644 index 0000000..bf615d2 --- /dev/null +++ b/saikyo-av-web/debian/install @@ -0,0 +1,4 @@ +bin/saikyo-av-web usr/sbin/ +assets/web usr/share/saikyo-av/ +systemd/saikyo-av-web.service lib/systemd/system/ +desktop/saikyo-av-web.desktop usr/share/applications/ diff --git a/saikyo-av-web/debian/rules b/saikyo-av-web/debian/rules new file mode 100755 index 0000000..303080e --- /dev/null +++ b/saikyo-av-web/debian/rules @@ -0,0 +1,14 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + +override_dh_auto_test: + +override_dh_fixperms: + dh_fixperms + chmod 0755 debian/saikyo-av-web/usr/sbin/saikyo-av-web + chmod 0755 debian/saikyo-av-web.postinst + chmod 0755 debian/saikyo-av-web.prerm diff --git a/saikyo-av-web/debian/saikyo-av-web.debhelper.log b/saikyo-av-web/debian/saikyo-av-web.debhelper.log new file mode 100644 index 0000000..93c5512 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web.debhelper.log @@ -0,0 +1 @@ +dh_fixperms diff --git a/saikyo-av-web/debian/saikyo-av-web.postinst b/saikyo-av-web/debian/saikyo-av-web.postinst new file mode 100755 index 0000000..0337072 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web.postinst @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$1" in + configure) + mkdir -p /var/lib/saikyo-av/reports || true + chmod 0755 /var/lib/saikyo-av /var/lib/saikyo-av/reports 2>/dev/null || true + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable --now saikyo-av-web.service >/dev/null 2>&1 || true + ;; +esac + +exit 0 diff --git a/saikyo-av-web/debian/saikyo-av-web.prerm b/saikyo-av-web/debian/saikyo-av-web.prerm new file mode 100755 index 0000000..71addf8 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web.prerm @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$1" in + remove|deconfigure) + systemctl disable --now saikyo-av-web.service >/dev/null 2>&1 || true + ;; +esac + +exit 0 diff --git a/saikyo-av-web/debian/saikyo-av-web.substvars b/saikyo-av-web/debian/saikyo-av-web.substvars new file mode 100644 index 0000000..978fc8b --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web.substvars @@ -0,0 +1,2 @@ +misc:Depends= +misc:Pre-Depends= diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/control b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/control new file mode 100644 index 0000000..dc0b1b1 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/control @@ -0,0 +1,10 @@ +Package: saikyo-av-web +Version: 0.1.0 +Architecture: all +Maintainer: SAIKYO OS +Installed-Size: 55 +Depends: python3, xdg-utils, systemd | systemd-sysv +Section: admin +Priority: optional +Description: Saikyo Antivirus local web UI + Local-only web interface for Saikyo Antivirus reports and operator actions. diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums new file mode 100644 index 0000000..f2128b7 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums @@ -0,0 +1,8 @@ +741fe4509e1bf5476938555cd0110891 lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service +c66991a9362e3a17db76e241fc164d14 usr/sbin/saikyo-av-web/saikyo-av-web +2017591af37f7ef11dac74bd7cb3ad82 usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop +eb195e490a2fa9ab640e3eebc3873676 usr/share/doc/saikyo-av-web/changelog.gz +ae84f8183c7c089da5d4e1bf78693e79 usr/share/doc/saikyo-av-web/copyright +beea2a09eabd1f2131d4cf937c144548 usr/share/saikyo-av/web/web/app.js +3e0b1b07fe39cb631cb97c8a84ebc60c usr/share/saikyo-av/web/web/index.html +2038384467f0e3e494e097005a67d5c5 usr/share/saikyo-av/web/web/styles.css diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst new file mode 100755 index 0000000..0337072 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$1" in + configure) + mkdir -p /var/lib/saikyo-av/reports || true + chmod 0755 /var/lib/saikyo-av /var/lib/saikyo-av/reports 2>/dev/null || true + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable --now saikyo-av-web.service >/dev/null 2>&1 || true + ;; +esac + +exit 0 diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm new file mode 100755 index 0000000..71addf8 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$1" in + remove|deconfigure) + systemctl disable --now saikyo-av-web.service >/dev/null 2>&1 || true + ;; +esac + +exit 0 diff --git a/saikyo-av-web/debian/saikyo-av-web/lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service b/saikyo-av-web/debian/saikyo-av-web/lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service new file mode 100644 index 0000000..3b3c4a7 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service @@ -0,0 +1,25 @@ +[Unit] +Description=Saikyo Antivirus Web UI (local) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/sbin/saikyo-av-web --bind 127.0.0.1 --port 8765 --reports-dir /var/lib/saikyo-av/reports +Restart=on-failure +RestartSec=3 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +ReadWritePaths=/var/lib/saikyo-av/reports + +[Install] +WantedBy=multi-user.target diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web b/saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web new file mode 100755 index 0000000..acacea5 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +import argparse +import datetime as _dt +import html +import json +import os +import pathlib +import re +import socketserver +import sys +import urllib.parse +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def _now_utc_iso() -> str: + return _dt.datetime.now(tz=_dt.timezone.utc).isoformat(timespec="seconds") + + +_SAFE_REPORT_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") + + +def _safe_report_id(report_id: str) -> str: + if not _SAFE_REPORT_ID_RE.match(report_id): + raise ValueError("invalid report id") + return report_id + + +def _read_json(path: pathlib.Path) -> dict: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _list_reports(reports_dir: pathlib.Path) -> list[dict]: + items: list[dict] = [] + if not reports_dir.exists(): + return items + for p in sorted(reports_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True): + try: + data = _read_json(p) + except Exception: + data = {} + items.append( + { + "id": p.stem, + "file": p.name, + "mtime": int(p.stat().st_mtime), + "summary": data.get("summary") or "(no summary)", + "severity": data.get("severity") or "unknown", + "created_utc": data.get("created_utc") or None, + } + ) + return items + + +def _read_report(reports_dir: pathlib.Path, report_id: str) -> dict: + report_id = _safe_report_id(report_id) + path = reports_dir / f"{report_id}.json" + if not path.exists(): + raise FileNotFoundError(report_id) + return _read_json(path) + + +def _write_json(path: pathlib.Path, data: dict) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, path) + + +def _append_audit(reports_dir: pathlib.Path, entry: dict) -> None: + audit_dir = reports_dir / "audit" + audit_dir.mkdir(parents=True, exist_ok=True) + day = _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%d") + path = audit_dir / f"actions-{day}.jsonl" + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +def _static_file(root: pathlib.Path, rel: str) -> pathlib.Path: + rel = rel.lstrip("/") + p = (root / rel).resolve() + if not str(p).startswith(str(root.resolve())): + raise ValueError("bad path") + return p + + +class Handler(BaseHTTPRequestHandler): + server_version = "SaikyoAvWeb/0.1" + + def _send_json(self, status: int, obj: dict | list) -> None: + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_text(self, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None: + body = text.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body_json(self) -> dict: + length = int(self.headers.get("Content-Length", "0") or "0") + if length <= 0: + return {} + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return {} + + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + + if path == "/api/health": + self._send_json(200, {"status": "ok", "time_utc": _now_utc_iso()}) + return + + if path == "/api/reports": + reports = _list_reports(self.server.reports_dir) + self._send_json(200, {"reports": reports}) + return + + if path.startswith("/api/reports/"): + report_id = path.removeprefix("/api/reports/").strip("/") + try: + data = _read_report(self.server.reports_dir, report_id) + except FileNotFoundError: + self._send_json(404, {"error": "not_found"}) + return + except ValueError: + self._send_json(400, {"error": "bad_request"}) + return + self._send_json(200, {"id": report_id, "report": data}) + return + + # static + if path == "/": + path = "/index.html" + try: + p = _static_file(self.server.web_root, path) + except ValueError: + self._send_text(400, "bad request") + return + if not p.exists() or not p.is_file(): + self._send_text(404, "not found") + return + + ctype = "application/octet-stream" + if p.name.endswith(".html"): + ctype = "text/html; charset=utf-8" + elif p.name.endswith(".css"): + ctype = "text/css; charset=utf-8" + elif p.name.endswith(".js"): + ctype = "application/javascript; charset=utf-8" + elif p.name.endswith(".svg"): + ctype = "image/svg+xml" + + try: + data = p.read_bytes() + except OSError: + self._send_text(500, "internal error") + return + + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_POST(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + + if path == "/api/apply-fix": + body = self._read_body_json() + report_id = body.get("report_id") + fix_id = body.get("fix_id") + confirm = body.get("confirm") + + if not report_id or not fix_id: + self._send_json(400, {"error": "missing_fields"}) + return + + # Consent gate: without explicit confirmation we do not attempt any fix. + if confirm is not True: + self._send_json( + 409, + { + "error": "confirmation_required", + "message": "Fixes are applied only with explicit operator confirmation.", + }, + ) + return + + # MVP: backend fixer not implemented yet. + entry = { + "time_utc": _now_utc_iso(), + "action": "apply_fix_requested", + "report_id": str(report_id), + "fix_id": str(fix_id), + "result": "not_implemented", + } + try: + _append_audit(self.server.reports_dir, entry) + except Exception: + pass + + self._send_json( + 501, + { + "error": "not_implemented", + "message": "Fix runner is not implemented in saikyo-av-web MVP. Use saikyo-av CLI once available.", + }, + ) + return + + self._send_json(404, {"error": "not_found"}) + + +class Server(ThreadingHTTPServer): + def __init__(self, server_address, RequestHandlerClass, reports_dir: pathlib.Path, web_root: pathlib.Path): + super().__init__(server_address, RequestHandlerClass) + self.reports_dir = reports_dir + self.web_root = web_root + + +def _ensure_sample_report(reports_dir: pathlib.Path) -> None: + # Create a small sample report only if the directory is empty. + try: + reports_dir.mkdir(parents=True, exist_ok=True) + if any(reports_dir.glob("*.json")): + return + except Exception: + return + + sample = { + "created_utc": _now_utc_iso(), + "severity": "info", + "summary": "Saikyo Antivirus Web UI is installed (sample report)", + "details": { + "note": "This is a placeholder report. Real reports will appear here once saikyo-avd is installed and running.", + }, + "suggested_fixes": [ + { + "id": "collect_artifacts", + "title": "Collect diagnostics artifacts", + "description": "Run collect-artifacts.sh and attach the resulting archive to a support ticket.", + "requires_consent": True, + } + ], + } + _write_json(reports_dir / "sample.json", sample) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="saikyo-av-web") + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--reports-dir", default="/var/lib/saikyo-av/reports") + parser.add_argument("--web-root", default="/usr/share/saikyo-av/web") + parser.add_argument("--no-sample", action="store_true") + args = parser.parse_args() + + reports_dir = pathlib.Path(args.reports_dir) + web_root = pathlib.Path(args.web_root) + + if not args.no_sample: + _ensure_sample_report(reports_dir) + + httpd = Server((args.bind, args.port), Handler, reports_dir=reports_dir, web_root=web_root) + print(f"Listening on http://{args.bind}:{args.port}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop b/saikyo-av-web/debian/saikyo-av-web/usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop new file mode 100644 index 0000000..792b08c --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Saikyo Antivirus +Comment=Local antivirus dashboard +Exec=xdg-open http://127.0.0.1:8765 +Icon=security-high +Terminal=false +Categories=System;Security; diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/changelog.gz b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/changelog.gz new file mode 100644 index 0000000..c1fd877 Binary files /dev/null and b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/changelog.gz differ diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/copyright b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/copyright new file mode 100644 index 0000000..4805add --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/copyright @@ -0,0 +1,26 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: saikyo-av-web +Source: https://saikyo-os.ru/ + +Files: * +Copyright: 2026 SAIKYO OS +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/app.js b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/app.js new file mode 100644 index 0000000..52c39c1 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/app.js @@ -0,0 +1,234 @@ +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +function badgeClass(sev) { + const s = (sev || "").toLowerCase(); + if (["ok", "info", "low"].includes(s)) return "good"; + if (["medium", "warn", "warning"].includes(s)) return "warn"; + if (["high", "critical", "bad"].includes(s)) return "bad"; + return ""; +} + +function fmtTime(ts) { + if (!ts) return "—"; + try { + if (typeof ts === "number") return new Date(ts * 1000).toLocaleString(); + return new Date(ts).toLocaleString(); + } catch { + return String(ts); + } +} + +function toast(msg) { + const t = $("#toast"); + t.textContent = msg; + t.classList.add("show"); + setTimeout(() => t.classList.remove("show"), 3500); +} + +async function api(path, opts) { + const res = await fetch(path, opts); + const ct = res.headers.get("content-type") || ""; + const data = ct.includes("application/json") ? await res.json() : await res.text(); + return { ok: res.ok, status: res.status, data }; +} + +let state = { + view: "dashboard", + reports: [], + selected: null, +}; + +function setView(v) { + state.view = v; + $$(".nav-item").forEach((a) => a.classList.toggle("active", a.dataset.view === v)); + $("#view-dashboard").classList.toggle("hidden", v !== "dashboard"); + $("#view-reports").classList.toggle("hidden", v !== "reports"); + $("#view-settings").classList.toggle("hidden", v !== "settings"); + + if (v === "dashboard") { + $("#page-title").textContent = "Dashboard"; + $("#page-sub").textContent = "Обзор состояния и последние отчёты"; + } + if (v === "reports") { + $("#page-title").textContent = "Reports"; + $("#page-sub").textContent = "Просмотр отчётов и применение действий (по согласию)"; + } + if (v === "settings") { + $("#page-title").textContent = "Settings"; + $("#page-sub").textContent = "Локальные параметры интерфейса"; + } +} + +function renderTable(el, rows, { limit = null } = {}) { + const r = limit ? rows.slice(0, limit) : rows; + const header = ` +
+
Summary
+
Severity
+
Updated
+
+
+ `; + + const body = r + .map((it) => { + const cls = badgeClass(it.severity); + return ` +
+
${escapeHtml(it.summary || "(no summary)")}
+
${escapeHtml(it.severity || "unknown")}
+
${escapeHtml(fmtTime(it.created_utc || it.mtime))}
+
open
+
+ `; + }) + .join(""); + + el.innerHTML = header + body; + el.querySelectorAll(".row.clickable").forEach((row) => { + row.addEventListener("click", () => openReport(row.dataset.id)); + }); +} + +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function refresh() { + const h = await api("/api/health"); + if (h.ok) { + $("#health-pill").textContent = "UI: OK"; + $("#status-value").textContent = "Protected"; + $("#status-hint").textContent = "local UI service running"; + } else { + $("#health-pill").textContent = "UI: error"; + $("#status-value").textContent = "Degraded"; + $("#status-hint").textContent = "health check failed"; + } + + const r = await api("/api/reports"); + if (r.ok) { + state.reports = r.data.reports || []; + $("#reports-count").textContent = String(state.reports.length); + renderTable($("#latest-table"), state.reports, { limit: 6 }); + renderTable($("#reports-table"), state.reports); + } else { + toast("Failed to load reports"); + } +} + +async function openReport(reportId) { + const p = await api(`/api/reports/${encodeURIComponent(reportId)}`); + if (!p.ok) { + toast("Report not found"); + return; + } + const report = p.data.report; + state.selected = { id: reportId, report }; + + $("#report-details").classList.remove("hidden"); + $("#details-title").textContent = `Report: ${reportId}`; + $("#details-sub").textContent = "Read-only details"; + $("#details-summary").textContent = report.summary || "—"; + + const sev = report.severity || "unknown"; + const cls = badgeClass(sev); + const badge = $("#details-severity"); + badge.textContent = sev; + badge.className = `badge ${cls}`; + + $("#details-created").textContent = fmtTime(report.created_utc || "—"); + $("#raw").textContent = JSON.stringify(report, null, 2); + + const fixesEl = $("#fixes"); + fixesEl.classList.add("fixes"); + const fixes = report.suggested_fixes || []; + if (!fixes.length) { + fixesEl.innerHTML = `
No suggested fixes
`; + } else { + fixesEl.innerHTML = fixes + .map((f) => { + return ` +
+
${escapeHtml(f.title || f.id || "Fix")}
+
${escapeHtml(f.description || "")}
+
+ + consent +
+
+ `; + }) + .join(""); + + fixesEl.querySelectorAll("button[data-fix]").forEach((b) => { + b.addEventListener("click", () => applyFix(b.dataset.fix)); + }); + } +} + +async function applyFix(fixId) { + if (!state.selected) return; + const reportId = state.selected.id; + + const confirm1 = window.confirm( + `Apply fix '${fixId}' for report '${reportId}'?\n\nThis action requires explicit operator confirmation.` + ); + if (!confirm1) return; + + const res = await api("/api/apply-fix", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ report_id: reportId, fix_id: fixId, confirm: true }), + }); + + if (res.ok) { + toast("Fix applied"); + } else { + if (res.status === 501) { + toast("Fix runner not implemented yet (MVP UI only)"); + return; + } + if (res.status === 409) { + toast("Confirmation required"); + return; + } + toast("Failed to apply fix"); + } +} + +function bindEvents() { + $$(".nav-item").forEach((a) => { + a.addEventListener("click", (e) => { + e.preventDefault(); + setView(a.dataset.view); + }); + }); + + $("#refresh-btn").addEventListener("click", () => refresh()); + + $("#close-details").addEventListener("click", () => { + $("#report-details").classList.add("hidden"); + state.selected = null; + }); + + $("#filter").addEventListener("input", () => { + const q = $("#filter").value.trim().toLowerCase(); + const rows = !q + ? state.reports + : state.reports.filter((r) => + String(r.summary || "").toLowerCase().includes(q) || String(r.id || "").toLowerCase().includes(q) + ); + renderTable($("#reports-table"), rows); + }); +} + +bindEvents(); +setView("dashboard"); +refresh(); diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/index.html b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/index.html new file mode 100644 index 0000000..192a750 --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/index.html @@ -0,0 +1,182 @@ + + + + + + Saikyo Antivirus + + + +
+ + +
+
+
+

Dashboard

+
Обзор состояния и последние отчёты
+
+
+ +
+
+ +
+
+
+
+
Status
+
+
waiting for health check
+
+
+
Reports
+
+
available locally
+
+
+
Mode
+
Consent
+
fixes require confirmation
+
+
+ +
+
+
+
Latest reports
+
Click a report to open details
+
+
+
+
+
+ + + + +
+
+
+ +
+ + + + diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/styles.css b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/styles.css new file mode 100644 index 0000000..d637e2f --- /dev/null +++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/styles.css @@ -0,0 +1,281 @@ +:root{ + --bg:#0b1220; + --panel:#0f1a2e; + --panel2:#0c1628; + --text:#e7eefc; + --muted:#9bb0d1; + --line:rgba(255,255,255,.08); + --good:#2dd4bf; + --warn:#fbbf24; + --bad:#fb7185; + --accent:#60a5fa; + --shadow: 0 20px 60px rgba(0,0,0,.35); + --radius:18px; + --radius2:14px; + --font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; +} + +*{box-sizing:border-box} +html,body{height:100%} +body{ + margin:0; + font-family:var(--font); + color:var(--text); + background: radial-gradient(1200px 800px at 15% 5%, rgba(96,165,250,.25), transparent 60%), + radial-gradient(900px 700px at 80% 20%, rgba(45,212,191,.18), transparent 55%), + radial-gradient(800px 600px at 60% 85%, rgba(251,113,133,.14), transparent 55%), + var(--bg); +} + +a{color:inherit} + +.app{ + height:100%; + display:grid; + grid-template-columns: 320px 1fr; +} + +.sidebar{ + padding:24px; + border-right:1px solid var(--line); + background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 30%), + rgba(15,26,46,.55); + backdrop-filter: blur(10px); +} + +.brand{ + display:flex; + gap:14px; + align-items:center; + padding:14px; + border:1px solid var(--line); + border-radius: var(--radius); + background: rgba(12,22,40,.55); + box-shadow: var(--shadow); +} +.logo{ + width:44px; + height:44px; + border-radius:14px; + display:grid; + place-items:center; + font-weight:800; + background: linear-gradient(135deg, rgba(96,165,250,.8), rgba(45,212,191,.75)); + color:#0b1220; +} +.brand-title{font-size:16px;font-weight:800;letter-spacing:.2px} +.brand-sub{font-size:12px;color:var(--muted)} + +.nav{margin-top:18px;display:flex;flex-direction:column;gap:8px} +.nav-item{ + text-decoration:none; + padding:12px 14px; + border-radius: 14px; + border:1px solid transparent; + color:var(--muted); + background: rgba(12,22,40,.20); +} +.nav-item:hover{border-color:var(--line);color:var(--text)} +.nav-item.active{color:var(--text);border-color:rgba(96,165,250,.45);background: rgba(96,165,250,.12)} + +.sidebar-footer{ + position:absolute; + left:24px; + right:24px; + bottom:24px; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; +} + +.pill{ + padding:8px 10px; + font-size:12px; + border-radius:999px; + border:1px solid var(--line); + background: rgba(12,22,40,.55); + color:var(--muted); +} +.small{font-size:12px;color:var(--muted)} + +.main{padding:26px} +.topbar{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; +} +.topbar h1{margin:0;font-size:26px} +.muted{color:var(--muted);font-size:13px} + +.btn{ + cursor:pointer; + border:1px solid rgba(96,165,250,.45); + background: rgba(96,165,250,.15); + color:var(--text); + padding:10px 14px; + border-radius: 12px; + font-weight:700; +} +.btn:hover{background: rgba(96,165,250,.22)} + +.btn-secondary{ + cursor:pointer; + border:1px solid var(--line); + background: rgba(255,255,255,.04); + color:var(--text); + padding:10px 14px; + border-radius: 12px; + font-weight:700; +} +.btn-secondary:hover{background: rgba(255,255,255,.06)} + +.input{ + border:1px solid var(--line); + background: rgba(12,22,40,.45); + color:var(--text); + padding:10px 12px; + border-radius:12px; + min-width:260px; +} + +.content{margin-top:20px} +.view.hidden{display:none} + +.grid{ + display:grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap:14px; +} + +.card{ + border:1px solid var(--line); + border-radius: var(--radius); + background: rgba(15,26,46,.55); + box-shadow: var(--shadow); + padding:16px; +} +.card-title{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em} +.card-value{font-size:30px;margin-top:8px;font-weight:900} +.card-hint{margin-top:6px;font-size:12px;color:var(--muted)} + +.panel{ + margin-top:14px; + border:1px solid var(--line); + border-radius: var(--radius); + background: rgba(15,26,46,.55); + box-shadow: var(--shadow); + overflow:hidden; +} +.panel.small-panel{margin-top:0} +.panel-head{ + display:flex; + align-items:center; + justify-content:space-between; + padding:14px 16px; + border-bottom:1px solid var(--line); + background: rgba(12,22,40,.35); +} +.panel-title{font-weight:900} +.panel-actions{display:flex;gap:10px;align-items:center} + +.table{display:flex;flex-direction:column} +.row{ + display:grid; + grid-template-columns: 1.2fr .6fr .6fr auto; + gap:10px; + padding:12px 16px; + border-bottom:1px solid var(--line); + align-items:center; +} +.row.header{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em} +.row:last-child{border-bottom:none} +.row.clickable{cursor:pointer} +.row.clickable:hover{background: rgba(255,255,255,.03)} + +.badge{ + display:inline-flex; + align-items:center; + gap:8px; + padding:7px 10px; + border-radius:999px; + font-size:12px; + border:1px solid var(--line); + color:var(--muted); +} +.badge.good{border-color:rgba(45,212,191,.35);color:rgba(45,212,191,.95)} +.badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95)} +.badge.bad{border-color:rgba(251,113,133,.35);color:rgba(251,113,133,.95)} + +.details-grid{ + padding:16px; + display:grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + gap:12px; +} +.details-card{ + border:1px solid var(--line); + border-radius: var(--radius2); + background: rgba(12,22,40,.35); + padding:12px; +} +.details-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em} +.details-value{margin-top:8px;font-weight:800} +.details-badge{margin-top:8px;display:inline-flex} + +.split{ + padding:16px; + display:grid; + grid-template-columns: 1fr 1fr; + gap:12px; +} + +.code{ + margin:0; + padding:14px 16px; + overflow:auto; + max-height:420px; + font-size:12px; + border-top:1px solid var(--line); + background: rgba(8,14,24,.55); +} + +.fixes{padding:12px} +.fix{ + border:1px solid var(--line); + border-radius: var(--radius2); + padding:12px; + background: rgba(12,22,40,.35); + margin-bottom:10px; +} +.fix-title{font-weight:900} +.fix-desc{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.35} +.fix-actions{margin-top:10px;display:flex;gap:10px;align-items:center} + +.settings{padding:16px;display:flex;flex-direction:column;gap:12px} +.setting{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius: var(--radius2);padding:12px;background: rgba(12,22,40,.35)} +.setting-title{font-weight:900} + +.toast{ + position:fixed; + bottom:22px; + right:22px; + padding:12px 14px; + border-radius: 14px; + border:1px solid var(--line); + background: rgba(15,26,46,.85); + box-shadow: var(--shadow); + color:var(--text); + display:none; + max-width:520px; +} +.toast.show{display:block} + +@media (max-width: 980px){ + .app{grid-template-columns:1fr} + .sidebar{position:relative} + .sidebar-footer{position:relative;left:auto;right:auto;bottom:auto;margin-top:16px} + .grid{grid-template-columns:1fr} + .split{grid-template-columns:1fr} +} diff --git a/saikyo-av-web/debian/source/format b/saikyo-av-web/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/saikyo-av-web/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/saikyo-av-web/desktop/saikyo-av-web.desktop b/saikyo-av-web/desktop/saikyo-av-web.desktop new file mode 100644 index 0000000..792b08c --- /dev/null +++ b/saikyo-av-web/desktop/saikyo-av-web.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Saikyo Antivirus +Comment=Local antivirus dashboard +Exec=xdg-open http://127.0.0.1:8765 +Icon=security-high +Terminal=false +Categories=System;Security; diff --git a/saikyo-av-web/systemd/saikyo-av-web.service b/saikyo-av-web/systemd/saikyo-av-web.service new file mode 100644 index 0000000..3b3c4a7 --- /dev/null +++ b/saikyo-av-web/systemd/saikyo-av-web.service @@ -0,0 +1,25 @@ +[Unit] +Description=Saikyo Antivirus Web UI (local) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/sbin/saikyo-av-web --bind 127.0.0.1 --port 8765 --reports-dir /var/lib/saikyo-av/reports +Restart=on-failure +RestartSec=3 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +ReadWritePaths=/var/lib/saikyo-av/reports + +[Install] +WantedBy=multi-user.target