From 7f3f298fa768f5da662a1099f39b2e21e00b4370 Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:22:17 -0500 Subject: [PATCH] Add Docker support for headless network discovery Enables running SC2 discovery in Docker without GUI dependencies. The container uses library APIs directly to bypass interactive getpass() prompts, saving ~200MB by excluding PyQt6. Co-Authored-By: Claude Opus 4.6 --- README.md | 30 +++++ docker/.env.template | 57 +++++++++ docker/.gitignore | 5 + docker/Dockerfile | 90 +++++++++++++ docker/discovery_runner.py | 253 +++++++++++++++++++++++++++++++++++++ docker/docker-compose.yml | 78 ++++++++++++ docker/entrypoint.sh | 46 +++++++ 7 files changed, 559 insertions(+) create mode 100644 docker/.env.template create mode 100644 docker/.gitignore create mode 100644 docker/Dockerfile create mode 100644 docker/discovery_runner.py create mode 100644 docker/docker-compose.yml create mode 100644 docker/entrypoint.sh diff --git a/README.md b/README.md index fdca27c..f1db0c3 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,36 @@ pip install pysnmp-lextudio paramiko cryptography textfsm aiofiles pip install PyQt6 PyQt6-WebEngine ``` +### Docker (Headless) + +Run discovery without installing Python or any dependencies. The Docker image excludes PyQt6 (~200MB savings) and uses the library APIs directly to bypass interactive `getpass()` prompts. + +```bash +cd docker +cp .env.template .env +# Edit .env with your seed IP, vault password, and SNMP/SSH credentials + +docker compose build +docker compose up +``` + +Output files (map.json, devices.csv, topology.graphml) are written to `docker/output/`. The credential vault persists in a Docker volume between runs. + +**Environment variables:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `SC_VAULT_PASSWORD` | Yes | Master password for the encrypted credential vault | +| `SC_SEED_IP` | Yes | IP of the seed device (core switch/router) | +| `SC_CRAWL_DEPTH` | No | Max hop depth (default: 10) | +| `SC_SNMPV2C_COMMUNITY` | No | SNMPv2c community string | +| `SC_SSH_USERNAME` | No | SSH username for fallback discovery | +| `SC_SSH_PASSWORD` | No | SSH password | +| `SC_EXCLUDE_STRING` | No | Comma-separated patterns to exclude from crawl | +| `SC_DOMAIN_SUFFIX` | No | Domain suffix(es) to strip from hostnames | + +See `docker/.env.template` for the full list including SNMPv3 fields. + --- ## Quick Start diff --git a/docker/.env.template b/docker/.env.template new file mode 100644 index 0000000..6ec2bf3 --- /dev/null +++ b/docker/.env.template @@ -0,0 +1,57 @@ +# ============================================================================= +# Secure Cartography v2 - Docker Environment Configuration +# Copy this file to .env and fill in your values +# NEVER commit this file to version control with real credentials +# ============================================================================= + +# --- REQUIRED --- + +# Master password for the encrypted credential vault +# Use a strong password - this protects all stored network credentials +SC_VAULT_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE + +# Seed device IP - the core switch or router to start discovery from +# Choose a device with the broadest CDP/LLDP neighbor visibility +SC_SEED_IP=10.1.1.1 + +# --- DISCOVERY SCOPE --- + +# Maximum hop depth from seed device +SC_CRAWL_DEPTH=10 + +# Domain suffix(es) to strip from hostnames for cleaner diagram labels +# Comma-separated for multiple domains +# Example: "corp.example.com,example.lan" +SC_DOMAIN_SUFFIX= + +# Comma-separated hostname patterns to exclude from CRAWLING (not discovery) +# Matched devices still appear in topology as leaf nodes, but their neighbors +# are not queried. This prevents crawling through phones, APs, servers, etc. +SC_EXCLUDE_STRING=phone,sep,wireless,ap,linux,camera,printer + +# --- SNMPv2c CREDENTIALS --- +# Leave empty if not using SNMPv2c +SC_SNMPV2C_COMMUNITY= + +# --- SNMPv3 CREDENTIALS --- +# Leave empty if not using SNMPv3 +SC_SNMPV3_USERNAME= +SC_SNMPV3_AUTH_PROTOCOL=sha +SC_SNMPV3_AUTH_PASSWORD= +SC_SNMPV3_PRIV_PROTOCOL=aes128 +SC_SNMPV3_PRIV_PASSWORD= + +# --- SSH CREDENTIALS --- +# Used as fallback when SNMP cannot retrieve CDP/LLDP neighbor data +# Leave empty if SNMP is sufficient for your environment +SC_SSH_USERNAME= +SC_SSH_PASSWORD= + +# --- DNS --- +# Your site's DNS servers (for Docker container hostname resolution) +SC_DNS_PRIMARY=8.8.8.8 +SC_DNS_SECONDARY=8.8.4.4 + +# --- LOGGING --- +# Options: DEBUG, INFO, WARNING, ERROR +SC_LOG_LEVEL=INFO diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..e433e73 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,5 @@ +# Credentials - NEVER commit +.env + +# Discovery output +output/ diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..3092d79 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,90 @@ +# ============================================================================= +# Secure Cartography v2 - Headless Docker Deployment +# Purpose: Automated network topology discovery without GUI dependencies +# Target: Cisco IOS/IOS-XE, NX-OS, ASA; Arista EOS; Juniper JUNOS +# Mode: CLI-only (no PyQt6) for scheduled/automated runs +# ============================================================================= + +FROM python:3.12-slim AS base + +LABEL maintainer="Secure Cartography contributors" +LABEL description="Secure Cartography v2 - Headless Network Discovery" +LABEL version="2.0.0" + +# Prevent interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive + +# Enable non-free repo for snmp-mibs-downloader (not in main) +RUN sed -i 's/Components: main/Components: main contrib non-free/' \ + /etc/apt/sources.list.d/debian.sources + +# Install system dependencies +# - graphviz: SVG/PNG diagram rendering +# - openssh-client: SSH key management utilities +# - snmp: SNMP CLI tools for manual troubleshooting inside container +# - tini: proper PID 1 signal handling +RUN apt-get update && apt-get install -y --no-install-recommends \ + graphviz \ + openssh-client \ + snmp \ + snmp-mibs-downloader \ + tini \ + && rm -rf /var/lib/apt/lists/* + +# Download standard MIBs (needed for SNMP OID resolution) +RUN download-mibs 2>/dev/null || true + +# Create non-root user for security +RUN groupadd -r scart && useradd -r -g scart -m -s /bin/bash scart + +# Install Secure Cartography v2 without PyQt6 GUI dependencies. +# The upstream package lists PyQt6 as a hard dependency, but we only use +# the headless discovery engine and credential vault APIs. Install with +# --no-deps and explicitly list the runtime dependencies we actually need. +RUN pip install --no-cache-dir --no-deps "secure-cartography>=2.0.0" \ + && pip install --no-cache-dir \ + "pysnmp>=6.1" \ + "pyasn1>=0.6" \ + "pysmi>=1.3" \ + paramiko \ + cryptography \ + textfsm \ + aiofiles \ + pyyaml \ + networkx \ + pydot \ + tfsm-fire + +# Create directory structure +RUN mkdir -p /app/output \ + /app/vault \ + /app/logs \ + && chown -R scart:scart /app + +# Copy entrypoint and discovery runner +COPY --chown=scart:scart entrypoint.sh /app/entrypoint.sh +COPY --chown=scart:scart discovery_runner.py /app/discovery_runner.py +RUN chmod +x /app/entrypoint.sh + +# Set working directory +WORKDIR /app + +# Switch to non-root user +USER scart + +# Environment variables with sane defaults +# Override these at runtime via docker-compose.yml or docker run -e +ENV SC_VAULT_PATH="/app/vault/credentials.db" +ENV SC_OUTPUT_DIR="/app/output" +ENV SC_LOG_LEVEL="INFO" +ENV SC_CRAWL_DEPTH="10" +ENV SC_DOMAIN_SUFFIX="" +ENV SC_EXCLUDE_STRING="phone,sep,wireless,ap,linux" +ENV SC_SEED_IP="" +ENV SC_VAULT_PASSWORD="" + +# Use tini as init system for proper signal handling +ENTRYPOINT ["tini", "--"] + +# Default command - run discovery via entrypoint script +CMD ["/app/entrypoint.sh"] diff --git a/docker/discovery_runner.py b/docker/discovery_runner.py new file mode 100644 index 0000000..b54856d --- /dev/null +++ b/docker/discovery_runner.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Secure Cartography v2 - Headless Discovery Runner + +Uses the secure_cartography library APIs directly for vault management +and network discovery. This bypasses the CLI tools (sc2-creds, sc2-discover) +which require interactive getpass() input and cannot run headlessly. + +Environment variables: + SC_VAULT_PASSWORD - Master password for the credential vault (required) + SC_SEED_IP - Seed device IP to start crawl from (required) + SC_VAULT_PATH - Path to vault database (default: /app/vault/credentials.db) + SC_OUTPUT_DIR - Output directory (default: /app/output) + SC_CRAWL_DEPTH - Max crawl depth (default: 10) + SC_DOMAIN_SUFFIX - Comma-separated domain suffixes to strip + SC_EXCLUDE_STRING - Comma-separated hostname patterns to exclude + SC_SNMPV2C_COMMUNITY - SNMPv2c community string + SC_SNMPV3_* - SNMPv3 credential fields + SC_SSH_USERNAME - SSH username + SC_SSH_PASSWORD - SSH password + SC_LOG_LEVEL - Logging level (default: INFO) +""" +import asyncio +import os +import sys +import logging +from pathlib import Path + +log = logging.getLogger("discovery-runner") + + +def setup_logging(): + level = os.environ.get("SC_LOG_LEVEL", "INFO").upper() + logging.basicConfig( + level=getattr(logging, level, logging.INFO), + format="[%(levelname)-5s] %(asctime)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def validate_env(): + """Validate required environment variables.""" + errors = [] + if not os.environ.get("SC_VAULT_PASSWORD"): + errors.append("SC_VAULT_PASSWORD is required") + if not os.environ.get("SC_SEED_IP"): + errors.append("SC_SEED_IP is required") + if errors: + for e in errors: + log.error(e) + sys.exit(1) + + +def init_vault(vault_path: str, password: str): + """Initialize or open the credential vault.""" + from sc2.scng.creds.vault import CredentialVault + + vault_p = Path(vault_path) + vault_p.parent.mkdir(parents=True, exist_ok=True) + + if not vault_p.exists(): + log.info("Creating new vault at %s", vault_p) + vault = CredentialVault(vault_p) + vault.initialize(password) + else: + log.info("Opening existing vault at %s", vault_p) + vault = CredentialVault(vault_p) + vault.unlock(password) + + return vault + + +def inject_credentials(vault): + """Inject SNMP and SSH credentials from environment variables.""" + # SNMPv2c + community = os.environ.get("SC_SNMPV2C_COMMUNITY", "") + if community: + log.info("Adding SNMPv2c credentials...") + try: + vault.add_snmpv2c_credential( + "snmpv2c-auto", community=community, is_default=True, + ) + except Exception as e: + log.warning("SNMPv2c credential add: %s", e) + + # SNMPv3 + snmpv3_user = os.environ.get("SC_SNMPV3_USERNAME", "") + if snmpv3_user: + log.info("Adding SNMPv3 credentials...") + kwargs = {"username": snmpv3_user, "is_default": True} + auth_proto = os.environ.get("SC_SNMPV3_AUTH_PROTOCOL", "") + if auth_proto and auth_proto != "none": + try: + from sc2.scng.creds.models import SNMPv3AuthProtocol + kwargs["auth_protocol"] = SNMPv3AuthProtocol(auth_proto.lower()) + except (ValueError, ImportError): + kwargs["auth_protocol"] = auth_proto + auth_pass = os.environ.get("SC_SNMPV3_AUTH_PASSWORD", "") + if auth_pass: + kwargs["auth_password"] = auth_pass + priv_proto = os.environ.get("SC_SNMPV3_PRIV_PROTOCOL", "") + if priv_proto and priv_proto != "none": + try: + from sc2.scng.creds.models import SNMPv3PrivProtocol + kwargs["priv_protocol"] = SNMPv3PrivProtocol(priv_proto.lower()) + except (ValueError, ImportError): + kwargs["priv_protocol"] = priv_proto + priv_pass = os.environ.get("SC_SNMPV3_PRIV_PASSWORD", "") + if priv_pass: + kwargs["priv_password"] = priv_pass + try: + vault.add_snmpv3_credential("snmpv3-auto", **kwargs) + except Exception as e: + log.warning("SNMPv3 credential add: %s", e) + + # SSH + ssh_user = os.environ.get("SC_SSH_USERNAME", "") + if ssh_user: + log.info("Adding SSH credentials...") + ssh_pass = os.environ.get("SC_SSH_PASSWORD", "") + try: + vault.add_ssh_credential( + "ssh-auto", username=ssh_user, password=ssh_pass, is_default=True, + ) + except Exception as e: + log.warning("SSH credential add: %s", e) + + +def run_discovery(vault): + """Run the SC2 discovery engine.""" + from sc2.scng.discovery.engine import DiscoveryEngine + + seed_ip = os.environ["SC_SEED_IP"] + output_dir = Path(os.environ.get("SC_OUTPUT_DIR", "/app/output")) + crawl_depth = int(os.environ.get("SC_CRAWL_DEPTH", "10")) + domain_suffix = os.environ.get("SC_DOMAIN_SUFFIX", "") + exclude_string = os.environ.get("SC_EXCLUDE_STRING", "") + + excludes = [p.strip() for p in exclude_string.split(",") if p.strip()] if exclude_string else [] + domains = [d.strip() for d in domain_suffix.split(",") if d.strip()] if domain_suffix else [] + + log.info("Seed IP: %s", seed_ip) + log.info("Crawl depth: %d", crawl_depth) + log.info("Exclude patterns: %s", excludes) + log.info("Domain suffixes: %s", domains) + log.info("Output directory: %s", output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + + engine = DiscoveryEngine(vault=vault, verbose=True, no_dns=True) + + # Subscribe to events for crawl visibility + def on_event(event_name, **kw): + if event_name == "device_queued": + log.info("QUEUED: %s (depth %s)", kw.get("target"), kw.get("depth")) + elif event_name == "device_complete": + log.info("COMPLETE: %s (%s) - %d neighbors via %s", + kw.get("hostname"), kw.get("ip"), kw.get("neighbor_count", 0), kw.get("method")) + elif event_name == "device_failed": + log.warning("FAILED: %s - %s", kw.get("target"), kw.get("error")) + elif event_name == "device_excluded": + log.info("EXCLUDED: %s (matched: %s)", kw.get("hostname"), kw.get("pattern")) + elif event_name in ("depth_started", "depth_complete"): + log.info("=== %s depth=%s ===", event_name, kw.get("depth")) + + engine.events.subscribe(on_event) + + result = asyncio.run(engine.crawl( + seeds=[seed_ip], + max_depth=crawl_depth, + output_dir=output_dir, + domains=domains if domains else None, + exclude_patterns=excludes if excludes else None, + )) + + return result + + +def create_latest_symlink(output_dir: str): + """Create a 'latest' symlink pointing to the most recent output.""" + output_path = Path(output_dir) + if not output_path.exists(): + return + + subdirs = [ + d for d in output_path.iterdir() + if d.is_dir() and d.name != "latest" + ] + if not subdirs: + return + + latest = max(subdirs, key=lambda d: d.stat().st_mtime) + latest_link = output_path / "latest" + + if latest_link.is_symlink() or latest_link.exists(): + latest_link.unlink() + + latest_link.symlink_to(latest) + log.info("Updated latest symlink -> %s", latest.name) + + +def main(): + setup_logging() + log.info("=== Secure Cartography v2 - Headless Discovery Runner ===") + + validate_env() + + vault_password = os.environ["SC_VAULT_PASSWORD"] + vault_path = os.environ.get("SC_VAULT_PATH", "/app/vault/credentials.db") + output_dir = os.environ.get("SC_OUTPUT_DIR", "/app/output") + + # Set up the default vault location symlink + default_vault_dir = Path.home() / ".scng" + default_vault_dir.mkdir(parents=True, exist_ok=True) + default_vault_file = default_vault_dir / "credentials.db" + if not default_vault_file.exists() and vault_path != str(default_vault_file): + try: + default_vault_file.symlink_to(vault_path) + log.info("Symlinked %s -> %s", default_vault_file, vault_path) + except OSError: + pass + + log.info("Step 1: Initializing credential vault...") + vault = init_vault(vault_path, vault_password) + + log.info("Step 2: Injecting credentials...") + inject_credentials(vault) + + log.info("Step 3: Running discovery...") + try: + run_discovery(vault) + log.info("Discovery completed successfully") + except Exception as e: + log.error("Discovery failed: %s", e) + sys.exit(1) + + create_latest_symlink(output_dir) + + # List output files + latest_path = Path(output_dir) / "latest" + if latest_path.exists(): + log.info("Output files:") + for f in sorted(latest_path.rglob("*")): + if f.is_file(): + size = f.stat().st_size + rel = f.relative_to(latest_path) + log.info(" %10d bytes %s", size, rel) + + log.info("=== Discovery complete ===") + + +if __name__ == "__main__": + main() diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..e308743 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,78 @@ +# ============================================================================= +# Secure Cartography v2 - Headless Network Discovery +# Usage: docker compose up (run discovery) +# docker compose run sc2 bash (interactive shell) +# ============================================================================= + +services: + sc2: + build: + context: . + dockerfile: Dockerfile + container_name: secure-cartography + hostname: sc2 + + # Persist credential vault and output between runs + volumes: + - sc2-vault:/app/vault + - ./output:/app/output + + # --- Environment Variables --- + environment: + # REQUIRED: Master password for the encrypted credential vault + SC_VAULT_PASSWORD: "${SC_VAULT_PASSWORD:?Set SC_VAULT_PASSWORD in .env file}" + + # REQUIRED: IP address of the seed device (core switch/router to start crawl) + SC_SEED_IP: "${SC_SEED_IP:?Set SC_SEED_IP in .env file}" + + # --- Discovery Scope --- + SC_CRAWL_DEPTH: "${SC_CRAWL_DEPTH:-10}" + SC_DOMAIN_SUFFIX: "${SC_DOMAIN_SUFFIX:-}" + SC_EXCLUDE_STRING: "${SC_EXCLUDE_STRING:-phone,sep,wireless,ap,linux,camera,printer}" + + # --- SNMPv2c Credentials --- + SC_SNMPV2C_COMMUNITY: "${SC_SNMPV2C_COMMUNITY:-}" + + # --- SNMPv3 Credentials --- + SC_SNMPV3_USERNAME: "${SC_SNMPV3_USERNAME:-}" + SC_SNMPV3_AUTH_PROTOCOL: "${SC_SNMPV3_AUTH_PROTOCOL:-sha}" + SC_SNMPV3_AUTH_PASSWORD: "${SC_SNMPV3_AUTH_PASSWORD:-}" + SC_SNMPV3_PRIV_PROTOCOL: "${SC_SNMPV3_PRIV_PROTOCOL:-}" + SC_SNMPV3_PRIV_PASSWORD: "${SC_SNMPV3_PRIV_PASSWORD:-}" + + # --- SSH Credentials --- + SC_SSH_USERNAME: "${SC_SSH_USERNAME:-}" + SC_SSH_PASSWORD: "${SC_SSH_PASSWORD:-}" + + # --- Logging --- + SC_LOG_LEVEL: "${SC_LOG_LEVEL:-INFO}" + + # Bridge networking — container needs SNMP (161/udp) and SSH (22/tcp) + # access to your network devices. For VLAN-specific access, use macvlan + # or host networking. + network_mode: bridge + + # --- Resource Limits --- + deploy: + resources: + limits: + memory: 1G + cpus: '2.0' + reservations: + memory: 256M + cpus: '0.5' + + # One-shot discovery — do not restart + restart: "no" + + # --- DNS --- + # Set to your site's DNS servers for hostname resolution + dns: + - ${SC_DNS_PRIMARY:-8.8.8.8} + - ${SC_DNS_SECONDARY:-8.8.4.4} + +# --- Named Volumes --- +volumes: + sc2-vault: + driver: local + name: sc2-vault diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..b67d429 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================================= +# Secure Cartography v2 - Container Entrypoint Script +# Thin wrapper that launches the Python discovery runner with signal handling. +# +# The Python runner (discovery_runner.py) uses the secure-cartography library +# APIs directly instead of the CLI tools (sc2-creds, sc2-discover), because +# the CLI tools require interactive getpass() input and cannot run headlessly. +# ============================================================================= +set -euo pipefail + +echo "" +echo "================================================" +echo " Secure Cartography v2 - Network Discovery" +echo "================================================" +echo "" + +# Quick validation before handing off to Python +if [[ -z "${SC_VAULT_PASSWORD:-}" ]]; then + echo "[ERROR] SC_VAULT_PASSWORD is required. Set it in .env or docker-compose.yml." >&2 + exit 1 +fi + +if [[ -z "${SC_SEED_IP:-}" ]]; then + echo "[ERROR] SC_SEED_IP is required. Set it in .env or docker-compose.yml." >&2 + exit 1 +fi + +# Signal handling - forward signals to the Python process +cleanup() { + echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - Received shutdown signal, stopping discovery..." + if [[ -n "${PYTHON_PID:-}" ]]; then + kill "${PYTHON_PID}" 2>/dev/null + wait "${PYTHON_PID}" 2>/dev/null + fi + exit 0 +} +trap cleanup SIGTERM SIGINT + +# Launch the Python discovery runner in background so we can handle signals +python3 /app/discovery_runner.py & +PYTHON_PID=$! +wait "${PYTHON_PID}" +DISCOVERY_EXIT=$? + +exit ${DISCOVERY_EXIT}