Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.git
.github
.venv
venv
__pycache__
*.pyc
.pytest_cache
.runtime
.runtime-cache
logs
*.db
*.sqlite
*.sqlite3
*.log
*.zip
*.pcap
*.pcapng
node_modules
frontend/node_modules
desktop/electron/node_modules
frontend/dist
desktop/electron/dist
desktop/out
build
dist
.env
.env.*
!.env.example
.codex_tmp_stage2

2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Run backend tests
run: python -m unittest discover -s tests -v
- name: Run CI-safe real-load benchmark
run: python benchmarks/long_soak_runner.py --profile light_desktop --duration-sec 5 --events-per-sec 100 --flows 10 --websocket-clients 1 --sample-interval-sec 0.25 --ci-safe --output .runtime/benchmarks/ci-safe-real-load
run: python benchmarks/long_soak_runner.py --profile light_desktop --duration-sec 5 --events-per-sec 100 --flows 10 --websocket-clients 1 --sample-interval-sec 0.25 --max-cpu-avg-percent 100 --max-cpu-peak-percent 1000 --ci-safe --output .runtime/benchmarks/ci-safe-real-load

frontend-build:
name: Frontend Build (${{ matrix.os }})
Expand Down
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
FROM python:3.12-slim AS backend

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NETBOT_PROFILE=server \
NETBOT_HOST=0.0.0.0 \
NETBOT_PORT=8000 \
NETBOT_RUNTIME_DIR=/var/lib/netbotpro \
NETBOT_LOG_DIR=/var/log/netbotpro \
NETBOT_ENABLE_LIVE_CAPTURE=false

WORKDIR /opt/netbotpro

RUN groupadd --system netbotpro && useradd --system --gid netbotpro --home-dir /var/lib/netbotpro netbotpro

COPY requirements.txt ./
RUN python -m pip install --no-cache-dir --upgrade pip && \
python -m pip install --no-cache-dir -r requirements.txt

COPY backend ./backend
COPY core ./core
COPY config ./config
COPY agent ./agent
COPY log_manager.py core_sniffer.py ./

RUN mkdir -p /var/lib/netbotpro /var/log/netbotpro && \
chown -R netbotpro:netbotpro /var/lib/netbotpro /var/log/netbotpro /opt/netbotpro

USER netbotpro
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import json,urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)); raise SystemExit(0 if data.get('ok') else 1)"

CMD ["python", "-m", "uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]

10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ monitoring platform. It combines packet inspection, redacted reporting,
authorized Remote Sensor capture, and summary-only Agent/Fleet monitoring in a
single operator dashboard.

Runtime profiles are explicit: `desktop` preserves the current local/Electron
behavior, `server` enables strict Linux API/UI deployment validation, and
`sensor`/`agent` remain metadata-only remote node modes. See
[Linux Server Deployment](docs/LINUX_SERVER_DEPLOYMENT.md) for systemd,
Docker/Compose, Nginx, Caddy, HTTPS, live-capture permission notes, and
health/readiness checks.

Agent Mode is intentionally read-only. It sends health, network, capture,
alert, flow, and risk summaries without forwarding raw packets, raw payloads,
or PCAP artifacts.
Expand Down Expand Up @@ -54,6 +61,7 @@ or PCAP artifacts.
| Remote Sensor Mode | Controlled opt-in | Capture-mode, sensor-script, and policy tests are included. | Requires explicit remote access, strong token, allowlist controls, and owner/admin permission. |
| Full and Forensic capture | Guarded opt-in | Safe-use, allow-full, duration, and raw-export gates are tested. | Authorized servers only; raw PCAP export is never available in Metadata mode. |
| Agent and Fleet Mode | Ready, read-only | Registry/API/history tests plus Agents UI tests run in CI. | Summary telemetry only; no command/control, file collection, raw packet, payload, or PCAP forwarding. |
| Linux Server Mode foundation | Foundation step | Runtime profile validation, health/readiness, systemd/static Docker checks, and docs are covered by tests. | Central API/UI node with strict config validation; remote nodes submit redacted metadata only. |
| Agent history and risk | Ready | SQLite auto-init, history, offline detection, redaction, and risk scoring are tested. | Redacted history with configurable retention and `90s` default offline threshold. |
| Flow Analysis | Active | Live packets are grouped into directional flows and bidirectional conversations. | Flow snapshots contain redacted metadata, not raw payloads or credentials. |
| Protocol Intelligence | Active MVP | DNS, HTTP, TLS metadata, SSH, RDP, SMB, mail, ICMP, and unknown traffic are classified. | Metadata-safe detection only; no TLS decryption, MITM, or key extraction. |
Expand Down Expand Up @@ -175,6 +183,8 @@ history query latency, flow totals, and detection counters. Recommended actions
in the UI are intentionally short and operational: stale snapshots, capture
stops, queue pressure, dropped writes, websocket delivery gaps, and backend
runtime pressure are surfaced without exposing packet payloads or secrets.
`/api/health` reports alive/profile metadata and `/api/ready` reports readiness
for server-mode deployment checks.

The bounded queue is the first performance-pipeline foundation step, not the
complete performance pipeline. It adds queue pressure metrics, drop counters,
Expand Down
14 changes: 13 additions & 1 deletion agent/agent_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
from typing import Any

AGENT_VERSION = "0.2.0"
AGENT_CAPABILITIES = ["health", "capture_status", "alerts_summary", "flows_summary"]
AGENT_CAPABILITIES = [
"telemetry",
"health",
"capture_status",
"alerts_summary",
"flows_summary",
]


def default_identity_path(base_dir: str | Path | None = None) -> Path:
Expand Down Expand Up @@ -54,5 +60,11 @@ def build_registration_payload(agent_id: str, display_name: str = "") -> dict[st
"os_version": platform.version(),
"machine": platform.machine(),
"agent_version": AGENT_VERSION,
"version": AGENT_VERSION,
"node_id": agent_id,
"node_name": display_name or hostname,
"node_type": "agent",
"profile": "agent",
"metadata_redacted": True,
"capabilities": list(AGENT_CAPABILITIES),
}
2 changes: 2 additions & 0 deletions backend/app/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Runtime configuration helpers for NetBotPro."""

185 changes: 185 additions & 0 deletions backend/app/config/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
from __future__ import annotations

import ipaddress
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Mapping

SUPPORTED_PROFILES = {"dev", "desktop", "server", "sensor", "agent"}
NODE_TYPES = {"server", "sensor", "agent"}
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
DEFAULT_INSECURE_SECRETS = {
"changeme",
"change-me",
"default",
"secret",
"password",
"netbotpro",
"netbotpro-secret",
"dev-token",
}
APP_STARTED_AT = time.time()


@dataclass(frozen=True)
class RuntimeProfileConfig:
profile: str
host: str
port: int
public_base_url: str
allowed_origins: tuple[str, ...]
trusted_tokens: tuple[str, ...]
runtime_dir: Path
log_dir: Path
enable_live_capture: bool
debug: bool
server_mode: bool
validation_errors: tuple[str, ...] = field(default_factory=tuple)
validation_warnings: tuple[str, ...] = field(default_factory=tuple)

@property
def public_bind(self) -> bool:
return is_public_bind(self.host)


def _bool(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}


def _split_csv(value: str | None) -> tuple[str, ...]:
items: list[str] = []
for raw in str(value or "").split(","):
item = raw.strip()
if item and item not in items:
items.append(item)
return tuple(items)


def normalize_profile(value: str | None, *, default: str = "desktop") -> str:
profile = str(value or default).strip().lower()
if profile not in SUPPORTED_PROFILES:
raise ValueError(f"Unsupported NETBOT_PROFILE '{profile}'")
return profile


def is_loopback_bind(host: str) -> bool:
text = str(host or "").strip().strip("[]").lower()
if text in LOCAL_HOSTS:
return True
try:
parsed = ipaddress.ip_address(text)
except ValueError:
return False
if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None:
return parsed.ipv4_mapped.is_loopback
return parsed.is_loopback


def is_public_bind(host: str) -> bool:
text = str(host or "").strip().strip("[]").lower()
if not text:
return False
if text in {"0.0.0.0", "::"}:
return True
return not is_loopback_bind(text)


def _path_writable(path: Path) -> bool:
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / ".netbotpro_write_test"
probe.write_text("ok", encoding="utf-8")
probe.unlink(missing_ok=True)
return True
except Exception:
return False


def load_runtime_profile_config(
env: Mapping[str, str] | None = None,
*,
validate_paths: bool = True,
) -> RuntimeProfileConfig:
source = env if env is not None else os.environ
profile = normalize_profile(source.get("NETBOT_PROFILE"))
host = str(source.get("NETBOT_HOST") or "127.0.0.1").strip()
try:
port = int(source.get("NETBOT_PORT") or "8000")
except (TypeError, ValueError):
port = 8000
allowed_origins = _split_csv(source.get("NETBOT_ALLOWED_ORIGINS"))
local_token = str(source.get("NETBOT_LOCAL_TOKEN") or "").strip()
trusted_tokens = _split_csv(source.get("NETBOT_TRUSTED_TOKENS"))
if local_token and local_token not in trusted_tokens:
trusted_tokens = (local_token, *trusted_tokens)
runtime_dir = Path(source.get("NETBOT_RUNTIME_DIR") or source.get("NETBOT_DATA_DIR") or ".runtime")
log_dir = Path(source.get("NETBOT_LOG_DIR") or runtime_dir / "logs")
debug = _bool(source.get("NETBOT_DEBUG") or source.get("DEBUG"))
server_mode = profile == "server" or _bool(source.get("NETBOT_SERVER_MODE"))

errors: list[str] = []
warnings: list[str] = []
if server_mode:
if not trusted_tokens:
errors.append("server_profile_requires_trusted_token")
if any(token.lower() in DEFAULT_INSECURE_SECRETS for token in trusted_tokens):
errors.append("server_profile_rejects_default_secret")
if "*" in allowed_origins:
errors.append("server_profile_rejects_wildcard_cors")
if debug:
errors.append("server_profile_rejects_debug")
if is_public_bind(host) and not allowed_origins:
errors.append("server_profile_requires_explicit_origins_for_public_bind")
if is_public_bind(host) and not str(source.get("NETBOT_PUBLIC_BASE_URL") or "").strip():
warnings.append("server_profile_public_base_url_recommended")
if validate_paths:
if not _path_writable(runtime_dir):
errors.append("runtime_dir_not_writable")
if not _path_writable(log_dir):
errors.append("log_dir_not_writable")

return RuntimeProfileConfig(
profile=profile,
host=host,
port=port,
public_base_url=str(source.get("NETBOT_PUBLIC_BASE_URL") or "").strip(),
allowed_origins=allowed_origins,
trusted_tokens=trusted_tokens,
runtime_dir=runtime_dir,
log_dir=log_dir,
enable_live_capture=_bool(source.get("NETBOT_ENABLE_LIVE_CAPTURE")),
debug=debug,
server_mode=server_mode,
validation_errors=tuple(errors),
validation_warnings=tuple(warnings),
)


def require_valid_runtime_config(config: RuntimeProfileConfig) -> None:
if config.validation_errors:
joined = ", ".join(config.validation_errors)
raise RuntimeError(f"Unsafe NetBotPro runtime configuration: {joined}")


def profile_metadata(config: RuntimeProfileConfig | None = None) -> dict[str, object]:
active = config or load_runtime_profile_config(validate_paths=False)
return {
"profile": active.profile,
"server_mode": active.server_mode,
"host": active.host,
"port": active.port,
"public_base_url_configured": bool(active.public_base_url),
"allowed_origins_count": len(active.allowed_origins),
"trusted_tokens_configured": bool(active.trusted_tokens),
"runtime_dir": str(active.runtime_dir),
"log_dir": str(active.log_dir),
"live_capture_enabled": active.enable_live_capture,
"validation": {
"ok": not active.validation_errors,
"errors": list(active.validation_errors),
"warnings": list(active.validation_warnings),
},
}

Loading