mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
Merge pull request #88 from SquidSec/feature/hosts-graph-locks-ui
feat: Assets host graph + session claim TTL
This commit is contained in:
+8
-6
@@ -223,7 +223,7 @@ The `/ops` console is an **app shell** (multi-page nav + context rail + dock).
|
||||
| Region | Purpose |
|
||||
|--------|---------|
|
||||
| **Top bar** | Host, online status, Connect, Refresh, **INKO** flyout |
|
||||
| **Left nav** | Dashboard - Sessions - Listeners - Payloads - **Profiles** - **Artifacts** - Post-Ex - Collab - **INKO** - Observe - Admin |
|
||||
| **Left nav** | Dashboard - Sessions - **Assets** - Listeners - Payloads - **Profiles** - **Artifacts** - Post-Ex - Collab - **INKO** - Observe - Admin |
|
||||
| **Main** | Active workspace for the selected nav item |
|
||||
| **Right rail** | Selected session context (claim, shell, task) |
|
||||
| **Bottom dock** | Live event stream + command output (resizable) |
|
||||
@@ -1151,19 +1151,21 @@ sc5 tokens revoke <id>
|
||||
|
||||
### What
|
||||
|
||||
Teams, **session claim/lock**, handoff packs, spectator snapshots, operator presence, team-scoped chat, and per-operator audit filters.
|
||||
Teams, **session claim/lock** (TTL + renew on activity), handoff packs, spectator snapshots, operator presence, team-scoped chat, per-operator audit filters, and the **Assets** host graph.
|
||||
|
||||
**UI:** Ops -> **Collab**.
|
||||
**UI:** Ops -> **Collab** (teams/chat) · Ops -> **Assets** (host graph).
|
||||
|
||||
### Why
|
||||
|
||||
Two operators must not stomp the same shell; shift changes need context; leads need read-only watch.
|
||||
Two operators must not stomp the same shell; shift changes need context; leads need read-only watch; operators need a host-centric map of implants/access.
|
||||
|
||||
### How
|
||||
|
||||
| Action | API / UI |
|
||||
|--------|----------|
|
||||
| Claim session | `POST /api/v1/sessions/{id}/claim` - Context -> Claim |
|
||||
| Host inventory / graph | `GET /api/v1/hosts` - Ops -> **Assets** |
|
||||
| Claim session | `POST /api/v1/sessions/{id}/claim` `{force?, ttl_sec?}` - Context -> Claim lock |
|
||||
| Force claim | same endpoint with `force: true` (admin) |
|
||||
| Release | `POST /api/v1/sessions/{id}/release` |
|
||||
| Handoff pack | `POST /api/v1/sessions/{id}/handoff` `{to, note}` |
|
||||
| Spectate | `GET /api/v1/sessions/{id}/spectator` |
|
||||
@@ -1171,7 +1173,7 @@ Two operators must not stomp the same shell; shift changes need context; leads n
|
||||
| Team chat | `POST /api/v1/collab/chat` with optional `team_id` |
|
||||
| My audit | `GET /api/v1/audit/me` or `?mine=true` |
|
||||
|
||||
Claim lock is enforced on shell, tasks, and file ops (admins bypass). Feature flag: `collab_teams`.
|
||||
Claim lock is enforced on shell, tasks, and file ops (admins bypass). Default TTL: `SQUIDC5_SESSION_CLAIM_TTL_SEC` (3600; `0` = no expiry). Feature flag: `collab_teams`.
|
||||
|
||||
### Example
|
||||
|
||||
|
||||
+148
-2
@@ -19,6 +19,7 @@ from squidc5.auth.tokens import (
|
||||
AuthContext,
|
||||
scope_catalog,
|
||||
)
|
||||
from squidc5.collab.teams import claim_info
|
||||
from squidc5.paths import web_file
|
||||
from squidc5.policy.engine import PolicyDecision
|
||||
|
||||
@@ -275,6 +276,7 @@ class HandoffRequest(BaseModel):
|
||||
|
||||
class ClaimRequest(BaseModel):
|
||||
force: bool = False
|
||||
ttl_sec: int | None = None # override default claim TTL; 0 = no expiry
|
||||
|
||||
|
||||
class PresenceHeartbeat(BaseModel):
|
||||
@@ -705,7 +707,141 @@ def build_api_router() -> APIRouter:
|
||||
else:
|
||||
filtered.append(r)
|
||||
rows = filtered
|
||||
return rows
|
||||
# Attach normalized claim info for UI locks
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
row = dict(r)
|
||||
meta = row.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
info = claim_info(meta if isinstance(meta, dict) else {})
|
||||
row["claim"] = info
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
@api.get("/hosts")
|
||||
async def list_hosts(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_scope("sessions:read", "admin")),
|
||||
) -> dict[str, Any]:
|
||||
"""Engagement host inventory: group sessions into host nodes for the Assets graph."""
|
||||
state = get_state(request)
|
||||
await state.policy.check_and_audit(auth, "sessions.list")
|
||||
await state.sessions.close_orphaned_shells()
|
||||
rows = await state.sessions.list(status=None)
|
||||
hosts: dict[str, dict[str, Any]] = {}
|
||||
edges: list[dict[str, str]] = []
|
||||
for r in rows:
|
||||
if r.get("status") == "closed":
|
||||
# still include closed for inventory but mark inactive
|
||||
pass
|
||||
meta = r.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
info = claim_info(meta)
|
||||
host_key = (r.get("hostname") or r.get("remote_addr") or r.get("id") or "unknown").strip()
|
||||
node = hosts.get(host_key)
|
||||
if not node:
|
||||
node = {
|
||||
"id": host_key,
|
||||
"label": host_key,
|
||||
"hostname": r.get("hostname"),
|
||||
"addrs": [],
|
||||
"sessions": [],
|
||||
"kinds": set(),
|
||||
"active": 0,
|
||||
"claimed_by": None,
|
||||
"os_info": r.get("os_info"),
|
||||
"usernames": set(),
|
||||
}
|
||||
hosts[host_key] = node
|
||||
addr = r.get("remote_addr")
|
||||
if addr and addr not in node["addrs"]:
|
||||
node["addrs"].append(addr)
|
||||
if r.get("os_info") and not node.get("os_info"):
|
||||
node["os_info"] = r.get("os_info")
|
||||
if r.get("username"):
|
||||
node["usernames"].add(r.get("username"))
|
||||
kind = r.get("kind") or "?"
|
||||
node["kinds"].add(kind)
|
||||
if r.get("status") == "active":
|
||||
node["active"] += 1
|
||||
if info.get("claimed_by") and not node.get("claimed_by"):
|
||||
node["claimed_by"] = info.get("claimed_by")
|
||||
node["sessions"].append(
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"kind": kind,
|
||||
"status": r.get("status"),
|
||||
"verified": r.get("verified"),
|
||||
"username": r.get("username"),
|
||||
"remote_addr": addr,
|
||||
"last_seen_at": r.get("last_seen_at"),
|
||||
"claim": info,
|
||||
}
|
||||
)
|
||||
# Simple edge: reverse_shell -> beacon on same host (access path)
|
||||
# represented later when serializing
|
||||
nodes = []
|
||||
for h in hosts.values():
|
||||
kinds = sorted(h["kinds"])
|
||||
nodes.append(
|
||||
{
|
||||
"id": h["id"],
|
||||
"label": h["label"],
|
||||
"hostname": h.get("hostname"),
|
||||
"addrs": h["addrs"],
|
||||
"os_info": h.get("os_info"),
|
||||
"usernames": sorted(h["usernames"]),
|
||||
"kinds": kinds,
|
||||
"active_sessions": h["active"],
|
||||
"session_count": len(h["sessions"]),
|
||||
"claimed_by": h.get("claimed_by"),
|
||||
"sessions": h["sessions"],
|
||||
}
|
||||
)
|
||||
# Edges between sessions on same host for graph layout
|
||||
sess = h["sessions"]
|
||||
for i, a in enumerate(sess):
|
||||
for b in sess[i + 1 :]:
|
||||
edges.append(
|
||||
{
|
||||
"source": a["id"],
|
||||
"target": b["id"],
|
||||
"host": h["id"],
|
||||
"rel": "co-host",
|
||||
}
|
||||
)
|
||||
# Also emit session-level nodes for drill-down graph
|
||||
session_nodes = []
|
||||
for h in nodes:
|
||||
for s in h["sessions"]:
|
||||
session_nodes.append(
|
||||
{
|
||||
"id": s["id"],
|
||||
"type": "session",
|
||||
"host": h["id"],
|
||||
"kind": s.get("kind"),
|
||||
"status": s.get("status"),
|
||||
"verified": s.get("verified"),
|
||||
"claimed_by": (s.get("claim") or {}).get("claimed_by"),
|
||||
"label": f"{s.get('kind')}:{(s.get('id') or '')[:8]}",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"hosts": nodes,
|
||||
"session_nodes": session_nodes,
|
||||
"edges": edges,
|
||||
"claim_ttl_sec": int(getattr(state.settings, "session_claim_ttl_sec", 0) or 0),
|
||||
}
|
||||
|
||||
@api.get("/sessions/{session_id}")
|
||||
async def get_session(
|
||||
@@ -718,7 +854,15 @@ def build_api_router() -> APIRouter:
|
||||
s = await state.sessions.get(session_id)
|
||||
if not s:
|
||||
raise HTTPException(404, "session not found")
|
||||
return s
|
||||
row = dict(s)
|
||||
meta = row.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
row["claim"] = claim_info(meta if isinstance(meta, dict) else {})
|
||||
return row
|
||||
|
||||
@api.post("/sessions/{session_id}/close")
|
||||
async def close_session(
|
||||
@@ -2579,12 +2723,14 @@ def build_api_router() -> APIRouter:
|
||||
"""M1: claim session lock (only claim holder or admin may task)."""
|
||||
state = get_state(request)
|
||||
force = bool(body and body.force)
|
||||
ttl = body.ttl_sec if body else None
|
||||
try:
|
||||
result = await state.teams.claim(
|
||||
session_id,
|
||||
auth.name,
|
||||
force=force,
|
||||
is_admin=auth.has_scope("admin"),
|
||||
ttl_sec=ttl,
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "session not found") from None
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from squidc5.collab.presence import PresenceService
|
||||
from squidc5.collab.teams import TeamService
|
||||
from squidc5.collab.teams import TeamService, claim_info
|
||||
|
||||
__all__ = ["PresenceService", "TeamService"]
|
||||
|
||||
__all__ = ["TeamService"]
|
||||
__all__ = ["PresenceService", "TeamService", "claim_info"]
|
||||
|
||||
+115
-20
@@ -21,9 +21,47 @@ def _meta(row: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return dict(meta) if isinstance(meta, dict) else {}
|
||||
|
||||
|
||||
def claim_info(meta: dict[str, Any], *, now: float | None = None) -> dict[str, Any]:
|
||||
"""Normalized claim fields for API/UI (handles expiry)."""
|
||||
ts = now if now is not None else time.time()
|
||||
claimed_by = meta.get("claimed_by")
|
||||
claimed_at = meta.get("claimed_at")
|
||||
expires_at = meta.get("claim_expires_at")
|
||||
expired = False
|
||||
if claimed_by and expires_at is not None:
|
||||
try:
|
||||
if float(expires_at) <= ts:
|
||||
expired = True
|
||||
claimed_by = None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
remaining = None
|
||||
if claimed_by and expires_at is not None:
|
||||
try:
|
||||
remaining = max(0.0, float(expires_at) - ts)
|
||||
except (TypeError, ValueError):
|
||||
remaining = None
|
||||
return {
|
||||
"claimed_by": claimed_by,
|
||||
"claimed_at": claimed_at,
|
||||
"claim_expires_at": expires_at if claimed_by else None,
|
||||
"claim_remaining_sec": remaining,
|
||||
"claim_expired": expired,
|
||||
"locked": bool(claimed_by),
|
||||
}
|
||||
|
||||
|
||||
class TeamService:
|
||||
def __init__(self, db: Database) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
db: Database,
|
||||
*,
|
||||
claim_ttl_sec: int = 3600,
|
||||
renew_on_activity: bool = True,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.claim_ttl_sec = max(0, int(claim_ttl_sec or 0))
|
||||
self.renew_on_activity = bool(renew_on_activity)
|
||||
|
||||
async def list_teams(self) -> list[dict[str, Any]]:
|
||||
return await self.db.list_teams()
|
||||
@@ -32,6 +70,33 @@ class TeamService:
|
||||
tid = await self.db.create_team(name, created_by)
|
||||
return {"id": tid, "name": name, "created_by": created_by}
|
||||
|
||||
def _expiry(self, claimed_at: float, ttl_sec: int | None) -> float | None:
|
||||
ttl = self.claim_ttl_sec if ttl_sec is None else max(0, int(ttl_sec))
|
||||
if ttl <= 0:
|
||||
return None
|
||||
return claimed_at + ttl
|
||||
|
||||
async def _clear_expired_claim(self, session_id: str, meta: dict[str, Any]) -> dict[str, Any]:
|
||||
"""If claim expired, clear lock fields and return updated meta."""
|
||||
info = claim_info(meta)
|
||||
if not info["claim_expired"]:
|
||||
return meta
|
||||
was = meta.get("claimed_by")
|
||||
meta.pop("claimed_by", None)
|
||||
meta.pop("claim_expires_at", None)
|
||||
meta["claim_expired_at"] = time.time()
|
||||
meta["claim_expired_was"] = was
|
||||
await self.db.update_session(session_id, metadata=meta)
|
||||
await self.db.audit(
|
||||
actor="system",
|
||||
actor_type="system",
|
||||
action="session.claim_expire",
|
||||
resource=session_id,
|
||||
details={"was": was},
|
||||
risk_score=1,
|
||||
)
|
||||
return meta
|
||||
|
||||
async def claim(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -39,35 +104,48 @@ class TeamService:
|
||||
*,
|
||||
force: bool = False,
|
||||
is_admin: bool = False,
|
||||
ttl_sec: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""M1: claim session lock. Only admin may force-steal."""
|
||||
"""Claim session lock. Only admin may force-steal. Optional per-claim TTL override."""
|
||||
row = await self.db.get_session(session_id)
|
||||
if not row:
|
||||
raise KeyError(session_id)
|
||||
meta = _meta(row)
|
||||
meta = await self._clear_expired_claim(session_id, _meta(row))
|
||||
current = meta.get("claimed_by") or meta.get("owner")
|
||||
if current and current != actor and not (force and is_admin):
|
||||
raise PermissionError(f"Session claimed by {current}")
|
||||
# owner alone does not block after release cleared claimed_by
|
||||
current_lock = meta.get("claimed_by")
|
||||
if current_lock and current_lock != actor and not (force and is_admin):
|
||||
raise PermissionError(f"Session claimed by {current_lock}")
|
||||
now = time.time()
|
||||
expires = self._expiry(now, ttl_sec)
|
||||
meta["claimed_by"] = actor
|
||||
meta["owner"] = actor
|
||||
meta["claimed_at"] = now
|
||||
if current and current != actor:
|
||||
meta["previous_claim"] = current
|
||||
if expires is not None:
|
||||
meta["claim_expires_at"] = expires
|
||||
else:
|
||||
meta.pop("claim_expires_at", None)
|
||||
if current_lock and current_lock != actor:
|
||||
meta["previous_claim"] = current_lock
|
||||
await self.db.update_session(session_id, metadata=meta)
|
||||
await self.db.audit(
|
||||
actor=actor,
|
||||
actor_type="operator",
|
||||
action="session.claim",
|
||||
resource=session_id,
|
||||
details={"force": bool(force and is_admin), "previous": current},
|
||||
details={
|
||||
"force": bool(force and is_admin),
|
||||
"previous": current_lock,
|
||||
"expires_at": expires,
|
||||
},
|
||||
risk_score=3 if force else 2,
|
||||
)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"claimed_by": actor,
|
||||
"claimed_at": now,
|
||||
"previous": current,
|
||||
"claim_expires_at": expires,
|
||||
"previous": current_lock or current,
|
||||
}
|
||||
|
||||
async def release(
|
||||
@@ -80,14 +158,14 @@ class TeamService:
|
||||
row = await self.db.get_session(session_id)
|
||||
if not row:
|
||||
raise KeyError(session_id)
|
||||
meta = _meta(row)
|
||||
current = meta.get("claimed_by") or meta.get("owner")
|
||||
meta = await self._clear_expired_claim(session_id, _meta(row))
|
||||
current = meta.get("claimed_by")
|
||||
if current and current != actor and not is_admin:
|
||||
raise PermissionError(f"Session claimed by {current}")
|
||||
meta.pop("claimed_by", None)
|
||||
meta.pop("claim_expires_at", None)
|
||||
meta["released_by"] = actor
|
||||
meta["released_at"] = time.time()
|
||||
# keep owner history lightly
|
||||
await self.db.update_session(session_id, metadata=meta)
|
||||
await self.db.audit(
|
||||
actor=actor,
|
||||
@@ -105,17 +183,25 @@ class TeamService:
|
||||
actor: str,
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
renew: bool = True,
|
||||
) -> None:
|
||||
"""Raise PermissionError if claim lock blocks actor."""
|
||||
"""Raise PermissionError if claim lock blocks actor. Renews TTL for holder."""
|
||||
if is_admin:
|
||||
return
|
||||
row = await self.db.get_session(session_id)
|
||||
if not row:
|
||||
raise KeyError(session_id)
|
||||
meta = _meta(row)
|
||||
meta = await self._clear_expired_claim(session_id, _meta(row))
|
||||
claimed = meta.get("claimed_by")
|
||||
if claimed and claimed != actor:
|
||||
raise PermissionError(f"Session claimed by {claimed}; claim or release first")
|
||||
if claimed and claimed == actor and renew and self.renew_on_activity:
|
||||
expires = meta.get("claim_expires_at")
|
||||
if expires is not None and self.claim_ttl_sec > 0:
|
||||
now = time.time()
|
||||
meta["claim_expires_at"] = now + self.claim_ttl_sec
|
||||
meta["claim_renewed_at"] = now
|
||||
await self.db.update_session(session_id, metadata=meta)
|
||||
team_id = meta.get("team_id")
|
||||
if team_id:
|
||||
members = await self.db.list_team_members(str(team_id))
|
||||
@@ -134,7 +220,7 @@ class TeamService:
|
||||
include_pack: bool = True,
|
||||
state: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""M2: handoff note + optional pack (tasks/output/ROE) + claim transfer."""
|
||||
"""Handoff note + optional pack + claim transfer."""
|
||||
row = await self.db.get_session(session_id)
|
||||
if not row:
|
||||
raise KeyError(session_id)
|
||||
@@ -155,10 +241,16 @@ class TeamService:
|
||||
|
||||
if transfer_claim and to_actor:
|
||||
meta = _meta(row)
|
||||
now = time.time()
|
||||
meta["claimed_by"] = to_actor
|
||||
meta["owner"] = to_actor
|
||||
meta["claimed_at"] = time.time()
|
||||
meta["claimed_at"] = now
|
||||
meta["handed_off_from"] = from_actor
|
||||
expires = self._expiry(now, None)
|
||||
if expires is not None:
|
||||
meta["claim_expires_at"] = expires
|
||||
else:
|
||||
meta.pop("claim_expires_at", None)
|
||||
await self.db.update_session(session_id, metadata=meta)
|
||||
|
||||
await self.db.audit(
|
||||
@@ -225,11 +317,12 @@ class TeamService:
|
||||
await self.db.set_session_owner(session_id, owner)
|
||||
|
||||
async def spectator_view(self, session_id: str, *, state: Any = None) -> dict[str, Any]:
|
||||
"""M3: read-only snapshot (no shell interact)."""
|
||||
"""Read-only snapshot (no shell interact)."""
|
||||
row = await self.db.get_session(session_id)
|
||||
if not row:
|
||||
raise KeyError(session_id)
|
||||
meta = _meta(row)
|
||||
meta = await self._clear_expired_claim(session_id, _meta(row))
|
||||
info = claim_info(meta)
|
||||
tasks: list[dict[str, Any]] = []
|
||||
try:
|
||||
rows = await self.db.list_tasks(session_id=session_id)
|
||||
@@ -261,8 +354,10 @@ class TeamService:
|
||||
"username": row.get("username"),
|
||||
"os_info": row.get("os_info"),
|
||||
"owner": meta.get("owner"),
|
||||
"claimed_by": meta.get("claimed_by"),
|
||||
"claimed_at": meta.get("claimed_at"),
|
||||
"claimed_by": info.get("claimed_by"),
|
||||
"claimed_at": info.get("claimed_at"),
|
||||
"claim_expires_at": info.get("claim_expires_at"),
|
||||
"claim_remaining_sec": info.get("claim_remaining_sec"),
|
||||
"team_id": meta.get("team_id"),
|
||||
"handoffs": await self.session_notes(session_id),
|
||||
"recent_tasks": tasks,
|
||||
|
||||
@@ -55,6 +55,9 @@ class Settings(BaseSettings):
|
||||
public_ip: str = "" # A-record for OAST DNS answers (SQUIDC5_PUBLIC_IP)
|
||||
shell_stabilize_delay_sec: float = 0.8
|
||||
shell_probe_wait_sec: float = 1.5
|
||||
# Session claim/lock TTL (0 = no expiry). Renewed on claim-holder write activity when enabled.
|
||||
session_claim_ttl_sec: int = 3600
|
||||
session_claim_renew_on_activity: bool = True
|
||||
# OAST Collaborator (SQUIDC5_OAST_*)
|
||||
oast_enabled: bool = True
|
||||
oast_zone: str = "oast.lab.invalid"
|
||||
@@ -77,7 +80,12 @@ class Settings(BaseSettings):
|
||||
raise ValueError("port must be 1-65535")
|
||||
return int(v)
|
||||
|
||||
@field_validator("max_body_bytes", "rate_limit_per_minute", "auth_fail_limit_per_minute")
|
||||
@field_validator(
|
||||
"max_body_bytes",
|
||||
"rate_limit_per_minute",
|
||||
"auth_fail_limit_per_minute",
|
||||
"session_claim_ttl_sec",
|
||||
)
|
||||
@classmethod
|
||||
def _non_negative(cls, v: int) -> int:
|
||||
if int(v) < 0:
|
||||
|
||||
+5
-1
@@ -73,7 +73,11 @@ async def build_state(settings: Settings) -> AppState:
|
||||
profiles = ProfileEngine(db)
|
||||
await profiles.load()
|
||||
implants = ImplantRegistry()
|
||||
teams = TeamService(db)
|
||||
teams = TeamService(
|
||||
db,
|
||||
claim_ttl_sec=int(getattr(settings, "session_claim_ttl_sec", 3600) or 0),
|
||||
renew_on_activity=bool(getattr(settings, "session_claim_renew_on_activity", True)),
|
||||
)
|
||||
from squidc5.collab.presence import PresenceService
|
||||
|
||||
presence = PresenceService(ttl_sec=90.0)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Assets host graph API + claim TTL + ops UI markers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from squidc5.collab.teams import TeamService, claim_info
|
||||
from squidc5.config import Settings
|
||||
from squidc5.main import create_app
|
||||
|
||||
ADMIN = "sc5_test_admin_token_bootstrap_hosts01"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosts_api_and_claim_ttl(tmp_path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "hg2",
|
||||
debug=True,
|
||||
mcp_enabled=False,
|
||||
admin_token_bootstrap=ADMIN,
|
||||
plugin_signing_secret="test-plugin-signing-secret-for-ci",
|
||||
implant_require_auth=False,
|
||||
rate_limit_per_minute=2000,
|
||||
session_claim_ttl_sec=120,
|
||||
)
|
||||
app = create_app(settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
h = {"Authorization": f"Bearer {ADMIN}"}
|
||||
b1 = await client.post(
|
||||
"/api/v1/implant/beacon",
|
||||
json={"hostname": "workstation-a", "username": "alice"},
|
||||
)
|
||||
assert b1.status_code == 200
|
||||
sid1 = b1.json()["session_id"]
|
||||
b2 = await client.post(
|
||||
"/api/v1/implant/beacon",
|
||||
json={"hostname": "workstation-a", "username": "bob"},
|
||||
)
|
||||
assert b2.status_code == 200
|
||||
sid2 = b2.json()["session_id"]
|
||||
b3 = await client.post(
|
||||
"/api/v1/implant/beacon",
|
||||
json={"hostname": "dc01", "username": "svc"},
|
||||
)
|
||||
assert b3.status_code == 200
|
||||
|
||||
hosts = await client.get("/api/v1/hosts", headers=h)
|
||||
assert hosts.status_code == 200
|
||||
body = hosts.json()
|
||||
assert body["claim_ttl_sec"] == 120
|
||||
by_id = {x["id"]: x for x in body["hosts"]}
|
||||
assert "workstation-a" in by_id
|
||||
assert "dc01" in by_id
|
||||
wa = by_id["workstation-a"]
|
||||
assert wa["session_count"] == 2
|
||||
assert set(wa["usernames"]) == {"alice", "bob"}
|
||||
assert "beacon" in wa["kinds"]
|
||||
assert isinstance(body.get("edges"), list)
|
||||
assert isinstance(body.get("session_nodes"), list)
|
||||
pair = {sid1, sid2}
|
||||
assert any(
|
||||
{e["source"], e["target"]} == pair and e.get("rel") == "co-host"
|
||||
for e in body["edges"]
|
||||
)
|
||||
|
||||
c = await client.post(f"/api/v1/sessions/{sid1}/claim", headers=h, json={})
|
||||
assert c.status_code == 200
|
||||
cj = c.json()
|
||||
assert cj["claimed_by"]
|
||||
assert cj.get("claim_expires_at") is not None
|
||||
|
||||
sess = await client.get(f"/api/v1/sessions/{sid1}", headers=h)
|
||||
assert sess.status_code == 200
|
||||
claim = sess.json().get("claim") or {}
|
||||
assert claim.get("locked") is True
|
||||
assert claim.get("claim_remaining_sec") is not None
|
||||
|
||||
hosts2 = await client.get("/api/v1/hosts", headers=h)
|
||||
wa2 = {x["id"]: x for x in hosts2.json()["hosts"]}["workstation-a"]
|
||||
assert wa2.get("claimed_by")
|
||||
|
||||
c2 = await client.post(
|
||||
f"/api/v1/sessions/{sid2}/claim",
|
||||
headers=h,
|
||||
json={"ttl_sec": 1},
|
||||
)
|
||||
assert c2.status_code == 200
|
||||
time.sleep(1.2)
|
||||
ts: TeamService = app.state.app_state.teams
|
||||
await ts.assert_write_access(sid2, "other-op", is_admin=False)
|
||||
await ts.assert_write_access(sid2, "other-op")
|
||||
|
||||
|
||||
def test_claim_info_unit():
|
||||
now = 1_000_000.0
|
||||
open_lock = claim_info(
|
||||
{"claimed_by": "alice", "claimed_at": now - 10, "claim_expires_at": now + 50},
|
||||
now=now,
|
||||
)
|
||||
assert open_lock["locked"] is True
|
||||
assert open_lock["claim_remaining_sec"] == 50
|
||||
dead = claim_info(
|
||||
{"claimed_by": "alice", "claim_expires_at": now - 1},
|
||||
now=now,
|
||||
)
|
||||
assert dead["locked"] is False
|
||||
assert dead["claim_expired"] is True
|
||||
forever = claim_info({"claimed_by": "bob", "claimed_at": now}, now=now)
|
||||
assert forever["locked"] is True
|
||||
assert forever["claim_expires_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosts_ui_markers(tmp_path):
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "hg3",
|
||||
debug=True,
|
||||
mcp_enabled=False,
|
||||
admin_token_bootstrap=ADMIN,
|
||||
plugin_signing_secret="test-plugin-signing-secret-for-ci",
|
||||
rate_limit_per_minute=2000,
|
||||
)
|
||||
app = create_app(settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
h = {"Authorization": f"Bearer {ADMIN}"}
|
||||
r = await client.get("/api/v1/ops/admin.js", headers=h)
|
||||
assert r.status_code == 200
|
||||
js = r.text
|
||||
for m in (
|
||||
"renderHostsView",
|
||||
"drawHostGraph",
|
||||
"/api/v1/hosts",
|
||||
"hostGraph",
|
||||
"ctxForceClaim",
|
||||
"claimChipHtml",
|
||||
):
|
||||
assert m in js, m
|
||||
html = await client.get("/ops")
|
||||
assert html.status_code == 200
|
||||
assert 'data-view="hosts"' in html.text
|
||||
assert 'id="view-hosts"' in html.text
|
||||
@@ -207,12 +207,16 @@ async def test_ops_admin_collab_ui_markers(tmp_path):
|
||||
"view-sessions",
|
||||
"view-collab",
|
||||
"ctxClaim",
|
||||
"ctxForceClaim",
|
||||
"/api/v1/sessions/",
|
||||
"/api/v1/hosts",
|
||||
"claim",
|
||||
"handoff",
|
||||
"presence",
|
||||
"selectSession",
|
||||
"renderSessionsView",
|
||||
"renderHostsView",
|
||||
"drawHostGraph",
|
||||
):
|
||||
assert m in js, m
|
||||
|
||||
|
||||
+242
-7
@@ -52,6 +52,10 @@
|
||||
{ a: "tasks", t: "Tasks / beacons" },
|
||||
{ a: "verified-reverse-shells", t: "Verified shells" },
|
||||
],
|
||||
hosts: [
|
||||
{ a: "sessions", t: "Sessions / hosts" },
|
||||
{ a: "multi-operator-collab", t: "Session locks" },
|
||||
],
|
||||
listeners: [
|
||||
{ a: "listeners", t: "Listeners" },
|
||||
{ a: "oast-collaborator", t: "OAST" },
|
||||
@@ -448,16 +452,28 @@
|
||||
if (el("ctxClaim")) el("ctxClaim").onclick = async () => {
|
||||
try {
|
||||
const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/claim`, {});
|
||||
showOk("Claimed");
|
||||
showOk("Lock claimed");
|
||||
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
|
||||
if (window.__SC5_refresh) await window.__SC5_refresh();
|
||||
renderContext(true);
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
if (el("ctxForceClaim")) el("ctxForceClaim").onclick = async () => {
|
||||
if (!confirm("Force-steal lock from current holder?")) return;
|
||||
try {
|
||||
const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/claim`, { force: true });
|
||||
showOk("Force claimed");
|
||||
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
|
||||
if (window.__SC5_refresh) await window.__SC5_refresh();
|
||||
renderContext(true);
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
if (el("ctxRelease")) el("ctxRelease").onclick = async () => {
|
||||
try {
|
||||
await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/release`);
|
||||
showOk("Released");
|
||||
showOk("Lock released");
|
||||
if (window.__SC5_refresh) await window.__SC5_refresh();
|
||||
renderContext(true);
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
if (el("ctxSpectate")) el("ctxSpectate").onclick = async () => {
|
||||
@@ -504,6 +520,23 @@
|
||||
return;
|
||||
}
|
||||
const m = metaOf(s);
|
||||
const claim = s.claim || {
|
||||
claimed_by: m.claimed_by,
|
||||
claim_expires_at: m.claim_expires_at,
|
||||
claim_remaining_sec: m.claim_remaining_sec,
|
||||
locked: !!m.claimed_by,
|
||||
};
|
||||
function claimChipHtml(c) {
|
||||
if (!c || !c.claimed_by) return '<span class="chip">unlocked</span>';
|
||||
let extra = "";
|
||||
if (c.claim_remaining_sec != null && c.claim_expires_at) {
|
||||
const mleft = Math.max(0, Math.ceil(Number(c.claim_remaining_sec) / 60));
|
||||
extra = " · " + mleft + "m left";
|
||||
} else if (c.claim_expires_at == null && c.claimed_by) {
|
||||
extra = " · no timeout";
|
||||
}
|
||||
return `<span class="chip warn">locked: ${esc(c.claimed_by)}${esc(extra)}</span>`;
|
||||
}
|
||||
// Soft update: keep form fields if same session already bound
|
||||
if (!force && ctxBoundSid === selectedId && el("ctxMeta")) {
|
||||
el("ctxMeta").innerHTML = `
|
||||
@@ -512,7 +545,7 @@
|
||||
<div class="chips" style="margin-bottom:10px">
|
||||
<span class="chip">${esc(s.kind || "?")}</span>
|
||||
<span class="chip ${s.verified ? "ok" : ""}">${s.verified ? "verified" : esc(s.status || "")}</span>
|
||||
${m.claimed_by ? `<span class="chip warn">${esc(m.claimed_by)}</span>` : '<span class="chip">unlocked</span>'}
|
||||
${claimChipHtml(claim)}
|
||||
</div>
|
||||
<div class="muted" style="font-size:0.78rem;margin-bottom:8px">
|
||||
User: ${esc(s.username || "-")}<br/>OS: ${esc(s.os_info || "-")}<br/>Addr: ${esc(s.remote_addr || "-")}
|
||||
@@ -520,12 +553,14 @@
|
||||
return;
|
||||
}
|
||||
const shellOk = can("shell:interact") && (s.kind === "reverse_shell" || s.interactive || s.verified);
|
||||
const canLock = can("shell:interact") || can("collab:use") || can("admin");
|
||||
body.innerHTML = `
|
||||
<div id="ctxMeta"></div>
|
||||
<div class="row">
|
||||
${can("shell:interact") || can("collab:use") ? '<button type="button" class="primary" id="ctxClaim">Claim</button>' : ""}
|
||||
${can("shell:interact") || can("collab:use") ? '<button type="button" id="ctxRelease">Release</button>' : ""}
|
||||
${can("sessions:read") ? '<button type="button" id="ctxSpectate">Spectate</button>' : ""}
|
||||
${canLock ? '<button type="button" class="primary" id="ctxClaim">Claim lock</button>' : ""}
|
||||
${canLock ? '<button type="button" class="ghost" id="ctxRelease">Release</button>' : ""}
|
||||
${can("admin") ? '<button type="button" class="danger sm" id="ctxForceClaim">Force claim</button>' : ""}
|
||||
${can("sessions:read") ? '<button type="button" class="ghost" id="ctxSpectate">Spectate</button>' : ""}
|
||||
</div>
|
||||
${shellOk ? `
|
||||
<label for="ctxCmd">Shell command</label>
|
||||
@@ -549,7 +584,7 @@
|
||||
<div class="chips" style="margin-bottom:10px">
|
||||
<span class="chip">${esc(s.kind || "?")}</span>
|
||||
<span class="chip ${s.verified ? "ok" : ""}">${s.verified ? "verified" : esc(s.status || "")}</span>
|
||||
${m.claimed_by ? `<span class="chip warn">${esc(m.claimed_by)}</span>` : '<span class="chip">unlocked</span>'}
|
||||
${claimChipHtml(claim)}
|
||||
</div>
|
||||
<div class="muted" style="font-size:0.78rem;margin-bottom:8px">
|
||||
User: ${esc(s.username || "-")}<br/>OS: ${esc(s.os_info || "-")}<br/>Addr: ${esc(s.remote_addr || "-")}
|
||||
@@ -2602,11 +2637,210 @@
|
||||
}
|
||||
|
||||
|
||||
/* -- Assets / hosts graph -- */
|
||||
let _hostsCache = { hosts: [], edges: [], claim_ttl_sec: 0, selected: null };
|
||||
|
||||
function renderHostsView(force) {
|
||||
const root = el("view-hosts");
|
||||
if (!root) return;
|
||||
if (!force && viewBuilt.hosts && root.querySelector("#hostGraph")) {
|
||||
loadHostsGraph();
|
||||
return;
|
||||
}
|
||||
root.innerHTML = `
|
||||
<div class="split" style="grid-template-columns: minmax(260px, 340px) 1fr">
|
||||
<div class="list-panel">
|
||||
<div class="lp-head">Hosts
|
||||
<button type="button" class="ghost sm" id="hostReload" style="margin-left:auto">Reload</button>
|
||||
</div>
|
||||
<div class="lp-body"><table class="data"><thead><tr>
|
||||
<th>Host</th><th>Sessions</th><th>Lock</th>
|
||||
</tr></thead><tbody id="hostTbody"></tbody></table></div>
|
||||
</div>
|
||||
<div class="work-panel">
|
||||
<div class="wp-head">Asset graph <span class="muted" id="hostGraphMeta" style="font-weight:400;margin-left:8px;font-size:0.72rem"></span></div>
|
||||
<div class="wp-body" style="display:flex;flex-direction:column;min-height:0;height:100%">
|
||||
<p class="muted" style="font-size:0.75rem;margin:0 0 8px">Compromised hosts as nodes. Pink = active access; amber = session lock held. Click host for implants; click a session row to open the lock rail.</p>
|
||||
<div class="chips" style="margin-bottom:8px">
|
||||
<span class="chip ok">active</span>
|
||||
<span class="chip warn">locked</span>
|
||||
<span class="chip">idle / closed only</span>
|
||||
</div>
|
||||
<div id="hostGraph" style="flex:1;min-height:300px;border:1px solid var(--border);border-radius:10px;background:#0a0a10;position:relative;overflow:hidden"></div>
|
||||
<div id="hostDetail" class="outbox empty" style="margin-top:10px;max-height:240px;overflow:auto">Select a host</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
viewBuilt.hosts = true;
|
||||
if (el("hostReload")) el("hostReload").onclick = () => loadHostsGraph();
|
||||
loadHostsGraph();
|
||||
}
|
||||
|
||||
async function loadHostsGraph() {
|
||||
const tbody = el("hostTbody");
|
||||
const graph = el("hostGraph");
|
||||
const detail = el("hostDetail");
|
||||
if (!tbody || !graph) return;
|
||||
try {
|
||||
const data = await api("GET", "/api/v1/hosts");
|
||||
const hosts = data.hosts || [];
|
||||
_hostsCache = {
|
||||
hosts,
|
||||
edges: data.edges || [],
|
||||
claim_ttl_sec: data.claim_ttl_sec || 0,
|
||||
selected: _hostsCache.selected,
|
||||
};
|
||||
if (el("hostGraphMeta")) {
|
||||
const ttl = _hostsCache.claim_ttl_sec;
|
||||
el("hostGraphMeta").textContent =
|
||||
hosts.length + " host(s) · claim TTL " + (ttl > 0 ? Math.round(ttl / 60) + "m" : "off");
|
||||
}
|
||||
tbody.innerHTML = hosts.map((h) => {
|
||||
const lock = h.claimed_by
|
||||
? `<span class="chip warn">${esc(h.claimed_by)}</span>`
|
||||
: `<span class="chip">-</span>`;
|
||||
return `<tr data-host="${esc(h.id)}">
|
||||
<td><strong>${esc(h.label)}</strong>
|
||||
<div class="muted mono" style="font-size:0.65rem">${esc((h.addrs || []).join(", ") || "")}</div>
|
||||
</td>
|
||||
<td>${esc(String(h.active_sessions || 0))}/${esc(String(h.session_count || 0))}</td>
|
||||
<td>${lock}</td>
|
||||
</tr>`;
|
||||
}).join("") || '<tr><td colspan="3" class="muted">No hosts yet — catch a beacon or shell</td></tr>';
|
||||
const pick = (id) => {
|
||||
tbody.querySelectorAll("tr").forEach((x) => {
|
||||
x.classList.toggle("selected", x.getAttribute("data-host") === id);
|
||||
});
|
||||
const h = hosts.find((x) => x.id === id);
|
||||
_hostsCache.selected = id;
|
||||
if (h) showHostDetail(h, detail);
|
||||
drawHostGraph(graph, hosts, pick, id);
|
||||
};
|
||||
tbody.querySelectorAll("tr[data-host]").forEach((tr) => {
|
||||
tr.onclick = () => pick(tr.getAttribute("data-host"));
|
||||
});
|
||||
const sel = _hostsCache.selected && hosts.some((h) => h.id === _hostsCache.selected)
|
||||
? _hostsCache.selected
|
||||
: null;
|
||||
drawHostGraph(graph, hosts, pick, sel);
|
||||
if (sel) {
|
||||
const h = hosts.find((x) => x.id === sel);
|
||||
if (h) showHostDetail(h, detail);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(String(e.message || e));
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="muted">Failed to load hosts</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function showHostDetail(h, detail) {
|
||||
if (!detail || !h) return;
|
||||
detail.classList.remove("empty");
|
||||
const sess = (h.sessions || []).map((s) => {
|
||||
const c = s.claim || {};
|
||||
const lock = c.claimed_by ? c.claimed_by : "-";
|
||||
const left = (c.claim_remaining_sec != null)
|
||||
? ` · ${Math.ceil(c.claim_remaining_sec / 60)}m`
|
||||
: "";
|
||||
return `<tr data-sid="${esc(s.id)}" style="cursor:pointer">
|
||||
<td class="mono">${esc(String(s.id || "").slice(0, 12))}</td>
|
||||
<td>${esc(s.kind || "")}</td>
|
||||
<td>${esc(s.status || "")}${s.verified ? " ✓" : ""}</td>
|
||||
<td>${esc(s.username || "-")}</td>
|
||||
<td>${c.claimed_by ? `<span class="chip warn">${esc(lock)}${esc(left)}</span>` : '<span class="chip">unlocked</span>'}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
detail.innerHTML = `
|
||||
<div style="margin-bottom:8px">
|
||||
<strong style="font-size:1rem">${esc(h.label)}</strong>
|
||||
<div class="muted" style="font-size:0.78rem;margin-top:4px">
|
||||
OS: ${esc(h.os_info || "-")} · Addrs: ${esc((h.addrs || []).join(", ") || "-")}<br/>
|
||||
Users: ${esc((h.usernames || []).join(", ") || "-")} · Kinds: ${esc((h.kinds || []).join(", "))}
|
||||
</div>
|
||||
</div>
|
||||
<table class="data"><thead><tr>
|
||||
<th>Session</th><th>Kind</th><th>Status</th><th>User</th><th>Lock</th>
|
||||
</tr></thead><tbody>${sess || '<tr><td colspan="5" class="muted">No sessions</td></tr>'}</tbody></table>`;
|
||||
detail.querySelectorAll("tr[data-sid]").forEach((tr) => {
|
||||
tr.onclick = () => {
|
||||
const sid = tr.getAttribute("data-sid");
|
||||
if (sid) selectSession(sid);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function drawHostGraph(container, hosts, onClick, selectedId) {
|
||||
if (!container) return;
|
||||
const w = Math.max(320, container.clientWidth || 480);
|
||||
const hgt = Math.max(280, container.clientHeight || 320);
|
||||
if (!hosts.length) {
|
||||
container.innerHTML = `<div class="empty-state" style="height:100%;display:flex;align-items:center;justify-content:center;margin:0">
|
||||
<div><strong>No host nodes</strong><div class="muted" style="margin-top:6px">Beacons and shells appear here grouped by hostname / remote address.</div></div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const n = hosts.length;
|
||||
const cx = w / 2;
|
||||
const cy = hgt / 2;
|
||||
const R = Math.min(w, hgt) * (n === 1 ? 0 : 0.34);
|
||||
const nodes = hosts.map((host, i) => {
|
||||
const ang = (i / n) * Math.PI * 2 - Math.PI / 2;
|
||||
return {
|
||||
host,
|
||||
x: n === 1 ? cx : cx + Math.cos(ang) * R,
|
||||
y: n === 1 ? cy : cy + Math.sin(ang) * R,
|
||||
};
|
||||
});
|
||||
const byId = Object.fromEntries(nodes.map((nd) => [nd.host.id, nd]));
|
||||
let svg = `<svg width="100%" height="100%" viewBox="0 0 ${w} ${hgt}" xmlns="http://www.w3.org/2000/svg">`;
|
||||
// Hub spokes + ring for multi-host engagement topology
|
||||
if (n > 1) {
|
||||
nodes.forEach((nd) => {
|
||||
svg += `<line x1="${cx}" y1="${cy}" x2="${nd.x}" y2="${nd.y}" stroke="rgba(233,30,140,0.12)" stroke-width="1" stroke-dasharray="4 4"/>`;
|
||||
});
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[(i + 1) % n];
|
||||
svg += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="rgba(233,30,140,0.18)" stroke-width="1.5"/>`;
|
||||
}
|
||||
}
|
||||
// Co-host session edges collapsed to host pairs (thicker when many sessions share host - already same node)
|
||||
// Cross-host: none from API; keep topology visual only
|
||||
void byId;
|
||||
nodes.forEach((node, idx) => {
|
||||
const host = node.host;
|
||||
const active = (host.active_sessions || 0) > 0;
|
||||
const locked = !!host.claimed_by;
|
||||
const sel = selectedId && host.id === selectedId;
|
||||
const fill = locked ? "rgba(251,191,36,0.28)" : active ? "rgba(233,30,140,0.38)" : "rgba(255,255,255,0.07)";
|
||||
const stroke = sel ? "#fff" : locked ? "rgba(251,191,36,0.9)" : "rgba(233,30,140,0.7)";
|
||||
const sw = sel ? 3 : 2;
|
||||
const r = 20 + Math.min(16, (host.session_count || 1) * 3);
|
||||
const label = (host.label || host.id || "?").slice(0, 20);
|
||||
const sub = (host.active_sessions || 0) + "/" + (host.session_count || 0);
|
||||
svg += `<g class="host-node" data-idx="${idx}" style="cursor:pointer">
|
||||
<circle cx="${node.x}" cy="${node.y}" r="${r + 4}" fill="none" stroke="${sel ? "rgba(233,30,140,0.35)" : "transparent"}" stroke-width="6"/>
|
||||
<circle cx="${node.x}" cy="${node.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"/>
|
||||
<text x="${node.x}" y="${node.y + 4}" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">${esc(sub)}</text>
|
||||
<text x="${node.x}" y="${node.y + r + 16}" text-anchor="middle" fill="#c8c8d4" font-size="11" font-family="ui-monospace,monospace">${esc(label)}</text>
|
||||
</g>`;
|
||||
});
|
||||
svg += "</svg>";
|
||||
container.innerHTML = svg;
|
||||
container.querySelectorAll(".host-node").forEach((g) => {
|
||||
g.onclick = () => {
|
||||
const i = Number(g.getAttribute("data-idx"));
|
||||
if (nodes[i] && onClick) onClick(nodes[i].host.id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* -- View router -- */
|
||||
function renderView(name) {
|
||||
// Soft by default - preserve form focus/values; only build once per view
|
||||
switch (name) {
|
||||
case "sessions": renderSessionsView(false); break;
|
||||
case "hosts": renderHostsView(false); break;
|
||||
case "listeners": renderListenersView(false); break;
|
||||
case "payloads": renderPayloadsView(false); break;
|
||||
case "profiles": renderProfilesView(false); break;
|
||||
@@ -2643,6 +2877,7 @@
|
||||
renderSessionsView(false);
|
||||
if (el("tskList") && !typing) loadTasksPanel();
|
||||
}
|
||||
if (currentIs("hosts") && !typing) renderHostsView(false);
|
||||
if (currentIs("listeners")) renderListenersView(false);
|
||||
if (el("pxSid") && !typing) el("pxSid").textContent = selectedId || "(none - pick in Sessions)";
|
||||
document.querySelectorAll("tr[data-sid]").forEach((tr) => {
|
||||
|
||||
@@ -40,16 +40,25 @@
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button {
|
||||
cursor: pointer; border: 1px solid var(--border2); background: var(--bg3);
|
||||
border-radius: 6px; padding: 6px 12px; color: var(--text);
|
||||
border-radius: 6px; padding: 6px 12px; min-height: 32px; color: var(--text);
|
||||
font-size: 0.82rem; font-weight: 500; line-height: 1.2;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
button:hover { border-color: rgba(233,30,140,0.45); background: #22222c; }
|
||||
button.primary { background: rgba(233,30,140,0.2); border-color: rgba(233,30,140,0.5); color: var(--pink2); font-weight: 600; }
|
||||
button.primary:hover { background: rgba(233,30,140,0.35); }
|
||||
button.danger { border-color: rgba(248,113,113,0.4); color: var(--bad); }
|
||||
button.danger { border-color: rgba(248,113,113,0.4); color: var(--bad); background: rgba(248,113,113,0.08); }
|
||||
button.danger:hover { background: rgba(248,113,113,0.16); }
|
||||
button.ghost {
|
||||
background: transparent; border-color: var(--border); color: var(--muted); font-weight: 500;
|
||||
}
|
||||
button.ghost:hover { color: var(--text); border-color: rgba(233,30,140,0.4); background: rgba(255,255,255,0.03); }
|
||||
button.sm { padding: 4px 10px; min-height: 28px; font-size: 0.75rem; }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
button.active, .nav-item.active {
|
||||
background: rgba(233,30,140,0.18); border-color: rgba(233,30,140,0.4); color: var(--pink2);
|
||||
}
|
||||
.toolbar button, .row button { flex-shrink: 0; }
|
||||
input, select, textarea {
|
||||
width: 100%; background: #0e0e14; border: 1px solid var(--border2);
|
||||
border-radius: 6px; padding: 7px 10px; outline: none;
|
||||
@@ -986,6 +995,7 @@
|
||||
<div class="side-nav" id="sideNav">
|
||||
<button type="button" class="nav-item active" data-view="dashboard">Dashboard</button>
|
||||
<button type="button" class="nav-item" data-view="sessions">Sessions <span class="badge-n" id="navNSes">0</span></button>
|
||||
<button type="button" class="nav-item" data-view="hosts">Assets</button>
|
||||
<button type="button" class="nav-item" data-view="listeners">Listeners <span class="badge-n" id="navNLis">0</span></button>
|
||||
<button type="button" class="nav-item" data-view="payloads">Payloads</button>
|
||||
<button type="button" class="nav-item" data-view="profiles">Profiles</button>
|
||||
@@ -1039,6 +1049,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="view" id="view-sessions"></div>
|
||||
<div class="view" id="view-hosts"></div>
|
||||
<div class="view" id="view-listeners"></div>
|
||||
<div class="view" id="view-payloads"></div>
|
||||
<div class="view" id="view-profiles"></div>
|
||||
@@ -1212,6 +1223,7 @@
|
||||
/* Nav data-view -> any-of scopes (dashboard always shown when connected) */
|
||||
const NAV_GATES = {
|
||||
sessions: ["sessions:read"],
|
||||
hosts: ["sessions:read"],
|
||||
listeners: ["listeners:read"],
|
||||
payloads: ["payloads:generate"],
|
||||
profiles: ["profiles:read"],
|
||||
@@ -1239,7 +1251,7 @@
|
||||
const need = NAV_GATES[preferred];
|
||||
if (!need || canAny(need)) return preferred;
|
||||
const order = [
|
||||
"sessions", "listeners", "payloads", "profiles", "artifacts",
|
||||
"sessions", "hosts", "listeners", "payloads", "profiles", "artifacts",
|
||||
"postex", "collab", "ai", "observe", "admin",
|
||||
];
|
||||
for (const v of order) {
|
||||
@@ -1275,6 +1287,7 @@
|
||||
const VIEWS = {
|
||||
dashboard: { title: "Dashboard", sub: "Teamserver health and shortcuts", doc: "status-overview" },
|
||||
sessions: { title: "Sessions", sub: "Beacons and reverse shells", doc: "sessions" },
|
||||
hosts: { title: "Assets", sub: "Host graph - implants, access, system info", doc: "sessions" },
|
||||
listeners: { title: "Listeners", sub: "Bind ports and manage acceptors", doc: "listeners" },
|
||||
payloads: { title: "Payloads", sub: "Generate implants and templates", doc: "payloads-and-implants" },
|
||||
profiles: { title: "Profiles", sub: "Malleable C2 profiles - activate, edit, push", doc: "c2-profiles" },
|
||||
|
||||
Reference in New Issue
Block a user