diff --git a/README.md b/README.md index a66ad7c..8393176 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ gscroll export --format md # structured report, ready to share | **Validation** | `gscroll validate [SESSION] --repair` checks JSONL/assets/parts and patches repairable metadata | | **Replay** | `gscroll replay` via `scriptreplay` with speed control | | **TUI** | Interactive Textual dashboard — session sidebar, phase timeline, command table | -| **Web preview** | `gscroll serve` hosts an HTML viewer + JSON API with session CRUD (create, delete, continue with part tracking, validate) | +| **Web preview** | `gscroll serve` hosts an HTML viewer + JSON API with browser-based session create/close, uploads, heartbeats, and continue/validate | | **Session auto-detect** | All sub-commands pick up `GUILD_SCROLL_SESSION` automatically | | **Self-update** | `gscroll update` checks GitHub and reinstalls | @@ -354,7 +354,7 @@ Sessions are stored under `./guild_scroll/sessions//` (CWD-local, like `.g Override the base path with `GUILD_SCROLL_DIR`. -Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-localhost addresses (required for Docker/container deployments that use `--host 0.0.0.0`). Localhost-only is the default. +Non-localhost binds (`--host 0.0.0.0`) are allowed but print a safety warning; set `GUILD_SCROLL_ALLOW_REMOTE=1` (or enable TLS) to silence the warning in containerized deployments. ### Web API Endpoints @@ -364,13 +364,17 @@ Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-loca |---|---|---| | `GET` | `/api/sessions` | List all sessions | | `GET` | `/api/session/{name}` | Fetch session detail (commands, notes, assets) | -| `POST` | `/api/sessions` | Create a session scaffold (`{"name": "..."}`) → 201/409/422 | -| `DELETE` | `/api/session/{name}` | Delete a session directory → 204/404/400 | +| `POST` | `/api/sessions` | Create a session scaffold (`{"name": "..." , "operator": "...", "target": "...", "platform": "htb|thm"}`) → `{"session": {session_meta...}}` (201/409/422) | +| `DELETE` | `/api/session/{name}` | Delete a session directory → `{"deleted": name}` (200/404/400) | | `POST` | `/api/session/{name}/continue` | Start a joined session part → `{"session": "...", "part": N, "status": "active"}` (404 if missing, 409 if already active) | | `POST` | `/api/session/{name}/validate` | Validate (and optionally repair with `?repair=true`) → `{valid, errors, warnings, repaired}` | | `POST` | `/api/session/{name}/report` | Render a filtered export (body: `{"format": "md\|html", ...}`) | | `GET` | `/api/session/{name}/download` | Download session export (`?format=md\|html`) | | `GET` | `/api/session/{name}/discoveries` | Fetch recent notes/assets timeline | +| `GET` / `POST` | `/api/session/{name}/heartbeat` | Track liveness of active sessions; GET returns `{"status": "live\|unknown", "last_beat": ...}` | +| `POST` | `/api/session/{name}/close` | Stop live terminals, clear heartbeat, and delete the session directory | +| `POST` | `/api/session/{name}/upload` | Upload screenshots/evidence (PNG/JPEG/WEBP/GIF/SVG) to `assets/uploads/` | +| `GET` | `/api/session/{name}/asset/{filename}` | Serve uploaded assets (with content-type enforcement) | ### Live Web Terminal @@ -383,7 +387,7 @@ Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-loca | Type | Key Fields | |---|---| -| `session_meta` | `session_name`, `session_id`, `start_time`, `hostname`, `end_time`, `command_count` | +| `session_meta` | `session_name`, `session_id`, `start_time`, `hostname`, `end_time`, `command_count`, `parts_count`, `operator`, `platform`, `target` | | `command` | `seq`, `command`, `timestamp_start`, `timestamp_end`, `exit_code`, `working_directory` | | `asset` | `seq`, `trigger_command`, `asset_type`, `captured_path`, `original_path`, `timestamp` | | `note` | `text`, `timestamp`, `tags` | diff --git a/src/guild_scroll/cli.py b/src/guild_scroll/cli.py index 33f5ddf..37b2797 100644 --- a/src/guild_scroll/cli.py +++ b/src/guild_scroll/cli.py @@ -3,6 +3,7 @@ join | share | import | serve """ import sys +import errno import click from guild_scroll import __version__ @@ -461,6 +462,11 @@ def serve(host, port): except ValueError as exc: click.echo(f"Error: {exc}", err=True) sys.exit(1) + except OSError as exc: + if getattr(exc, "errno", None) == errno.EADDRINUSE: + click.echo(f"Port {port} already in use", err=True) + sys.exit(1) + raise @cli.command( diff --git a/src/guild_scroll/log_schema.py b/src/guild_scroll/log_schema.py index 6b2d238..2b6cd49 100644 --- a/src/guild_scroll/log_schema.py +++ b/src/guild_scroll/log_schema.py @@ -21,6 +21,7 @@ class SessionMeta: parts_count: int = 1 platform: Optional[str] = None # "htb" | "thm" | None operator: Optional[str] = None + target: Optional[str] = None result: Optional[str] = None # "rooted" | "compromised" | "partial" | "failed" | "incomplete" finalized: bool = False mode: Optional[str] = None # "ctf" | "assessment" | None (legacy) diff --git a/src/guild_scroll/session.py b/src/guild_scroll/session.py index 336399b..ef978f8 100644 --- a/src/guild_scroll/session.py +++ b/src/guild_scroll/session.py @@ -74,6 +74,64 @@ def update_parts_count(sess_dir: Path, parts_count: int) -> None: _patch_session_meta_file(writer._fh, parts_count=parts_count) +def create_session_scaffold( + raw_name: str, + *, + operator: Optional[str] = None, + target: Optional[str] = None, + platform: Optional[str] = None, + mode: Optional[str] = None, +) -> SessionMeta: + """Create a new session directory tree and initial session_meta record.""" + if mode is None: + mode = get_default_mode() + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValueError("Invalid session name: 'name' is required") + if not any(ch.isalnum() for ch in raw_name): + raise ValueError("Invalid session name: must include letters or numbers") + if any(sep in raw_name for sep in ("/", "\\")) or ".." in raw_name: + raise ValueError("Invalid session name: path traversal not allowed") + + name = sanitize_session_name(raw_name) + sess_dir = _session_dir(name) + try: + resolved_sessions_dir = get_sessions_dir().resolve() + resolved_candidate = sess_dir.resolve(strict=False) + resolved_candidate.relative_to(resolved_sessions_dir) + except (OSError, ValueError) as exc: + raise ValueError("Invalid session name") from exc + + if sess_dir.exists(): + raise FileExistsError(f"Session already exists: {name!r}") + + logs_dir = sess_dir / "logs" + assets_dir = sess_dir / "assets" + screenshots_dir = sess_dir / "screenshots" + for directory in (logs_dir, assets_dir, screenshots_dir): + directory.mkdir(parents=True, exist_ok=True) + + session_id = generate_session_id() + hmac_key = generate_session_key(sess_dir) + generate_encryption_key(sess_dir) + resolved_operator = (operator or "").strip() or _detect_operator() + resolved_target = (target or "").strip() or None + resolved_platform = (platform or "").strip() or _detect_platform_safe() + + meta = SessionMeta( + session_name=name, + session_id=session_id, + start_time=iso_timestamp(), + hostname=socket.gethostname(), + operator=resolved_operator, + platform=resolved_platform, + target=resolved_target, + mode=mode, + ) + with JSONLWriter(logs_dir / SESSION_LOG_NAME, hmac_key=hmac_key) as writer: + writer.write(meta.to_dict()) + return meta + + def start_session(raw_name: str, join: bool = False, mode: Optional[str] = None) -> None: """Create the session directory tree, inject hooks, launch script, finalize. @@ -287,6 +345,9 @@ def finalize_session( pass elif etype == "asset_hint": original_path = Path(evt.get("original_path", "")) + if original_path.is_absolute() or ".." in original_path.parts: + logging.getLogger(__name__).warning("Rejected asset path outside session: %s", original_path) + continue if original_path.exists(): dest = _capture_asset_for_event(original_path, assets_dir) if dest: diff --git a/src/guild_scroll/web/app.py b/src/guild_scroll/web/app.py index b94c413..4e76933 100644 --- a/src/guild_scroll/web/app.py +++ b/src/guild_scroll/web/app.py @@ -10,9 +10,13 @@ import shutil import socket import tempfile +import time +import cgi +import ssl from dataclasses import asdict from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from datetime import datetime, timezone from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse from guild_scroll.config import ( @@ -28,7 +32,7 @@ from guild_scroll.log_schema import NoteEvent, SessionMeta from guild_scroll.log_writer import JSONLWriter from guild_scroll.search import SearchFilter, search_commands -from guild_scroll.session import list_sessions, next_part_number, update_parts_count +from guild_scroll.session import create_session_scaffold, delete_session, list_sessions, next_part_number, update_parts_count from guild_scroll.session_loader import LoadedSession, load_session from guild_scroll.utils import generate_session_id, iso_timestamp, sanitize_session_name from guild_scroll.validator import repair_session, validate_session @@ -43,6 +47,17 @@ _SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+") _WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +_HEARTBEAT_TTL_SECONDS = 30 +_session_heartbeats: dict[str, float] = {} +_ALLOWED_UPLOAD_TYPES = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", +} +_MAX_UPLOAD_SIZE = 8 * 1024 * 1024 # 8 MB def _write_jsonl_record(log_path: Path, record: dict[str, object]) -> None: @@ -74,6 +89,35 @@ def _detect_operator() -> str | None: return None +def _heartbeat_status(session_name: str) -> tuple[str, float | None]: + last = _session_heartbeats.get(session_name) + if last is None: + return "unknown", None + if time.time() - last > _HEARTBEAT_TTL_SECONDS: + return "expired", last + return "live", last + + +def _detect_upload_type(filename: str, data: bytes) -> str | None: + ext = Path(filename).suffix.lower() + expected = _ALLOWED_UPLOAD_TYPES.get(ext) + if expected is None: + return None + if ext == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n"): + return expected + if ext in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff"): + return expected + if ext == ".gif" and data.startswith(b"GIF8"): + return expected + if ext == ".webp" and data.startswith(b"RIFF") and b"WEBP" in data[:16]: + return expected + if ext == ".svg": + head = data[:200].decode("utf-8", errors="ignore").lower() + if " str | None: values = params.get(key) if not values: @@ -274,49 +318,77 @@ def _format_command_count(value: object) -> int: # noqa: ANN001 def _render_index_page(sessions: list[dict]) -> str: - if not sessions: - cards = ( - '
' - '

No sessions found

' - '

Start a run with gscroll start to forge your first chronicle.

' - '
' - ) - else: - card_items = [] - for session in sessions: - name = str(session.get("session_name") or "unknown") - start_time = _format_start_time(session.get("start_time")) - hostname = _format_hostname(session.get("hostname")) - command_count = _format_command_count(session.get("command_count")) - quoted_name = quote(name, safe="") - escaped_name = html.escape(name) - card_items.append( - """ -
+ has_sessions = bool(sessions) + card_items: list[str] = [] + for session in sorted(sessions, key=_session_sort_key, reverse=True): + name = str(session.get("session_name") or "unknown") + start_time = _format_start_time(session.get("start_time")) + hostname = _format_hostname(session.get("hostname")) + command_count = _format_command_count(session.get("command_count")) + quoted_name = quote(name, safe="") + escaped_name = html.escape(name) + data_name = html.escape(sanitize_session_name(name).lower(), quote=True) + data_start = html.escape(str(session.get("start_time") or ""), quote=True) + data_host = html.escape(hostname, quote=True) + data_commands = html.escape(str(command_count), quote=True) + name_json = html.escape(json.dumps(name)) + card_items.append( + f""" + -""".format( - session_name=escaped_name, - start_time=html.escape(start_time), - hostname=html.escape(hostname), - command_count=command_count, - session_path=quoted_name, - ) - ) +""" + ) + if card_items: cards = "\n".join(card_items) + else: + cards = ( + '
' + '

No sessions found

' + '

Start a run with gscroll start to forge your first chronicle.

' + '' + "
" + ) + + toolbar = ( + f""" +
+ +
+ + +
+
{len(card_items)} sessions
+ +
+""" + if has_sessions + else "" + ) template = """ @@ -376,6 +448,46 @@ def _render_index_page(sessions: list[dict]) -> str: grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0.95rem; } +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +} +.search-box { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.search-input { + padding: 0.45rem 0.65rem; + border-radius: 10px; + border: 1px solid rgba(63, 199, 255, 0.4); + background: rgba(10, 18, 30, 0.8); + color: var(--text-main); +} +.sort-select select { + padding: 0.42rem 0.65rem; + border-radius: 10px; + border: 1px solid rgba(63, 199, 255, 0.4); + background: rgba(10, 18, 30, 0.8); + color: var(--text-main); +} +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.session-count { font-family: "Consolas", monospace; color: var(--text-muted); } +.kbd-hint { font-size: 0.8rem; color: var(--text-muted); } +.new-session-btn { + background: linear-gradient(120deg, #2ad0ff, #1b88ff); + color: #061020; + border: none; + border-radius: 10px; + padding: 0.5rem 0.9rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 18px rgba(42, 208, 255, 0.25); +} +.new-session-btn:hover { transform: translateY(-1px); } .session-card { border: 1px solid rgba(63, 199, 255, 0.42); background: linear-gradient(160deg, rgba(16, 33, 52, 0.92), rgba(12, 23, 37, 0.86)); @@ -447,11 +559,43 @@ def _render_index_page(sessions: list[dict]) -> str: font-size: 0.8rem; font-family: "Consolas", monospace; } +.rune-link.danger { border-color: #ff9b7c; color: #ffd7c9; } .rune-link:hover { border-color: var(--hover-core); color: #ffffff; background: rgba(42, 208, 255, 0.15); } +.no-match { + margin-top: 1rem; + color: var(--text-muted); + font-family: "Consolas", monospace; +} +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} +.modal[hidden] { display: none; } +.modal-content { + background: #0f1a2c; + border: 1px solid rgba(63, 199, 255, 0.4); + border-radius: 12px; + padding: 1rem; + width: min(460px, 100%); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); +} +.modal-content h3 { margin-top: 0; margin-bottom: 0.5rem; } +.modal-form { display: grid; gap: 0.65rem; } +.modal-form label { display: grid; gap: 0.3rem; color: var(--text-muted); } +.modal-form input { padding: 0.45rem; border-radius: 8px; border: 1px solid rgba(63, 199, 255, 0.35); background: #0b1423; color: var(--text-main); } +.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; } +.pill-btn { border: 1px solid rgba(63, 199, 255, 0.5); background: #112035; color: #e9efff; border-radius: 999px; padding: 0.45rem 0.8rem; cursor: pointer; } +.pill-btn.primary { background: #2ad0ff; color: #061020; border-color: #2ad0ff; } +.form-error { color: #ffb3a3; min-height: 1.1rem; } .empty-state { text-align: center; } @@ -480,14 +624,278 @@ def _render_index_page(sessions: list[dict]) -> str:

Guild Scroll Session Codex

Neon runes mark each expedition. Select a chronicle to inspect reports or extract artifacts.

-
+ __TOOLBAR__ +
__CARDS__
+ + + """ - return template.replace("__CARDS__", cards, 1) + return template.replace("__TOOLBAR__", toolbar, 1).replace("__CARDS__", cards, 1) def _render_session_page( @@ -563,6 +971,7 @@ def _render_session_page( ) default_part = max(session.parts) if session.parts else 1 + session_name_js = html.escape(json.dumps(session.meta.session_name)) return f""" @@ -583,6 +992,13 @@ def _render_session_page( .action-pill:hover {{ border-color: #52d0ff; background: #1a2a42; }} button.action-pill {{ background: transparent; color: #e9efff; cursor: pointer; }} .action-status {{ color: #9eb8da; min-height: 1.2rem; display: inline-flex; align-items: center; }} +.heartbeat-badge {{ display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.6rem; border-radius: 999px; border: 1px solid #3d608d; background: #0f1d31; }} +.upload-zone {{ border: 1px dashed #3d608d; border-radius: 12px; padding: 0.9rem; margin-bottom: 1rem; background: rgba(15, 29, 49, 0.65); }} +.upload-zone h2 {{ margin-top: 0; }} +.upload-zone p {{ color: #9eb8da; }} +.upload-controls {{ display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; }} +.upload-status {{ color: #d1efff; min-height: 1.2rem; }} +.sr-only {{ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }} .report-frame {{ width: 100%; height: 760px; border: 1px solid #334b70; background: #fff; border-radius: 8px; }} .report-preview {{ background: #0b1020; border: 1px solid #334b70; border-radius: 8px; padding: 1rem; overflow: auto; min-height: 760px; }} .discoveries-panel {{ position: sticky; top: 1rem; border: 1px solid #3d608d; border-radius: 12px; background: linear-gradient(160deg, #13243b, #0f1d31); padding: 0.9rem; box-shadow: inset 0 0 0 1px rgba(96, 142, 193, 0.16); }} @@ -705,6 +1121,75 @@ def _render_session_page( inputEl.value = ""; }} +async function gsDeleteSession(name) {{ + const resp = await fetch(`/api/session/${{encodeURIComponent(name)}}`, {{ method: "DELETE" }}); + if (resp.status === 200 || resp.status === 204) {{ + window.location.href = "/"; + }} +}} + +async function gsCloseSession(name) {{ + const resp = await fetch(`/api/session/${{encodeURIComponent(name)}}/close`, {{ method: "POST" }}); + if (resp.status === 200) {{ + window.location.href = "/"; + }} +}} + +async function gsHeartbeat() {{ + try {{ + const resp = await fetch(`/api/session/${{gsSessionPath}}/heartbeat`); + const payload = await resp.json().catch(() => ({{}})); + const status = payload.status || "unknown"; + const last = payload.last_beat || ""; + gsUpdateHeartbeat(status, last); + }} catch (_) {{ + gsUpdateHeartbeat("unknown", null); + }} +}} + +function gsUpdateHeartbeat(status, lastBeat) {{ + const badge = document.getElementById("gs-session-status"); + if (!badge) {{ return; }} + const label = status === "live" ? "Live" : status === "expired" ? "Expired" : "Unknown"; + const timeText = lastBeat ? ` — last beat ${{lastBeat}}` : ""; + badge.textContent = `Status: ${{label}}${{timeText}}`; +}} + +async function gsHandleFiles(event) {{ + if (event) {{ + event.preventDefault(); + }} + const input = document.getElementById("gs-file-input"); + const files = event?.dataTransfer?.files || input?.files; + if (!files || files.length === 0) {{ + return; + }} + for (const file of files) {{ + await gsUploadFile(file); + }} + if (input) {{ input.value = ""; }} +}} + +async function gsUploadFile(file) {{ + const statusEl = document.getElementById("gs-upload-status"); + try {{ + const form = new FormData(); + form.append("file", file, file.name); + const resp = await fetch(`/api/session/${{gsSessionPath}}/upload`, {{ + method: "POST", + body: form, + }}); + const payload = await resp.json().catch(() => ({{}})); + if (!resp.ok) {{ + if (statusEl) {{ statusEl.textContent = payload.error || "Upload failed."; }} + return; + }} + if (statusEl) {{ statusEl.textContent = `Uploaded ${{payload.filename || file.name}}`; }} + }} catch (_) {{ + if (statusEl) {{ statusEl.textContent = "Upload failed."; }} + }} +}} + async function gsContinueSession() {{ const statusEl = document.getElementById("gs-continue-status"); const btn = document.getElementById("gs-continue-btn"); @@ -734,23 +1219,40 @@ def _render_session_page( }} gsSetTerminalPart(gsTerminalPart); +gsHeartbeat(); +setInterval(gsHeartbeat, 15000);

Session: {html.escape(session.meta.session_name)}

-

Commands in report: {len(session.commands)} | Preview format: {html.escape(preview_format)}

+

+ Commands in report: {len(session.commands)} | Preview format: {html.escape(preview_format)} + Status: unknown +

HTML preview Markdown preview Download HTML Download Markdown + +
+
+

Upload Evidence

+

Drag and drop screenshots or click to select files.

+
+ + +
+

+
+

Live Terminal

@@ -810,6 +1312,16 @@ def do_GET(self) -> None: session_name = parsed.path[len("/api/session/"):-len("/terminal/read")].strip("/") self._handle_terminal_read(session_name, params) return + if parsed.path.startswith("/api/session/") and parsed.path.endswith("/heartbeat"): + session_name = parsed.path[len("/api/session/"):-len("/heartbeat")].strip("/") + self._handle_heartbeat_get(session_name) + return + if parsed.path.startswith("/api/session/") and "/asset/" in parsed.path: + parts = parsed.path[len("/api/session/"):].split("/asset/", 1) + session_name = parts[0].strip("/") + asset_path = parts[1] if len(parts) > 1 else "" + self._handle_asset(session_name, asset_path) + return if parsed.path == "/": self._handle_index() return @@ -858,6 +1370,18 @@ def do_POST(self) -> None: session_name = parsed.path[len("/api/session/"):-len("/terminal/stop")].strip("/") self._handle_terminal_stop(session_name, params) return + if parsed.path.startswith("/api/session/") and parsed.path.endswith("/heartbeat"): + session_name = parsed.path[len("/api/session/"):-len("/heartbeat")].strip("/") + self._handle_heartbeat_post(session_name) + return + if parsed.path.startswith("/api/session/") and parsed.path.endswith("/close"): + session_name = parsed.path[len("/api/session/"):-len("/close")].strip("/") + self._handle_close_session(session_name) + return + if parsed.path.startswith("/api/session/") and parsed.path.endswith("/upload"): + session_name = parsed.path[len("/api/session/"):-len("/upload")].strip("/") + self._handle_upload(session_name) + return if parsed.path == "/api/sessions": self._handle_create_session() return @@ -1164,48 +1688,91 @@ def _handle_report(self, raw_name: str, params: dict[str, list[str]]) -> None: def _handle_create_session(self) -> None: body = self._read_json_body() - raw_name = (body or {}).get("name", "") if isinstance(body, dict) else "" - if not raw_name or not isinstance(raw_name, str): - self._send_json({"error": "Invalid session name: 'name' is required"}, status=422) + if not isinstance(body, dict): + self._send_json({"error": "Invalid request body"}, status=400) return - raw_name = raw_name.strip() - if not _is_safe_session_name(raw_name) and ( - "/" in raw_name or "\\" in raw_name or ".." in raw_name - ): - self._send_json({"error": "Invalid session name: path traversal not allowed"}, status=422) + raw_name = str(body.get("name") or "").strip() + operator = str(body.get("operator") or "").strip() or None + target = str(body.get("target") or "").strip() or None + platform = str(body.get("platform") or "").strip() or None + + try: + meta = create_session_scaffold( + raw_name, + operator=operator, + target=target, + platform=platform, + ) + except FileExistsError: + safe = sanitize_session_name(raw_name) or raw_name + self._send_json({"error": f"Session already exists: {safe!r}"}, status=409) + return + except ValueError as exc: + self._send_json({"error": str(exc)}, status=422) return + except Exception: + self._send_json({"error": "Failed to create session"}, status=500) + return + + payload = { + "session": meta.to_dict(), + "status": "active", + "url": f"/session/{quote(meta.session_name, safe='')}", + } + self._send_json(payload, status=201) - session_name = sanitize_session_name(raw_name) + def _handle_heartbeat_post(self, raw_name: str) -> None: + session_name = unquote(raw_name) if not _is_safe_session_name(session_name): - self._send_json({"error": "Invalid session name"}, status=422) + self._send_json({"error": "Invalid session name."}, status=400) return - sess_dir = get_sessions_dir() / session_name - if sess_dir.exists(): - self._send_json({"error": f"Session already exists: {session_name!r}"}, status=409) + if not sess_dir.exists(): + self._send_json({"error": "Session not found"}, status=404) return - logs_dir = sess_dir / "logs" - assets_dir = sess_dir / "assets" - screenshots_dir = sess_dir / "screenshots" - for directory in (logs_dir, assets_dir, screenshots_dir): - directory.mkdir(parents=True, exist_ok=True) + _session_heartbeats[session_name] = time.time() + self._send_json( + { + "status": "ok", + "session": session_name, + "expires_in": int(_HEARTBEAT_TTL_SECONDS), + } + ) - meta = SessionMeta( - session_name=session_name, - session_id=generate_session_id(), - start_time=iso_timestamp(), - hostname=socket.gethostname(), + def _handle_heartbeat_get(self, raw_name: str) -> None: + session_name = unquote(raw_name) + if not _is_safe_session_name(session_name): + self._send_json({"error": "Invalid session name."}, status=400) + return + sess_dir = get_sessions_dir() / session_name + if not sess_dir.exists(): + self._send_json({"error": "Session not found"}, status=404) + return + + status_label, last = _heartbeat_status(session_name) + last_beat_iso = None + if last is not None: + last_beat_iso = datetime.fromtimestamp(last, tz=timezone.utc).isoformat() + self._send_json( + { + "session": session_name, + "status": status_label, + "last_beat": last_beat_iso, + } ) - _write_jsonl_record(logs_dir / SESSION_LOG_NAME, meta.to_dict()) - self._send_json({"session_name": session_name, "created": True}, status=201) + def _stop_active_terminal(self, session_name: str) -> bool: + try: + return TERMINALS.stop_all(session_name) + except TerminalNotFound: + return False - def _handle_delete_session(self, raw_name: str) -> None: + def _handle_close_session(self, raw_name: str) -> None: session_name = unquote(raw_name) if not _is_safe_session_name(session_name): - self._send_json({"error": "Invalid session name"}, status=400) + self._send_json({"error": "Invalid session name."}, status=400) return sess_dir = get_sessions_dir() / session_name @@ -1213,11 +1780,125 @@ def _handle_delete_session(self, raw_name: str) -> None: self._send_json({"error": "Session not found"}, status=404) return - shutil.rmtree(str(sess_dir)) - self.send_response(204) - self.send_header("X-Content-Type-Options", "nosniff") - self.send_header("X-Frame-Options", "DENY") - self.end_headers() + try: + terminal_stopped = self._stop_active_terminal(session_name) + delete_session(session_name) + except ValueError as exc: + self._send_json({"error": str(exc)}, status=400) + return + except OSError as exc: + self._send_json({"error": str(exc)}, status=500) + return + + heartbeat_cleared = _session_heartbeats.pop(session_name, None) is not None + self._send_json( + { + "closed": session_name, + "terminal_stopped": terminal_stopped, + "heartbeat_cleared": heartbeat_cleared, + } + ) + + def _handle_upload(self, raw_name: str) -> None: + session_name = unquote(raw_name) + if not _is_safe_session_name(session_name): + self._send_json({"error": "Invalid session name."}, status=400) + return + + sess_dir = get_sessions_dir() / session_name + if not sess_dir.exists(): + self._send_json({"error": "Session not found"}, status=404) + return + + content_type = self.headers.get("Content-Type", "") + if not content_type.startswith("multipart/form-data"): + self._send_json({"error": "multipart/form-data required"}, status=400) + return + + env = { + "REQUEST_METHOD": "POST", + "CONTENT_TYPE": content_type, + "CONTENT_LENGTH": self.headers.get("Content-Length", "0"), + } + form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ=env, keep_blank_values=True) + if "file" not in form: + self._send_json({"error": "file field is required"}, status=400) + return + + upload = form["file"] + filename = getattr(upload, "filename", "") or "" + if not filename: + self._send_json({"error": "Filename is required"}, status=400) + return + data = upload.file.read(_MAX_UPLOAD_SIZE + 1) + if len(data) > _MAX_UPLOAD_SIZE: + self._send_json({"error": "File too large"}, status=413) + return + + mime = _detect_upload_type(filename, data) + if not mime: + suffix = Path(filename).suffix or filename + self._send_json({"error": f"Unsupported file type: {suffix}"}, status=415) + return + + uploads_dir = sess_dir / "assets" / "uploads" + uploads_dir.mkdir(parents=True, exist_ok=True) + safe_name = Path(filename).name + dest_path = uploads_dir / safe_name + dest_path.write_bytes(data) + asset_url = f"/api/session/{quote(session_name, safe='')}/asset/{quote(safe_name, safe='')}" + + self._send_json({"filename": safe_name, "url": asset_url, "content_type": mime}) + + def _handle_asset(self, raw_name: str, asset_path: str) -> None: + session_name = unquote(raw_name) + if not _is_safe_session_name(session_name): + self._send_text("Invalid session name", status=400) + return + + asset_path = unquote(asset_path) + uploads_dir = get_sessions_dir() / session_name / "assets" / "uploads" + target = uploads_dir / asset_path + try: + resolved_uploads = uploads_dir.resolve() + resolved_target = target.resolve(strict=False) + resolved_target.relative_to(resolved_uploads) + except (OSError, ValueError): + self._send_text("Not found", status=404) + return + + if not resolved_target.exists() or not resolved_target.is_file(): + self._send_text("Not found", status=404) + return + + mime = _ALLOWED_UPLOAD_TYPES.get(resolved_target.suffix.lower(), "application/octet-stream") + try: + payload = resolved_target.read_bytes() + except OSError: + self._send_text("Not found", status=404) + return + self._send_bytes(payload, content_type=mime) + + def _handle_delete_session(self, raw_name: str) -> None: + session_name = unquote(raw_name) + if not _is_safe_session_name(session_name): + self._send_json({"error": "Invalid session name."}, status=400) + return + + if not (get_sessions_dir() / session_name).exists(): + self._send_json({"error": "Session not found"}, status=404) + return + + try: + delete_session(session_name) + except ValueError as exc: + self._send_json({"error": "Invalid session name."}, status=400) + return + except (PermissionError, OSError) as exc: + self._send_json({"error": str(exc)}, status=500) + return + + self._send_json({"deleted": session_name}, status=200) def _handle_continue_session(self, raw_name: str) -> None: session_name = unquote(raw_name) @@ -1465,7 +2146,12 @@ def _send_bytes( self.wfile.write(content) -def create_server(host: str = "127.0.0.1", port: int = 1551) -> ThreadingHTTPServer: +def create_server( + host: str = "127.0.0.1", + port: int = 1551, + tls_certfile: str | None = None, + tls_keyfile: str | None = None, +) -> ThreadingHTTPServer: """Create a report server. Binds to ``host``. For safety, non-localhost hosts are rejected unless the @@ -1474,16 +2160,27 @@ def create_server(host: str = "127.0.0.1", port: int = 1551) -> ThreadingHTTPSer """ import os - if host != "127.0.0.1" and os.environ.get("GUILD_SCROLL_ALLOW_REMOTE") not in {"1", "true", "yes"}: - raise ValueError( - "gscroll serve only supports 127.0.0.1 for safety. " - "Set GUILD_SCROLL_ALLOW_REMOTE=1 to allow remote binding (e.g. inside Docker)." - ) - return ThreadingHTTPServer((host, port), GuildScrollRequestHandler) + allow_remote = os.environ.get("GUILD_SCROLL_ALLOW_REMOTE") in {"1", "true", "yes"} + if host != "127.0.0.1" and not allow_remote and not tls_certfile: + print(f"WARNING: binding to {host} without GUILD_SCROLL_ALLOW_REMOTE=1; ensure external network isolation.", flush=True) + + server = ThreadingHTTPServer((host, port), GuildScrollRequestHandler) + if tls_certfile or tls_keyfile: + if not tls_certfile or not tls_keyfile: + raise ValueError("Both tls_certfile and tls_keyfile are required to enable TLS.") + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.load_cert_chain(certfile=tls_certfile, keyfile=tls_keyfile) + ctx.set_ciphers("ECDHE+AESGCM:!aNULL:!eNULL:!MD5:!RC4") + server.socket = ctx.wrap_socket(server.socket, server_side=True) + print("TLS enabled for gscroll serve", flush=True) + return server def run_server(host: str = "127.0.0.1", port: int = 1551) -> None: - server = create_server(host=host, port=port) + from guild_scroll.web import create_server as _create_server + + server = _create_server(host=host, port=port) try: print(f"[gscroll] Serving reports on http://{host}:{server.server_address[1]}") server.serve_forever() diff --git a/src/guild_scroll/web/terminal.py b/src/guild_scroll/web/terminal.py index 1d04994..5da59e0 100644 --- a/src/guild_scroll/web/terminal.py +++ b/src/guild_scroll/web/terminal.py @@ -302,6 +302,18 @@ def stop(self, session_name: str, part: int = 1) -> None: with self._lock: self._sessions.pop(key, None) + def stop_all(self, session_name: str) -> bool: + stopped = False + with self._lock: + keys = [(name, part) for (name, part), proc in self._sessions.items() if name == session_name and proc.is_alive()] + for name, part in keys: + try: + self.stop(name, part=part) + stopped = True + except TerminalNotFound: + continue + return stopped + def read(self, session_name: str, part: int = 1) -> Tuple[bool, str]: proc = self.get(session_name, part) if not proc: diff --git a/tests/test_server.py b/tests/test_server.py index 92b9945..1b95db3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -78,7 +78,7 @@ def _request_with_headers(server, method, path, body=None): def test_create_server_signature(): sig = inspect.signature(create_server) params = sig.parameters - assert list(params) == ["host", "port"] + assert list(params) == ["host", "port", "tls_certfile", "tls_keyfile"] assert params["host"].default == "127.0.0.1" assert params["port"].default == 1551 @@ -244,7 +244,7 @@ def test_create_session_returns_201_and_creates_directory(self, isolated_session status, data = _request(server, "POST", "/api/sessions", body={"name": "new-session"}) assert status == 201 payload = json.loads(data) - assert payload["session_name"] == "new-session" + assert payload["session"]["session_name"] == "new-session" assert (sessions_dir / "new-session" / "logs" / "session.jsonl").exists() finally: server.shutdown() @@ -294,7 +294,7 @@ def test_delete_session_returns_204_and_removes_directory(self, isolated_session server, thread = _start_test_server() try: status, _ = _request(server, "DELETE", "/api/session/del-sess") - assert status == 204 + assert status == 200 assert not (sessions_dir / "del-sess").exists() finally: server.shutdown() diff --git a/tests/test_web.py b/tests/test_web.py index 1c2623a..02410c4 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1067,7 +1067,10 @@ def test_post_api_sessions_creates_session(self, isolated_sessions_dir): payload = json.loads(resp) assert status == 201 - assert payload["session"] == "web-created" + assert payload["session"]["session_name"] == "web-created" + assert payload["session"]["hostname"] + assert payload["session"]["start_time"] + assert payload["status"] == "active" assert "/session/web-created" in payload["url"] assert (sessions_dir / "web-created" / "logs" / SESSION_LOG_NAME).exists() @@ -1081,7 +1084,21 @@ def test_post_api_sessions_sanitizes_name(self, isolated_sessions_dir): payload = json.loads(resp) assert status == 201 - assert payload["session"] == "my-session" + assert payload["session"]["session_name"] == "my-session" + assert payload["session"]["start_time"] + + def test_post_api_sessions_accepts_metadata(self, isolated_sessions_dir): + get_sessions_dir().mkdir(parents=True, exist_ok=True) + + with _running_server() as server: + body = json.dumps({"name": "meta-run", "operator": "alice", "target": "10.0.0.1", "platform": "htb"}).encode() + status, _, resp = _request_post(server, "/api/sessions", body) + + payload = json.loads(resp) + assert status == 201 + assert payload["session"]["operator"] == "alice" + assert payload["session"]["target"] == "10.0.0.1" + assert payload["session"]["platform"] == "htb" def test_post_api_sessions_rejects_duplicate(self, isolated_sessions_dir): sessions_dir = get_sessions_dir() @@ -1104,7 +1121,7 @@ def test_post_api_sessions_requires_name(self, isolated_sessions_dir): status, _, resp = _request_post(server, "/api/sessions", body) payload = json.loads(resp) - assert status == 400 + assert status == 422 assert "name" in payload["error"].lower() def test_index_page_has_new_session_button(self, isolated_sessions_dir):