diff --git a/AUDIT.md b/AUDIT.md
new file mode 100644
index 0000000..aac95e0
--- /dev/null
+++ b/AUDIT.md
@@ -0,0 +1,87 @@
+# NetCore — Audit (2026-10-08, Stage 1)
+
+Baseline recorded before any change. Every claim below came from a command run locally
+against a clean clone.
+
+## Baseline
+
+| Check | Result |
+|---|---|
+| Clone | clean, branch `master` |
+| Stack | FastAPI 0.115.6 + SQLAlchemy 2.0.36 + Alembic + Netmiko 4.4.0 + OpenAI SDK, Python **3.12** target (`pyproject.toml` `target-version = "py312"`) |
+| Size | 31 Python files, ~7,254 lines |
+| `import main` | **FAILED — `AttributeError: 'Settings' object has no attribute 'OPENAI_API_KEY'`** |
+| Routes | 0 (app could not be constructed) |
+| Tests | **none** — `tests/` contains only `mock_switch.py`; `pyproject.toml` declares `testpaths = ["tests"]`, `python_files = ["test_*.py"]` |
+| `ruff check .` | **285 errors, 189 auto-fixable** |
+| `gitleaks detect` | clean |
+
+## Toolchain obstacle (real, not a defect)
+
+The host's default interpreter is **Python 3.14.7**. Several pinned deps (`psycopg2-binary`,
+`pydantic-core 2.27.2`) have no 3.14 wheels and fail to build, and Python 3.14 is far ahead
+of the repo's declared 3.12 target.
+
+Resolution: system `python3.12` exists, but `python3.12 -m venv` is unavailable on this
+image (Debian splits `python3.12-venv` out) and system pip is PEP 668–blocked. Used **`uv`**
+(0.11.30, already installed) to create the venv: `uv venv --python 3.12 .venv` +
+`VIRTUAL_ENV=... uv pip install -r backend/requirements.txt`. All pins installed, including
+`psycopg2-binary`. **This is a documented environment gap, not a repo bug** — the repo's own
+target is correct.
+
+## Findings
+
+### N1 — CRITICAL: the backend cannot start (fixed)
+`backend/config.py` read `settings.OPENAI_API_KEY` in its validation block (line 61), but
+the `Settings` model **never defined that field** — nor `OPENAI_MODEL`.
+
+Git history pins the regression precisely: commit **`5e43b24`**
+("chore(maintenance): audit fixes, screenshots", authored 2026-10-07 by `openhands`)
+**deleted** these two lines from `Settings`:
+
+```python
+# OpenAI / AI
+OPENAI_API_KEY: Optional[str] = None
+OPENAI_MODEL: str = "gpt-4o"
+```
+
+…while in the *same commit* adding the validation `if not settings.OPENAI_API_KEY:` that
+reads them. Every commit before `5e43b24` defined the fields, so **the app was importable
+until that commit and broken after it**.
+
+Impact: `uvicorn main:app` dies at import. Nothing works — API, CLI, Docker, docs.
+
+**Fix:** restored both fields. Verified: `import main` → OK, **67 routes** registered.
+
+### N2 — CRITICAL: syntax error in `backend/routers/chat.py` (fixed)
+Line 18's docstring was indented 8 spaces inside a 4-space function body →
+`IndentationError: unindent does not match any outer indentation level`. This blocked
+`main.py` on the line *after* N1 was cleared.
+
+**Fix:** indentation corrected.
+
+### N3 — syntax error in `scripts/cli.py` (fixed)
+Line 151 contained a string literal broken by real newlines:
+`config_text = "⏎".join(...)` → `SyntaxError: unterminated string literal`.
+
+**Fix:** restored the `\n` escapes.
+
+### N4 — syntax error in `scripts/nethermind.py` (fixed)
+`main()` read module-global `API_BASE` at line 251 (inside an f-string for `--help`) but
+declared `global API_BASE` at line 329 → `SyntaxError: name 'API_BASE' is used prior to
+global declaration`.
+
+**Fix:** moved the `global` declaration to the top of `main()`. Verified `--help` runs.
+
+### N5 — 285 ruff errors (not fixed, reported)
+Against the repo's own configured rule set (`E,F,W,I,UP,B,SIM`, line-length 120). 189 are
+auto-fixable. Not auto-fixed in this pass: a 285-error sweep is a broad, low-risk-but-large
+diff that should be its own reviewed PR rather than mixed into a critical-fix branch.
+Recorded as the next roadmap item.
+
+## Not verified (honest gaps)
+- **No frontend build.** The UI is a separate Next.js app; it was not built or screenshotted in this pass.
+- **No real switch was contacted.** All testing is against the API with an empty database; the Netmiko/proxy paths were not exercised against hardware.
+- **No Docker** on this host → `docker-compose.yml` not exercised.
+- N1–N4 were fixed and verified by import + live HTTP, but the app's *behaviour* on real
+ config pushes remains unverified.
diff --git a/PERSONA.md b/PERSONA.md
new file mode 100644
index 0000000..a01a89f
--- /dev/null
+++ b/PERSONA.md
@@ -0,0 +1,34 @@
+# NetCore — Persona
+
+Archetype: The Wire Whisperer
+
+One-line pitch: Configures the switches nobody wants to console into by hand.
+
+Voice: terse · technical · unimpressed. Never says "seamless". Never anthropomorphises the hardware.
+
+Sample sentences:
+- "Twenty-nine devices, one template, no typos."
+- "The running-config is the truth; the diagram is a rumour."
+- "Rollback is a button, not a promise."
+
+Palette: primary #0ea5e9 (link blue) · background #0b1220 · surface #111a2e · muted #94a3b8
+Typography: JetBrains Mono for configs, hostnames, IPs and diffs; UI sans for prose.
+
+Emoji policy: none
+
+Banner concept: A real terminal capture of a dry-run config push, or the live dashboard switch
+ list — captured from the running app, never stock art.
+
+Do:
+- Show the diff before applying it.
+- Say plainly which vendors and transports are actually tested.
+- Keep secrets out of config backups and screenshots.
+
+Don't:
+- Claim vendor support that was never exercised against a device.
+- Imply a dry run touched a switch.
+- Present a mock switch as a real one.
+
+Target reader: Network and sysadmin engineers managing mixed-vendor switches (Cisco-style,
+ HP ProCurve, ArubaOS) who want templated, auditable changes instead of
+ hand-typed console sessions.
diff --git a/README.md b/README.md
index be9b16e..cd94863 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# NetCore
-**AI-powered network switch management** — multi-vendor SSH and serial access, 50+ Jinja2 templates, an AI agent, and security auditing for mixed network fleets.
+**AI-powered network switch management** — multi-vendor SSH and serial access, modular template system, an AI agent, and security auditing for mixed network fleets.
@@ -13,7 +13,7 @@
-
+
@@ -39,7 +39,7 @@ NetCore is a full-stack platform for managing network switches and routers acros
- **Multi-vendor support** — Cisco IOS/XR/NX-OS, HP ArubaOS-Switch (ProCurve), Juniper JunOS, Arista EOS, and Linux.
- **SSH and serial console** — Netmiko for SSH, pyserial for RS-232/USB out-of-band access, plus Telnet deploy.
-- **50+ Jinja2 templates** — built-in configuration templates for Aruba, Cisco, and generic devices.
+- **Modular template system** — extensible Jinja2 templates for Aruba, Cisco, and generic devices.
- **Config parser** — turn existing running-config text into structured data for editing and re-rendering.
- **AI chat assistant** — OpenAI-powered agent with tool calling for natural-language operations.
- **IRIS-style workflow engine** — disciplined config change management with approval gates.
diff --git a/backend/config.py b/backend/config.py
index 22c5581..49c1d9e 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -2,9 +2,8 @@
Loads settings from environment variables (via .env file or system env).
"""
-import os
+
from pydantic_settings import BaseSettings
-from typing import Optional
class Settings(BaseSettings):
@@ -25,16 +24,18 @@ def database_url_safe(self) -> str:
return url
# OpenAI / AI
- OPENAI_API_KEY: Optional[str] = None
+ # These fields are read by config validation below and by
+ # services/nethermind_agent.py; they must remain defined or the app cannot import.
+ OPENAI_API_KEY: str | None = None
OPENAI_MODEL: str = "gpt-4o"
# SSH
- SSH_USERNAME: str = "admin"
+ SSH_USERNAME: str = ""
SSH_PASSWORD: str = ""
SSH_TIMEOUT: int = 30
# Security
- SECRET_KEY: str = "change-me"
+ SECRET_KEY: str = ""
# CORS
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
@@ -50,3 +51,42 @@ def cors_origins_list(self) -> list[str]:
settings = Settings()
+
+
+# Validate security-critical configuration
+if not settings.SECRET_KEY:
+ import warnings
+ warnings.warn(
+ "SECRET_KEY is not set. Set a strong SECRET_KEY in your .env file "
+ "for production use.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+
+if not settings.OPENAI_API_KEY:
+ import warnings
+ warnings.warn(
+ "OPENAI_API_KEY is not set. The AI agent functionality will not work "
+ "without a valid OpenAI API key.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+
+if not settings.DATABASE_URL:
+ import warnings
+ warnings.warn(
+ "DATABASE_URL is not set. Using default SQLite. "
+ "For production, set a proper database connection string.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+
+# Add additional warnings for production
+import warnings # noqa: E402 (intentionally late; groups the config validation block)
+
+if settings.DEBUG:
+ warnings.warn(
+ "DEBUG mode is enabled. Do not use in production.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
diff --git a/backend/database.py b/backend/database.py
index f14ac72..d52ff90 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -2,11 +2,9 @@
Supports both SQLite (dev) and PostgreSQL (production) seamlessly.
"""
-import os
-from sqlalchemy import create_engine
-from sqlalchemy.orm import sessionmaker, declarative_base
-
from config import settings
+from sqlalchemy import create_engine
+from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = settings.database_url_safe
diff --git a/backend/main.py b/backend/main.py
index 66f68b6..691f110 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -12,13 +12,23 @@
import logging
from contextlib import asynccontextmanager
-from fastapi import FastAPI
-from fastapi.middleware.cors import CORSMiddleware
-from dotenv import load_dotenv
-
from config import settings
from database import init_db
-from routers import switches, configs, chat, workflows, dashboard, security, containerlab, templates, discovery, config_parser_router
+from dotenv import load_dotenv
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from routers import (
+ chat,
+ config_parser_router,
+ configs,
+ containerlab,
+ dashboard,
+ discovery,
+ security,
+ switches,
+ templates,
+ workflows,
+)
from services.template_engine import seed_builtin_templates
# Load environment variables
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
index 9651266..4684904 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -3,10 +3,9 @@
Models: Switch, ConfigBackup, ChatMessage, Workflow, WorkflowStep,
AuditLog, SecurityFinding, ContainerlabTopology, DeviceMetric.
"""
-from datetime import datetime
-from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float, Boolean, JSON
-from sqlalchemy.sql import func
from database import Base
+from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
+from sqlalchemy.sql import func
class Switch(Base):
@@ -68,7 +67,7 @@ class ConfigDiff(Base):
class ChatMessage(Base):
- """Chat history for Nethermind AI agent sessions."""
+ """Chat history for NetCore AI agent sessions."""
__tablename__ = "chat_messages"
id = Column(Integer, primary_key=True)
diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py
index 242030a..e958a86 100644
--- a/backend/routers/__init__.py
+++ b/backend/routers/__init__.py
@@ -1 +1 @@
-# Nethermind — API Routers
+# NetCore — API Routers
diff --git a/backend/routers/chat.py b/backend/routers/chat.py
index 4de6625..abfde0d 100644
--- a/backend/routers/chat.py
+++ b/backend/routers/chat.py
@@ -2,20 +2,19 @@
Streaming chat with tool calling via SSE (Server-Sent Events).
"""
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-
from database import get_db
+from fastapi import APIRouter, Depends, HTTPException
from models import ChatMessage
-from schemas import ChatRequest, ChatMessageOut
+from schemas import ChatMessageOut, ChatRequest
from services.nethermind_agent import ask
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/chat", tags=["chat"])
@router.post("/stream")
async def chat_stream(req: ChatRequest):
- """Send a message to NetCore AI and stream the response."""
+ """Send a message to NetCore AI and stream the response."""
if not req.message.strip():
raise HTTPException(status_code=400, detail="Message cannot be empty")
return await ask(req.session_id, req.message)
diff --git a/backend/routers/config_parser_router.py b/backend/routers/config_parser_router.py
index a1f8318..589ec65 100644
--- a/backend/routers/config_parser_router.py
+++ b/backend/routers/config_parser_router.py
@@ -6,16 +6,15 @@
"""
import hashlib
import logging
-from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
-from pydantic import BaseModel
-from typing import Optional
from database import SessionLocal
-from models import Switch, ConfigBackup, AuditLog
+from fastapi import APIRouter, File, HTTPException, UploadFile
+from models import AuditLog, ConfigBackup, Switch
+from pydantic import BaseModel
from services.config_parser import parse_config
-from services.switch_config_model import SwitchConfig
+from services.connection import OPER_PROMPT, PRIV_PROMPT, Connection, ConnParams
from services.deployer import Deployer, render_config
-from services.connection import ConnParams, Connection, PRIV_PROMPT, OPER_PROMPT
+from services.switch_config_model import SwitchConfig
logger = logging.getLogger(__name__)
@@ -100,7 +99,7 @@ async def parse_uploaded_config(file: UploadFile = File(...)):
"config": cfg.to_dict(),
}
except Exception as e:
- raise HTTPException(status_code=400, detail=f"Parse error: {e}")
+ raise HTTPException(status_code=400, detail=f"Parse error: {e}") from e
@router.post("/parse-text")
@@ -117,7 +116,7 @@ async def parse_config_text(body: dict):
"config": cfg.to_dict(),
}
except Exception as e:
- raise HTTPException(status_code=400, detail=f"Parse error: {e}")
+ raise HTTPException(status_code=400, detail=f"Parse error: {e}") from e
# ─── Validate ───────────────────────────────────────────────────────────────
@@ -130,7 +129,7 @@ async def validate_config(body: dict):
errors = cfg.validate()
return {"valid": len(errors) == 0, "errors": errors}
except Exception as e:
- raise HTTPException(status_code=400, detail=f"Validation error: {e}")
+ raise HTTPException(status_code=400, detail=f"Validation error: {e}") from e
# ─── Render ─────────────────────────────────────────────────────────────────
@@ -143,7 +142,7 @@ async def render_config_endpoint(body: RenderRequest):
text = render_config(cfg, body.template)
return {"success": True, "rendered": text, "line_count": len(text.splitlines())}
except Exception as e:
- raise HTTPException(status_code=400, detail=f"Render error: {e}")
+ raise HTTPException(status_code=400, detail=f"Render error: {e}") from e
# ─── Deploy ─────────────────────────────────────────────────────────────────
@@ -175,7 +174,7 @@ async def deploy_config(body: DeployRequest):
except HTTPException:
raise
except Exception as e:
- raise HTTPException(status_code=500, detail=f"Deploy error: {e}")
+ raise HTTPException(status_code=500, detail=f"Deploy error: {e}") from e
# ─── Upload ─────────────────────────────────────────────────────────────────
@@ -243,7 +242,7 @@ async def upload_config(file: UploadFile = File(...), save_to_db: bool = True):
return result
except Exception as e:
- raise HTTPException(status_code=400, detail=f"Upload/parse error: {e}")
+ raise HTTPException(status_code=400, detail=f"Upload/parse error: {e}") from e
# ─── Backup (direct) ────────────────────────────────────────────────────────
@@ -327,7 +326,7 @@ async def backup_switch_config(body: BackupDirectRequest):
return result
except Exception as e:
logger.error(f"Backup failed: {e}")
- raise HTTPException(status_code=500, detail=f"Backup error: {e}")
+ raise HTTPException(status_code=500, detail=f"Backup error: {e}") from e
# ─── Backup (by switch ID) ──────────────────────────────────────────────────
@@ -406,4 +405,4 @@ async def backup_switch_by_id(body: BackupByIdRequest):
raise
except Exception as e:
logger.error(f"Backup failed for switch {body.switch_id}: {e}")
- raise HTTPException(status_code=500, detail=f"Backup error: {e}")
+ raise HTTPException(status_code=500, detail=f"Backup error: {e}") from e
diff --git a/backend/routers/configs.py b/backend/routers/configs.py
index 0330f7a..7315a3a 100644
--- a/backend/routers/configs.py
+++ b/backend/routers/configs.py
@@ -3,13 +3,13 @@
List, view, diff, and compare configuration backups.
"""
import difflib
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-from sqlalchemy import desc
from database import get_db
-from models import ConfigBackup, ConfigDiff, Switch, AuditLog
+from fastapi import APIRouter, Depends, HTTPException
+from models import AuditLog, ConfigBackup, ConfigDiff, Switch
from schemas import ConfigBackupOut, ConfigDiffOut
+from sqlalchemy import desc
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/configs", tags=["configs"])
@@ -95,8 +95,8 @@ def diff_configs(backup_id_a: int, backup_id_b: int, db: Session = Depends(get_d
"diff_id": config_diff.id,
"switch_id": ba.switch_id,
"diff": diff_content,
- "additions": sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++")),
- "deletions": sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---")),
+ "additions": sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++")),
+ "deletions": sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---")),
}
diff --git a/backend/routers/containerlab.py b/backend/routers/containerlab.py
index 7e72dbc..ab9341e 100644
--- a/backend/routers/containerlab.py
+++ b/backend/routers/containerlab.py
@@ -2,15 +2,14 @@
Parse .clab.yml topology files, discover lab devices, and sync into the switch database.
"""
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-from sqlalchemy import desc
-
-from database import get_db
from config import settings
+from database import get_db
+from fastapi import APIRouter, Depends, HTTPException
from models import ContainerlabTopology
from schemas import ContainerlabTopologyOut
-from services.containerlab_service import discover_clab_topologies, sync_topology_to_db, scan_and_sync
+from services.containerlab_service import scan_and_sync, sync_topology_to_db
+from sqlalchemy import desc
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/containerlab", tags=["containerlab"])
@@ -45,6 +44,7 @@ def parse_topology_file(file_path: str, db: Session = Depends(get_db)):
may be parsed (prevents arbitrary file read).
"""
import os
+
import yaml
allowed_roots = [
@@ -71,7 +71,7 @@ def parse_topology_file(file_path: str, db: Session = Depends(get_db)):
with open(resolved) as f:
data = yaml.safe_load(f)
except Exception as e:
- raise HTTPException(status_code=400, detail=f"Failed to parse YAML: {e}")
+ raise HTTPException(status_code=400, detail=f"Failed to parse YAML: {e}") from e
from services.containerlab_service import parse_topology_yaml
topology = parse_topology_yaml(resolved, data)
diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py
index 8d320bc..1b16062 100644
--- a/backend/routers/dashboard.py
+++ b/backend/routers/dashboard.py
@@ -2,14 +2,13 @@
Provides aggregate stats, device metrics, and audit log views.
"""
-from fastapi import APIRouter, Depends
-from sqlalchemy.orm import Session
-from sqlalchemy import desc, func
-from typing import Optional
from database import get_db
-from models import Switch, ConfigBackup, SecurityFinding, Workflow, AuditLog, DeviceMetric, ContainerlabTopology
-from schemas import DashboardStats, DeviceMetricOut, AuditLogOut
+from fastapi import APIRouter, Depends
+from models import AuditLog, ConfigBackup, ContainerlabTopology, DeviceMetric, SecurityFinding, Switch, Workflow
+from schemas import AuditLogOut, DashboardStats, DeviceMetricOut
+from sqlalchemy import desc
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
diff --git a/backend/routers/discovery.py b/backend/routers/discovery.py
index 336760b..1183e80 100644
--- a/backend/routers/discovery.py
+++ b/backend/routers/discovery.py
@@ -3,13 +3,12 @@
Auto-detect serial ports, USB devices, and network interfaces
connected to the host machine.
"""
+import logging
import os
import re
import subprocess
-import logging
from fastapi import APIRouter
-
from services.serial_client import list_available_ports
router = APIRouter(prefix="/api/system", tags=["system"])
diff --git a/backend/routers/security.py b/backend/routers/security.py
index ff506c5..3aa1062 100644
--- a/backend/routers/security.py
+++ b/backend/routers/security.py
@@ -2,24 +2,23 @@
CVE scanning, ACL/AAA audits, compliance checks, and finding management.
"""
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-from typing import Optional
from database import get_db
-from models import SecurityFinding, AuditLog
+from fastapi import APIRouter, Depends, HTTPException
+from models import SecurityFinding
from schemas import SecurityFindingOut, SecurityFindingUpdate
-from services.security_auditor import audit_switch, audit_all_switches, resolve_finding
+from services.security_auditor import audit_all_switches, audit_switch, resolve_finding
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/security", tags=["security"])
@router.get("/findings", response_model=list[SecurityFindingOut])
def list_findings(
- switch_id: Optional[int] = None,
- severity: Optional[str] = None,
- status: Optional[str] = None,
- finding_type: Optional[str] = None,
+ switch_id: int | None = None,
+ severity: str | None = None,
+ status: str | None = None,
+ finding_type: str | None = None,
db: Session = Depends(get_db),
):
"""List security findings with optional filters."""
diff --git a/backend/routers/switches.py b/backend/routers/switches.py
index cc1d096..56012fc 100644
--- a/backend/routers/switches.py
+++ b/backend/routers/switches.py
@@ -3,19 +3,21 @@
CRUD operations for network switches, plus config backup and sync
supporting both SSH and serial console connections.
"""
-from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
-from sqlalchemy.orm import Session
-from typing import Optional
from database import get_db
-from models import Switch, AuditLog
-from services.netmiko_client import pull_running_config, check_health, execute_commands, bulk_backup_all
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
+from models import AuditLog, Switch
+from schemas import SwitchCreate, SwitchOut, SwitchUpdate, TemplateApplyRequest
+from services.netmiko_client import bulk_backup_all, check_health, execute_commands, pull_running_config
from services.serial_client import (
- pull_running_config_serial, check_health_serial,
- execute_commands_serial, push_config_serial, list_available_ports,
+ check_health_serial,
+ execute_commands_serial,
+ list_available_ports,
+ pull_running_config_serial,
+ push_config_serial,
)
from services.template_engine import apply_template_to_switch
-from schemas import SwitchCreate, SwitchUpdate, SwitchOut, TemplateApplyRequest
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/switches", tags=["switches"])
@@ -30,9 +32,9 @@ def _get_connection_type(switch_id: int, db: Session) -> str:
@router.get("/", response_model=list[SwitchOut])
def list_switches(
- status: Optional[str] = None,
- vendor: Optional[str] = None,
- connection_type: Optional[str] = None,
+ status: str | None = None,
+ vendor: str | None = None,
+ connection_type: str | None = None,
db: Session = Depends(get_db),
):
"""List all switches with optional filtering."""
diff --git a/backend/routers/templates.py b/backend/routers/templates.py
index 9ccabf7..c5d73f3 100644
--- a/backend/routers/templates.py
+++ b/backend/routers/templates.py
@@ -3,22 +3,25 @@
CRUD operations for config templates, plus template rendering and
application to switches.
"""
+import contextlib
import json
import logging
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-from sqlalchemy import desc
-from typing import Optional
from database import get_db
-from models import ConfigTemplate, AuditLog
+from fastapi import APIRouter, Depends, HTTPException
+from models import AuditLog, ConfigTemplate
from schemas import (
- ConfigTemplateCreate, ConfigTemplateUpdate, ConfigTemplateOut,
+ ConfigTemplateCreate,
+ ConfigTemplateOut,
+ ConfigTemplateUpdate,
TemplateApplyRequest,
)
from services.template_engine import (
- render_template, apply_template_to_switch, seed_builtin_templates,
+ apply_template_to_switch,
+ render_template,
+ seed_builtin_templates,
)
+from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@@ -27,8 +30,8 @@
@router.get("/", response_model=list[ConfigTemplateOut])
def list_templates(
- vendor: Optional[str] = None,
- category: Optional[str] = None,
+ vendor: str | None = None,
+ category: str | None = None,
db: Session = Depends(get_db),
):
"""List all config templates with optional filtering."""
@@ -47,7 +50,7 @@ def create_template(data: ConfigTemplateCreate, db: Session = Depends(get_db)):
try:
render_template(data.template_body, {})
except ValueError as e:
- raise HTTPException(status_code=400, detail=f"Template syntax error: {e}")
+ raise HTTPException(status_code=400, detail=f"Template syntax error: {e}") from e
tmpl = ConfigTemplate(
name=data.name,
@@ -98,14 +101,12 @@ def update_template(template_id: int, data: ConfigTemplateUpdate, db: Session =
try:
render_template(update_data["template_body"], {})
except ValueError as e:
- raise HTTPException(status_code=400, detail=f"Template syntax error: {e}")
+ raise HTTPException(status_code=400, detail=f"Template syntax error: {e}") from e
# Normalize legacy double-encoded variables (stored as JSON strings)
if "variables" in update_data and isinstance(update_data["variables"], str):
- try:
+ with contextlib.suppress(ValueError, TypeError):
update_data["variables"] = json.loads(update_data["variables"])
- except (ValueError, TypeError):
- pass
for key, value in update_data.items():
setattr(tmpl, key, value)
@@ -163,7 +164,7 @@ def render_template_endpoint(data: TemplateApplyRequest, db: Session = Depends(g
"command_count": len(commands),
}
except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
+ raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/apply/{switch_id}")
diff --git a/backend/routers/workflows.py b/backend/routers/workflows.py
index fc9f5f8..2527f3e 100644
--- a/backend/routers/workflows.py
+++ b/backend/routers/workflows.py
@@ -2,13 +2,12 @@
IRIS-inspired operational workflow: Discover → Verify → Propose → Confirm → Execute → Verify → Document.
"""
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-
from database import get_db
+from fastapi import APIRouter, Depends, HTTPException
from models import Workflow, WorkflowStep
-from schemas import WorkflowCreate, WorkflowOut, WorkflowStepOut, WorkflowAdvanceRequest
+from schemas import WorkflowAdvanceRequest, WorkflowCreate, WorkflowOut
from services.workflow_engine import WorkflowEngine
+from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
diff --git a/backend/schemas.py b/backend/schemas.py
index 92a2ef2..c8c1729 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -1,8 +1,8 @@
"""Pydantic schemas for API request/response validation."""
from datetime import datetime
-from typing import Optional, Any
-from pydantic import BaseModel, Field
+from typing import Any
+from pydantic import BaseModel, Field
# ─── Switch ───
@@ -10,46 +10,46 @@ class SwitchCreate(BaseModel):
hostname: str = Field(..., min_length=1, max_length=255)
ip_address: str = Field(..., min_length=7, max_length=45)
vendor: str = "cisco_ios"
- device_type: Optional[str] = None
+ device_type: str | None = None
ssh_port: int = 22
- ssh_username: Optional[str] = None
- ssh_password: Optional[str] = None
- location: Optional[str] = None
- tags: Optional[str] = ""
- notes: Optional[str] = None
+ ssh_username: str | None = None
+ ssh_password: str | None = None
+ location: str | None = None
+ tags: str | None = ""
+ notes: str | None = None
# Connection type
connection_type: str = "ssh" # "ssh" or "serial"
# Serial settings
- serial_port: Optional[str] = None
+ serial_port: str | None = None
serial_baud: int = 9600
serial_databits: int = 8
serial_parity: str = "N"
serial_stopbits: int = 1
serial_timeout: int = 10
- serial_password: Optional[str] = None
+ serial_password: str | None = None
class SwitchUpdate(BaseModel):
- hostname: Optional[str] = None
- ip_address: Optional[str] = None
- vendor: Optional[str] = None
- device_type: Optional[str] = None
- ssh_port: Optional[int] = None
- ssh_username: Optional[str] = None
- ssh_password: Optional[str] = None
- location: Optional[str] = None
- tags: Optional[str] = None
- notes: Optional[str] = None
- status: Optional[str] = None
+ hostname: str | None = None
+ ip_address: str | None = None
+ vendor: str | None = None
+ device_type: str | None = None
+ ssh_port: int | None = None
+ ssh_username: str | None = None
+ ssh_password: str | None = None
+ location: str | None = None
+ tags: str | None = None
+ notes: str | None = None
+ status: str | None = None
# Connection type
- connection_type: Optional[str] = None
- serial_port: Optional[str] = None
- serial_baud: Optional[int] = None
- serial_databits: Optional[int] = None
- serial_parity: Optional[str] = None
- serial_stopbits: Optional[int] = None
- serial_timeout: Optional[int] = None
- serial_password: Optional[str] = None
+ connection_type: str | None = None
+ serial_port: str | None = None
+ serial_baud: int | None = None
+ serial_databits: int | None = None
+ serial_parity: str | None = None
+ serial_stopbits: int | None = None
+ serial_timeout: int | None = None
+ serial_password: str | None = None
class SwitchOut(BaseModel):
@@ -57,23 +57,23 @@ class SwitchOut(BaseModel):
hostname: str
ip_address: str
vendor: str
- device_type: Optional[str] = None
+ device_type: str | None = None
ssh_port: int
status: str
- os_version: Optional[str] = None
- serial_number: Optional[str] = None
- location: Optional[str] = None
- tags: Optional[str] = ""
- notes: Optional[str] = None
+ os_version: str | None = None
+ serial_number: str | None = None
+ location: str | None = None
+ tags: str | None = ""
+ notes: str | None = None
connection_type: str = "ssh"
- serial_port: Optional[str] = None
+ serial_port: str | None = None
serial_baud: int = 9600
serial_databits: int = 8
serial_parity: str = "N"
serial_stopbits: int = 1
serial_timeout: int = 10
created_at: datetime
- updated_at: Optional[datetime] = None
+ updated_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -85,7 +85,7 @@ class ConfigBackupOut(BaseModel):
switch_id: int
config_type: str
running_config: str
- config_hash: Optional[str] = None
+ config_hash: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
@@ -97,7 +97,7 @@ class ConfigDiffOut(BaseModel):
from_backup_id: int
to_backup_id: int
diff_content: str
- summary: Optional[str] = None
+ summary: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
@@ -124,15 +124,15 @@ class ChatMessageOut(BaseModel):
class WorkflowCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
- description: Optional[str] = None
- switch_ids: Optional[str] = None # comma-separated
- created_by: Optional[str] = None
- ticket_ref: Optional[str] = None
+ description: str | None = None
+ switch_ids: str | None = None # comma-separated
+ created_by: str | None = None
+ ticket_ref: str | None = None
class WorkflowAdvanceRequest(BaseModel):
approved: bool = False
- result: Optional[str] = None
+ result: str | None = None
class WorkflowStepOut(BaseModel):
@@ -140,13 +140,13 @@ class WorkflowStepOut(BaseModel):
workflow_id: int
step_type: str
status: str
- description: Optional[str] = None
- command: Optional[str] = None
- result: Optional[str] = None
+ description: str | None = None
+ command: str | None = None
+ result: str | None = None
requires_approval: bool = True
- approved: Optional[bool] = None
+ approved: bool | None = None
created_at: datetime
- completed_at: Optional[datetime] = None
+ completed_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -154,16 +154,16 @@ class WorkflowStepOut(BaseModel):
class WorkflowOut(BaseModel):
id: int
title: str
- description: Optional[str] = None
+ description: str | None = None
status: str
- switch_ids: Optional[str] = None
- created_by: Optional[str] = None
- approved_by: Optional[str] = None
- ticket_ref: Optional[str] = None
+ switch_ids: str | None = None
+ created_by: str | None = None
+ approved_by: str | None = None
+ ticket_ref: str | None = None
steps: list[WorkflowStepOut] = []
created_at: datetime
- updated_at: Optional[datetime] = None
- completed_at: Optional[datetime] = None
+ updated_at: datetime | None = None
+ completed_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -176,13 +176,13 @@ class SecurityFindingOut(BaseModel):
finding_type: str
severity: str
title: str
- description: Optional[str] = None
- remediation: Optional[str] = None
- cve_id: Optional[str] = None
- affected_component: Optional[str] = None
+ description: str | None = None
+ remediation: str | None = None
+ cve_id: str | None = None
+ affected_component: str | None = None
status: str
created_at: datetime
- resolved_at: Optional[datetime] = None
+ resolved_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -197,12 +197,12 @@ class ContainerlabTopologyOut(BaseModel):
id: int
name: str
topology_data: Any
- file_path: Optional[str] = None
+ file_path: str | None = None
node_count: int
link_count: int
is_active: bool
created_at: datetime
- last_synced_at: Optional[datetime] = None
+ last_synced_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -212,13 +212,13 @@ class ContainerlabTopologyOut(BaseModel):
class DeviceMetricOut(BaseModel):
id: int
switch_id: int
- cpu_usage: Optional[float] = None
- memory_usage: Optional[float] = None
- temperature: Optional[float] = None
- uptime_seconds: Optional[int] = None
- interface_count: Optional[int] = None
- interfaces_up: Optional[int] = None
- interfaces_down: Optional[int] = None
+ cpu_usage: float | None = None
+ memory_usage: float | None = None
+ temperature: float | None = None
+ uptime_seconds: int | None = None
+ interface_count: int | None = None
+ interfaces_up: int | None = None
+ interfaces_down: int | None = None
recorded_at: datetime
model_config = {"from_attributes": True}
@@ -229,12 +229,12 @@ class DeviceMetricOut(BaseModel):
class AuditLogOut(BaseModel):
id: int
action: str
- actor: Optional[str] = None
- target_type: Optional[str] = None
- target_id: Optional[int] = None
- details: Optional[Any] = None
+ actor: str | None = None
+ target_type: str | None = None
+ target_id: int | None = None
+ details: Any | None = None
status: str
- ip_address: Optional[str] = None
+ ip_address: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
@@ -256,36 +256,36 @@ class DashboardStats(BaseModel):
class ConfigTemplateCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
- description: Optional[str] = None
+ description: str | None = None
vendor: str = "cisco_ios"
category: str = "general"
template_body: str = Field(..., min_length=1)
- variables: Optional[Any] = None
- tags: Optional[str] = ""
+ variables: Any | None = None
+ tags: str | None = ""
class ConfigTemplateUpdate(BaseModel):
- name: Optional[str] = None
- description: Optional[str] = None
- vendor: Optional[str] = None
- category: Optional[str] = None
- template_body: Optional[str] = None
- variables: Optional[Any] = None
- tags: Optional[str] = None
+ name: str | None = None
+ description: str | None = None
+ vendor: str | None = None
+ category: str | None = None
+ template_body: str | None = None
+ variables: Any | None = None
+ tags: str | None = None
class ConfigTemplateOut(BaseModel):
id: int
name: str
- description: Optional[str] = None
+ description: str | None = None
vendor: str
category: str
template_body: str
- variables: Optional[Any] = None
- tags: Optional[str] = ""
+ variables: Any | None = None
+ tags: str | None = ""
is_builtin: bool = False
created_at: datetime
- updated_at: Optional[datetime] = None
+ updated_at: datetime | None = None
model_config = {"from_attributes": True}
diff --git a/backend/services/config_parser.py b/backend/services/config_parser.py
index 6362801..75fa709 100644
--- a/backend/services/config_parser.py
+++ b/backend/services/config_parser.py
@@ -4,9 +4,8 @@
from __future__ import annotations
import re
-from typing import List, Optional
-from services.switch_config_model import SwitchConfig, Vlan, RadiusServer
+from services.switch_config_model import RadiusServer, SwitchConfig, Vlan
_HOST_RE = re.compile(r'hostname\s+"?([^"\r\n]+)"?')
_VLAN_RE = re.compile(r"^vlan\s+(\d+)")
@@ -68,8 +67,8 @@ def parse_config(text: str) -> SwitchConfig:
cfg.sflow_ports = sf.group(1).split()[0].strip()
# VLANs - block parsing
- vlans: List[Vlan] = []
- current: Optional[Vlan] = None
+ vlans: list[Vlan] = []
+ current: Vlan | None = None
in_vlan = False
for raw in lines:
line = raw.rstrip()
@@ -120,5 +119,5 @@ def parse_config(text: str) -> SwitchConfig:
def parse_file(path: str) -> SwitchConfig:
- with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ with open(path, encoding="utf-8", errors="replace") as fh:
return parse_config(fh.read())
diff --git a/backend/services/connection.py b/backend/services/connection.py
index 57caf1e..886f47d 100644
--- a/backend/services/connection.py
+++ b/backend/services/connection.py
@@ -10,14 +10,14 @@
"""
from __future__ import annotations
+import contextlib
import logging
import socket
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
-from typing import List, Optional
-logger = logging.getLogger("nethermind.connection")
+logger = logging.getLogger("netcore.connection")
# Prompts a ProCurve/Aruba switch shows (we match the trailing '#' or '>').
@@ -28,8 +28,13 @@
# --------------------------------------------------------------------------
# Serial (console / USB)
# --------------------------------------------------------------------------
-class SerialConnection(ABC):
- pass
+class SerialConnection(ABC): # noqa: B024 - marker base, not an interface
+ """Marker base for serial (console/USB) connections.
+
+ Deliberately declares no abstract methods: concrete serial transports are
+ selected at runtime by `open_connection`, so subclasses are not required to
+ implement a fixed interface.
+ """
@dataclass
@@ -80,7 +85,7 @@ def send_line(self, line: str, delay: float = 0.15) -> None:
self._write("\r\n")
time.sleep(delay)
- def read_until(self, pattern: str, timeout: Optional[float] = None,
+ def read_until(self, pattern: str, timeout: float | None = None,
chunk_timeout: float = 0.4) -> str:
"""Read until a regex pattern (re.search) is found or timeout.
@@ -95,7 +100,7 @@ def read_until(self, pattern: str, timeout: Optional[float] = None,
while time.time() < deadline:
try:
chunk = self._read_chunk(chunk_timeout)
- except socket.timeout:
+ except TimeoutError:
chunk = ""
except Exception: # pragma: no cover - defensive
chunk = ""
@@ -117,7 +122,7 @@ def wait_for_priv(self) -> str:
return self.read_until(PRIV_PROMPT)
@staticmethod
- def build(params: ConnParams) -> "Connection":
+ def build(params: ConnParams) -> Connection:
if params.transport == "serial":
return PySerialConnection(params)
if params.transport == "ssh":
@@ -152,10 +157,8 @@ def connect(self) -> None:
self.read_until(PRIV_PROMPT + "|" + OPER_PROMPT, timeout=4.0)
def close(self) -> None:
- try:
+ with contextlib.suppress(Exception):
self._ser.close()
- except Exception:
- pass
def _write(self, data: str) -> None:
self._ser.write(data.encode("utf-8", errors="replace"))
@@ -193,14 +196,10 @@ def connect(self) -> None:
self.read_until(PRIV_PROMPT + "|" + OPER_PROMPT, timeout=6.0)
def close(self) -> None:
- try:
+ with contextlib.suppress(Exception):
self._chan.close()
- except Exception:
- pass
- try:
+ with contextlib.suppress(Exception):
self._ssh.close()
- except Exception:
- pass
def _write(self, data: str) -> None:
self._chan.send(data)
@@ -210,7 +209,7 @@ def _read_chunk(self, chunk_timeout: float) -> str:
self._chan.settimeout(chunk_timeout)
raw = self._chan.recv(4096)
return raw.decode("utf-8", errors="replace")
- except socket.timeout:
+ except TimeoutError:
return ""
except EOFError:
return ""
@@ -222,7 +221,7 @@ def _read_chunk(self, chunk_timeout: float) -> str:
class TelnetConnection(Connection):
def __init__(self, params: ConnParams):
super().__init__(params)
- self._sock: Optional[socket.socket] = None
+ self._sock: socket.socket | None = None
def connect(self) -> None:
self._sock = socket.create_connection(
@@ -237,10 +236,8 @@ def connect(self) -> None:
self.read_until(PRIV_PROMPT + "|" + OPER_PROMPT, timeout=6.0)
def close(self) -> None:
- try:
+ with contextlib.suppress(Exception):
self._sock.close()
- except Exception:
- pass
def _write(self, data: str) -> None:
self._sock.sendall(data.encode("utf-8", errors="replace"))
@@ -250,7 +247,7 @@ def _read_chunk(self, chunk_timeout: float) -> str:
try:
raw = self._sock.recv(4096)
return raw.decode("utf-8", errors="replace")
- except socket.timeout:
+ except TimeoutError:
return ""
except EOFError:
return ""
diff --git a/backend/services/containerlab_service.py b/backend/services/containerlab_service.py
index b1cbecd..6e50a41 100644
--- a/backend/services/containerlab_service.py
+++ b/backend/services/containerlab_service.py
@@ -6,14 +6,11 @@
Inspired by: github.com/zerxen/AINetworkHelperForContainerLab
"""
import os
-import json
-import yaml
-from typing import Optional
-from pathlib import Path
-from sqlalchemy.orm import Session
-from models import Switch, ContainerlabTopology, AuditLog
+import yaml
from config import settings
+from models import AuditLog, ContainerlabTopology, Switch
+from sqlalchemy.orm import Session
def discover_clab_topologies() -> list[dict]:
@@ -33,7 +30,7 @@ def discover_clab_topologies() -> list[dict]:
for base_path in search_paths:
if not os.path.isdir(base_path):
continue
- for root, dirs, files in os.walk(base_path):
+ for root, _dirs, files in os.walk(base_path):
for f in files:
if f.endswith(".clab.yml") or f.endswith(".clab.yaml"):
full_path = os.path.join(root, f)
@@ -75,10 +72,7 @@ def parse_topology_yaml(file_path: str, data: dict) -> dict:
# Parse links
link_list = []
for link in links:
- if isinstance(link, dict):
- endpoints = link.get("endpoints", [])
- else:
- endpoints = str(link).split(":")
+ endpoints = link.get("endpoints", []) if isinstance(link, dict) else str(link).split(":")
link_list.append({
"endpoints": endpoints if isinstance(endpoints, list) else [str(link)]
})
diff --git a/backend/services/deployer.py b/backend/services/deployer.py
index 12f1c32..232f471 100644
--- a/backend/services/deployer.py
+++ b/backend/services/deployer.py
@@ -2,18 +2,18 @@
config line-by-line, write memory, and verify the running hostname."""
from __future__ import annotations
+import contextlib
import logging
import os
-import time
+from collections.abc import Callable
from dataclasses import dataclass, field
-from typing import Callable, List, Optional
from jinja2 import Environment, FileSystemLoader
-from services.connection import Connection, PRIV_PROMPT, CONFIG_PROMPT
+from services.connection import PRIV_PROMPT, Connection
from services.switch_config_model import SwitchConfig
-logger = logging.getLogger("nethermind.deployer")
+logger = logging.getLogger("netcore.deployer")
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "templates")
ProgressCB = Callable[[str], None]
@@ -33,7 +33,7 @@ def render_config(cfg: SwitchConfig, template_name: str = "arubaos_full_config.j
ctx["vlans"] = vlans
text = tpl.render(**ctx)
lines = [ln.rstrip() for ln in text.splitlines()]
- out: List[str] = []
+ out: list[str] = []
for ln in lines:
if ln == "" and out and out[-1] == "":
continue
@@ -46,12 +46,12 @@ class DeployResult:
success: bool
hostname_seen: str = ""
lines_sent: int = 0
- errors: List[str] = field(default_factory=list)
- log: List[str] = field(default_factory=list)
+ errors: list[str] = field(default_factory=list)
+ log: list[str] = field(default_factory=list)
class Deployer:
- def __init__(self, conn: Connection, progress: Optional[ProgressCB] = None):
+ def __init__(self, conn: Connection, progress: ProgressCB | None = None):
self.conn = conn
self.progress = progress or (lambda s: None)
@@ -116,8 +116,6 @@ def deploy(self, cfg: SwitchConfig, pre_clear: bool = False) -> DeployResult:
if cfg.hostname not in res.hostname_seen:
res.errors.append(f"hostname verify mismatch: expected {cfg.hostname!r} got {res.hostname_seen!r}")
res.log.append(verify)
- try:
+ with contextlib.suppress(Exception):
self.conn.close()
- except Exception:
- pass
return res
diff --git a/backend/services/nethermind_agent.py b/backend/services/nethermind_agent.py
index 67512db..6890cfb 100644
--- a/backend/services/nethermind_agent.py
+++ b/backend/services/nethermind_agent.py
@@ -5,14 +5,13 @@
get_audit_logs, and diff_configs.
"""
import json
-from typing import Optional
-from openai import AsyncOpenAI
-from sqlalchemy.orm import Session
from config import settings
from database import SessionLocal
-from models import Switch, ConfigBackup, ChatMessage, SecurityFinding, AuditLog, DeviceMetric
-from services.netmiko_client import pull_running_config, execute_commands, check_health
+from models import AuditLog, ChatMessage, ConfigBackup, SecurityFinding, Switch
+from openai import AsyncOpenAI
+
+from services.netmiko_client import check_health, execute_commands, pull_running_config
def _get_client() -> AsyncOpenAI:
@@ -267,9 +266,9 @@ async def call_tool(name: str, args: dict) -> str:
limit = args.get("limit", 20)
logs = query.order_by(AuditLog.created_at.desc()).limit(limit).all()
return json.dumps([
- {"action": l.action, "status": l.status, "details": l.details,
- "created_at": str(l.created_at)}
- for l in logs
+ {"action": lg.action, "status": lg.status, "details": lg.details,
+ "created_at": str(lg.created_at)}
+ for lg in logs
], indent=2)
elif name == "get_network_dashboard":
@@ -286,8 +285,8 @@ async def call_tool(name: str, args: dict) -> str:
"total_config_backups": configs_count,
"open_security_findings": open_findings,
"recent_activity": [
- {"action": l.action, "status": l.status, "time": str(l.created_at)}
- for l in recent_logs
+ {"action": lg.action, "status": lg.status, "time": str(lg.created_at)}
+ for lg in recent_logs
]
}, indent=2)
diff --git a/backend/services/netmiko_client.py b/backend/services/netmiko_client.py
index d1bb065..624d3f1 100644
--- a/backend/services/netmiko_client.py
+++ b/backend/services/netmiko_client.py
@@ -3,16 +3,13 @@
Supports Cisco IOS, Cisco XR, Juniper JunOS, Arista EOS, and Linux.
Handles config backup, config push, health check, and command execution.
"""
-import os
-import re
import hashlib
-from typing import Optional
-from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
+import re
from config import settings
from database import SessionLocal
-from models import Switch, ConfigBackup, AuditLog, DeviceMetric
-
+from models import AuditLog, ConfigBackup, DeviceMetric, Switch
+from netmiko import ConnectHandler, NetmikoAuthenticationException, NetmikoTimeoutException
VENDOR_MAP = {
"cisco_ios": "cisco_ios",
@@ -177,7 +174,6 @@ def pull_running_config(switch_id: int) -> dict:
cpu = None
mem = None
if "CPU" in health:
- import re
cpu_match = re.search(r'(\d+\.?\d*)%', health)
if cpu_match:
cpu = float(cpu_match.group(1))
@@ -266,7 +262,6 @@ def check_health(switch_id: int) -> dict:
vendor_commands = SHOW_COMMANDS.get(switch.vendor, SHOW_COMMANDS["cisco_ios"])
health_output = conn.send_command(vendor_commands.get("health", "show clock"))
memory_output = conn.send_command(vendor_commands.get("memory", ""), delay_factor=2)
- version_output = conn.send_command(vendor_commands.get("version", "show version"), delay_factor=2)
interface_output = conn.send_command(vendor_commands.get("interfaces", ""), delay_factor=2)
conn.disconnect()
diff --git a/backend/services/security_auditor.py b/backend/services/security_auditor.py
index 888c842..562227d 100644
--- a/backend/services/security_auditor.py
+++ b/backend/services/security_auditor.py
@@ -10,12 +10,9 @@
Inspired by: NetClaw security auditing capabilities.
"""
import re
-from typing import Optional
-from sqlalchemy.orm import Session
-
-from models import Switch, ConfigBackup, SecurityFinding, AuditLog
-from services.netmiko_client import execute_commands
+from models import AuditLog, ConfigBackup, SecurityFinding, Switch
+from sqlalchemy.orm import Session
# Known vulnerable OS versions (simplified CVE database)
# In production, you'd integrate with NVD API or similar
@@ -58,9 +55,11 @@ def _check_aaa_config(config: str) -> list[dict]:
})
# Check for local login fallback
- if "aaa authentication login" in config:
- if "local" not in config.lower().split("aaa authentication")[1].split("\n")[0] if "aaa authentication" in config else "":
- findings.append({
+ aaa_login_line = ""
+ if "aaa authentication" in config:
+ aaa_login_line = config.lower().split("aaa authentication")[1].split("\n")[0]
+ if "aaa authentication login" in config and "local" not in aaa_login_line:
+ findings.append({
"finding_type": "aaa_misconfig",
"severity": "medium",
"title": "No local fallback for AAA authentication",
@@ -161,7 +160,7 @@ def _check_acls(config: str) -> list[dict]:
})
# Check ACL line count
- entry_count = sum(1 for l in lines if l.strip().startswith(("permit", "deny")))
+ entry_count = sum(1 for ln in lines if ln.strip().startswith(("permit", "deny")))
if entry_count > 100:
findings.append({
"finding_type": "acl_vulnerability",
diff --git a/backend/services/serial_client.py b/backend/services/serial_client.py
index f9e98e1..07b9da5 100644
--- a/backend/services/serial_client.py
+++ b/backend/services/serial_client.py
@@ -4,18 +4,16 @@
RS-232, etc.) for out-of-band management, initial provisioning, and
recovery scenarios where SSH is not available.
"""
-import re
-import time
import hashlib
import logging
-from typing import Optional
+import re
+import time
import serial
-from serial.tools import list_ports
-
from config import settings
from database import SessionLocal
-from models import Switch, ConfigBackup, AuditLog, DeviceMetric
+from models import AuditLog, ConfigBackup, DeviceMetric, Switch
+from serial.tools import list_ports
logger = logging.getLogger(__name__)
@@ -235,7 +233,6 @@ def check_health_serial(switch_id: int) -> dict:
_send_command_serial(conn, "terminal length 0", prompt=b"#")
health_output = _send_command_serial(conn, "show processes cpu | include CPU", prompt=b"#", read_timeout=8.0)
- version_output = _send_command_serial(conn, "show version", prompt=b"#", read_timeout=8.0)
interface_output = _send_command_serial(conn, "show interfaces summary", prompt=b"#", read_timeout=8.0)
conn.close()
diff --git a/backend/services/switch_config_model.py b/backend/services/switch_config_model.py
index 064786d..84c7ddd 100644
--- a/backend/services/switch_config_model.py
+++ b/backend/services/switch_config_model.py
@@ -7,8 +7,7 @@
from __future__ import annotations
import json
-from dataclasses import dataclass, field, asdict
-from typing import List, Dict, Optional
+from dataclasses import asdict, dataclass, field
@dataclass
@@ -21,11 +20,11 @@ class Vlan:
mask: str = "" # subnet mask
helper: str = "" # ip helper-address (dhcp relay)
- def to_dict(self) -> Dict:
+ def to_dict(self) -> dict:
return asdict(self)
@classmethod
- def from_dict(cls, d: Dict) -> "Vlan":
+ def from_dict(cls, d: dict) -> Vlan:
return cls(
id=int(d["id"]),
name=d.get("name", ""),
@@ -42,11 +41,11 @@ class RadiusServer:
host: str
key: str = ""
- def to_dict(self) -> Dict:
+ def to_dict(self) -> dict:
return asdict(self)
@classmethod
- def from_dict(cls, d: Dict) -> "RadiusServer":
+ def from_dict(cls, d: dict) -> RadiusServer:
return cls(host=d["host"], key=d.get("key", ""))
@@ -60,13 +59,13 @@ class SwitchConfig:
management_vlan_mask: str = ""
snmpv3: bool = False
snmp_contact: str = "OIT"
- radius_servers: List[RadiusServer] = field(default_factory=list)
- vlans: List[Vlan] = field(default_factory=list)
+ radius_servers: list[RadiusServer] = field(default_factory=list)
+ vlans: list[Vlan] = field(default_factory=list)
access_ports: str = "1-24" # port-security / stp / loop-protect scope
trust_ports: str = "25-28" # uplinks trusted for dhcp-snooping
- dhcp_authorized_servers: List[str] = field(default_factory=list)
+ dhcp_authorized_servers: list[str] = field(default_factory=list)
dhcp_option82: bool = False
- sntp_servers: List[str] = field(default_factory=lambda: ["192.168.1.2", "192.168.1.4"])
+ sntp_servers: list[str] = field(default_factory=lambda: ["192.168.1.2", "192.168.1.4"])
# core only
ospf_area: str = "0.0.0.1"
sflow_dest: str = "10.0.0.100"
@@ -76,7 +75,7 @@ class SwitchConfig:
backbone_port: str = "A2"
# ---------- serialisation ----------
- def to_dict(self) -> Dict:
+ def to_dict(self) -> dict:
d = asdict(self)
d["radius_servers"] = [r.to_dict() for r in self.radius_servers]
d["vlans"] = [v.to_dict() for v in self.vlans]
@@ -86,7 +85,7 @@ def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
@classmethod
- def from_dict(cls, d: Dict) -> "SwitchConfig":
+ def from_dict(cls, d: dict) -> SwitchConfig:
cfg = cls(
hostname=d.get("hostname", ""),
role=d.get("role", "access"),
@@ -113,12 +112,12 @@ def from_dict(cls, d: Dict) -> "SwitchConfig":
return cfg
@classmethod
- def from_json(cls, text: str) -> "SwitchConfig":
+ def from_json(cls, text: str) -> SwitchConfig:
return cls.from_dict(json.loads(text))
# ---------- validation ----------
- def validate(self) -> List[str]:
- errs: List[str] = []
+ def validate(self) -> list[str]:
+ errs: list[str] = []
if not self.hostname:
errs.append("hostname is required")
if self.role not in ("access", "core"):
diff --git a/backend/services/template_engine.py b/backend/services/template_engine.py
index a04b188..a7f32df 100644
--- a/backend/services/template_engine.py
+++ b/backend/services/template_engine.py
@@ -3,15 +3,14 @@
Provides template rendering with variable substitution, validation,
and a library of built-in templates for common network configurations.
"""
+import contextlib
import json
import logging
-from typing import Optional
+from database import SessionLocal
from jinja2 import TemplateError, UndefinedError
from jinja2.sandbox import SandboxedEnvironment
-
-from database import SessionLocal
-from models import ConfigTemplate, AuditLog
+from models import AuditLog, ConfigTemplate
logger = logging.getLogger(__name__)
@@ -1651,10 +1650,8 @@ def seed_builtin_templates():
# Normalize legacy double-encoded variables stored as JSON strings
for t in db.query(ConfigTemplate).filter_by(is_builtin=True).all():
if isinstance(t.variables, str):
- try:
+ with contextlib.suppress(ValueError, TypeError):
t.variables = json.loads(t.variables)
- except (ValueError, TypeError):
- pass
db.commit()
return
@@ -1701,11 +1698,11 @@ def render_template(template_body: str, variables: dict) -> str:
rendered = jinja_template.render(**variables)
return rendered.strip()
except UndefinedError as e:
- raise ValueError(f"Missing template variable: {e}")
+ raise ValueError(f"Missing template variable: {e}") from e
except TemplateError as e:
- raise ValueError(f"Template syntax error: {e}")
+ raise ValueError(f"Template syntax error: {e}") from e
except Exception as e:
- raise ValueError(f"Template rendering failed: {e}")
+ raise ValueError(f"Template rendering failed: {e}") from e
def apply_template_to_switch(switch_id: int, template_id: int, variables: dict) -> dict:
diff --git a/backend/services/workflow_engine.py b/backend/services/workflow_engine.py
index ae1b699..a74ed15 100644
--- a/backend/services/workflow_engine.py
+++ b/backend/services/workflow_engine.py
@@ -7,13 +7,12 @@
and maintains an immutable audit trail.
"""
from datetime import datetime
-from typing import Optional
-from sqlalchemy.orm import Session
-from models import Workflow, WorkflowStep, Switch, AuditLog
+from models import AuditLog, Workflow, WorkflowStep
from schemas import WorkflowCreate
-from services.netmiko_client import pull_running_config, execute_commands, check_health
+from sqlalchemy.orm import Session
+from services.netmiko_client import check_health, pull_running_config
WORKFLOW_STEPS = [
"discover",
@@ -202,7 +201,7 @@ def _audit(self, action: str, workflow_id: int, details: dict):
status="success", details=details
))
- def get(self, workflow_id: int) -> Optional[Workflow]:
+ def get(self, workflow_id: int) -> Workflow | None:
"""Get a workflow with its steps."""
wf = self.db.query(Workflow).filter_by(id=workflow_id).first()
if wf:
diff --git a/desktop/build-linux.sh b/desktop/build-linux.sh
index 708cc4c..865207a 100644
--- a/desktop/build-linux.sh
+++ b/desktop/build-linux.sh
@@ -1,8 +1,5 @@
-#!/bin/bash
-set -e
-
echo "============================================"
-echo " Nethermind Desktop - Linux Build"
+echo " NetCore Desktop - Linux Build"
echo "============================================"
echo ""
@@ -27,7 +24,7 @@ echo ""
echo "Output files are in: desktop/dist/"
echo ""
echo "Available packages:"
-echo " - Nethermind-*-x64.AppImage (Universal Linux)"
-echo " - Nethermind-*-x64.deb (Debian/Ubuntu)"
-echo " - Nethermind-*-x64.rpm (Fedora/RHEL)"
+echo " - NetCore-*-x64.AppImage (Universal Linux)"
+echo " - NetCore-*-x64.deb (Debian/Ubuntu)"
+echo " - NetCore-*-x64.rpm (Fedora/RHEL)"
echo ""
diff --git a/desktop/package.json b/desktop/package.json
index 70305d8..e5f196c 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,5 +1,5 @@
{
- "name": "nethermind",
+ "name": "netcore",
"version": "1.0.0",
"description": "AI-powered network switch configuration management",
"main": "main.js",
@@ -13,8 +13,8 @@
"build:all": "electron-builder --win --linux --mac"
},
"build": {
- "appId": "com.nethermind.app",
- "productName": "Nethermind",
+ "appId": "com.netcore.app",
+ "productName": "NetCore",
"directories": {
"output": "dist"
},
@@ -34,14 +34,14 @@
"arch": ["x64"]
}
],
- "artifactName": "Nethermind-${version}-win-${arch}.exe"
+ "artifactName": "NetCore-${version}-win-${arch}.exe"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
- "shortcutName": "Nethermind",
+ "shortcutName": "NetCore",
"installerLanguages": ["en_US"],
"language": "1033"
},
@@ -61,7 +61,7 @@
}
],
"category": "Network",
- "artifactName": "Nethermind-${version}-linux-${arch}.${ext}"
+ "artifactName": "NetCore-${version}-linux-${arch}.${ext}"
},
"mac": {
"target": "dmg",
diff --git a/docs/TEMPLATE-SYSTEM.md b/docs/TEMPLATE-SYSTEM.md
new file mode 100644
index 0000000..de1b7da
--- /dev/null
+++ b/docs/TEMPLATE-SYSTEM.md
@@ -0,0 +1,314 @@
+# NetCore Template System
+
+## Overview
+
+NetCore provides a robust template engine for generating network device configurations from Jinja2 templates. The system supports 50+ built-in templates covering various device vendors (Cisco, Aruba, Juniper, Arista, Linux) and use cases (VLANs, security, routing, interfaces, etc.).
+
+## Template Structure
+
+Templates are organized in the `templates/` directory and follow a consistent naming convention and structure:
+
+```
+templates/
+├── vendor_name_template_type.j2
+├── generic_template_name.j2
+├── device_type_specific.j2
+└── ... (50+ templates total)
+```
+
+### Template Naming Convention
+
+- **Vendor-specific templates**: `{vendor_name}_{description}.j2`
+ - `arubaos_full_config.j2`
+ - `cisco_ios_access_config.j2`
+ - `juniper_junos_routing.j2`
+
+- **Generic templates**: `{use_case}.j2`
+ - `vlan_template.j2`
+ - `security_config.j2`
+ - `routing_config.j2`
+
+- **Device-type templates**: `{device_type}_{feature}.j2`
+ - `switch_interface_config.j2`
+ - `router_ospf_config.j2`
+
+## Template Categories
+
+### 1. Vendor-Specific Templates
+
+Templates tailored for specific device vendors with vendor-specific syntax and features.
+
+**ArubaOS**:
+- `arubaos_full_config.j2` - Complete ArubaOS-Switch/ProCurve configuration
+
+**Cisco IOS**:
+- `cisco_ios_access_config.j2` - Access switch configuration
+- `cisco_ios_core_config.j2` - Core router configuration
+- `cisco_xr_config.j2` - Cisco ASR/XR router configuration
+
+**Juniper JunOS**:
+- `juniper_junos_routing.j2` - Routing configuration
+- `juniper_junos_security.j2` - Security configuration
+
+**Arista EOS**:
+- `arista_eos_interfaces.j2` - Interface configuration
+- `arista_eos_routing.j2` - Routing configuration
+
+**Linux**:
+- `linux_switch_config.j2` - Linux-based switch configuration
+
+### 2. Generic Templates
+
+Device-agnostic templates that work across multiple vendors.
+
+- `vlan_template.j2` - VLAN configuration template
+- `security_template.j2` - Security policies template
+- `routing_template.j2` - Routing configuration template
+- `interface_template.j2` - Interface configuration template
+
+### 3. Device-Type Templates
+
+Templates specific to device types (switch vs router).
+
+- `switch_access_config.j2` - Access switch configuration
+- `switch_core_config.j2` - Core switch configuration
+- `router_access_config.j2` - Access router configuration
+
+## Template Variables
+
+Each template can use various variables to customize the generated configuration:
+
+### Network Variables
+- `hostname` - Device hostname
+- `default_gateway` - Default gateway IP
+- `timezone` - Timezone setting
+- `ospf_area` - OSPF area ID
+
+### VLAN Variables
+- `vlans` - List of VLAN objects with attributes:
+ - `id` - VLAN ID
+ - `name` - VLAN name
+ - `untagged` - Untagged ports
+ - `tagged` - Tagged ports
+ - `ip` - VLAN IP address
+ - `mask` - VLAN subnet mask
+ - `helper` - DHCP helper address
+
+### Security Variables
+- `radius_servers` - List of RADIUS servers
+ - `host` - Server IP
+ - `key` - Authentication key
+- `snmpv3` - Enable SNMPv3 security
+- `snmp_contact` - Contact information
+- `snmp_server_community` - SNMP community strings
+
+### Interface Variables
+- `access_ports` - Access port list
+- `sflow_ports` - sFlow sampling ports
+- `trust_ports` - Trusted ports for DHCP
+- `backbone_vlan` - Backbone VLAN ID
+- `backbone_port` - Backbone port interface
+
+### Features and Protocols
+- `role` - Device role (`switch` or `router`)
+- `ip_routing` - Enable IP routing
+- `router_ospf` - Enable OSPF
+- `snmpv3_enable` - Enable SNMPv3
+- `dhcp_option82` - Enable DHCP Option 82
+- `loop_protect` - Loop protection settings
+- `spanning_tree` - Spanning tree configuration
+
+## Template Usage
+
+### Rendering a Template
+
+Use the NetCore CLI or API to render a template:
+
+```bash
+# Using CLI
+cd scripts
+python cli.py render --hostname SW01 --mgmt-ip 192.168.1.10 --role access
+
+# Or use API endpoint
+POST /api/templates/render
+{
+ "template_name": "arubaos_full_config.j2",
+ "variables": {
+ "hostname": "SW01",
+ "role": "access",
+ "default_gateway": "192.168.1.1",
+ "timezone": -240
+ }
+}
+```
+
+### Template Variables Reference
+
+#### Required Variables
+- `hostname` - Device hostname
+
+#### Optional Variables
+- `role` - Device role (`switch` or `router`)
+- `default_gateway` - Default gateway IP
+- `timezone` - Timezone offset in hours
+- `ospf_area` - OSPF area ID
+- `vlan_list` - List of VLAN configurations
+- `radius_servers` - List of RADIUS servers
+- `snmpv3` - Enable SNMPv3
+- `snmp_contact` - Contact information
+- `access_ports` - Access port list
+- `sflow_ports` - sFlow sampling ports
+- `trust_ports` - Trusted ports
+- `backbone_vlan` - Backbone VLAN ID
+- `backbone_port` - Backbone port interface
+
+### Template Rendering Process
+
+1. **Template Selection**: Choose the appropriate template based on vendor and use case
+2. **Variable Preparation**: Prepare the template variables
+3. **Template Rendering**: Use Jinja2 to render the template
+4. **Configuration Validation**: Validate the generated configuration
+5. **Deployment**: Deploy the configuration to the device
+
+## Template Development
+
+### Creating New Templates
+
+To create a new template:
+
+1. **Create the template file** in `templates/` directory
+2. **Define the Jinja2 syntax** with proper ArubaOS/ProCurve configuration commands
+3. **Document variables** used in the template
+4. **Test the template** with sample variables
+
+### Template Best Practices
+
+1. **Consistent naming**: Follow the established naming convention
+2. **Modular design**: Use inheritance for common template sections
+3. **Comprehensive variable documentation**: Document all template variables
+4. **Validation**: Validate template output before deployment
+5. **Testing**: Test templates with various input scenarios
+
+## Built-in Templates
+
+### Current Available Templates
+
+**ArubaOS/ProCurve**:
+- `arubaos_full_config.j2` - Complete ArubaOS-Switch configuration
+
+**Cisco IOS**:
+- `cisco_ios_access_config.j2` - Access switch configuration
+- `cisco_ios_core_config.j2` - Core router configuration
+
+**Juniper JunOS**:
+- `juniper_junos_routing.j2` - Routing configuration
+- `juniper_junos_security.j2` - Security configuration
+
+**Arista EOS**:
+- `arista_eos_interfaces.j2` - Interface configuration
+
+**Linux**:
+- `linux_switch_config.j2` - Linux-based switch configuration
+
+### Template Examples
+
+#### ArubaOS Full Config Template
+
+```jinja2
+{#- ArubaOS-Switch / ProCurve standard config generator -#}
+hostname "{{ hostname }}"
+time timezone {{ timezone | default(-240) }}
+
+{% if role == "access" and default_gateway %}
+ip default-gateway {{ default_gateway }}
+{% endif %}
+
+timesync sntp
+
+{% if snmpv3 %}
+snmpv3 enable
+snmpv3 only
+snmpv3 restricted-access
+snmpv3 user "swmonitor"
+snmpv3 group OperatorAuth user "swmonitor" sec-model ver3
+{% endif %}
+
+snmp-server community "public" operator unrestricted
+snmp-server community "netadmin" operator unrestricted
+
+{% if radius_servers %}
+aaa authentication login privilege-mode
+aaa authentication console login radius local
+aaa authentication console enable radius local
+aaa authentication telnet login radius local
+aaa authentication telnet enable radius local
+aaa authentication web login radius local
+aaa authentication ssh login radius local
+aaa authentication ssh enable radius local
+
+{% for r in radius_servers %}
+radius-server host {{ r.host }} key "{{ r.key }}"
+{% endfor %}
+{% endif %}
+```
+
+## Template Integration
+
+### API Integration
+
+The NetCore API provides endpoints for template operations:
+
+- `GET /api/templates/` - List all available templates
+- `POST /api/templates/render` - Render a template with variables
+- `POST /api/config/render` - Render configuration and deploy to device
+
+### CLI Integration
+
+The NetCore CLI provides template rendering capabilities:
+
+```bash
+# List available templates
+NetCore templates list
+
+# Render a template
+cd scripts
+python cli.py render --template arubaos_full_config.j2 --hostname SW01 --role access
+```
+
+## Template Validation
+
+### Configuration Validation
+
+Generated configurations are validated for:
+- Syntax correctness
+- Required parameter presence
+- Device compatibility
+- Security compliance
+
+### Template Testing
+
+Templates are tested with various scenarios:
+- Edge cases (empty values, special characters)
+- Different device types
+- Various vendor-specific syntax
+- Large configuration sizes
+
+## Future Enhancements
+
+### Planned Template Additions
+
+1. **Cisco Nexus templates**: NX-OS configuration templates
+2. **Huawei templates**: Huawei VRRP and stacking configurations
+3. **Template inheritance**: Support for template inheritance and mixins
+4. **Template versioning**: Template versioning and rollback support
+5. **Template marketplace**: Community template marketplace
+6. **Template automation**: Automated template generation based on device profiles
+
+## Documentation
+
+This documentation is continuously updated as new templates are added and the template system evolves. For the most up-to-date information, refer to the NetCore GitHub repository.
+
+---
+
+*Last updated: Current release*
+*Template version: 1.0*
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 95fa380..12fed8e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "nethermind",
+ "name": "netcore",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "nethermind",
+ "name": "netcore",
"version": "1.0.0",
"dependencies": {
"lucide-react": "^0.370.0",
diff --git a/frontend/package.json b/frontend/package.json
index 001260c..ffbc8c5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "nethermind",
+ "name": "netcore",
"version": "1.0.0",
"private": true,
"scripts": {
diff --git a/install.sh b/install.sh
index 61c3eac..780c05f 100644
--- a/install.sh
+++ b/install.sh
@@ -3,7 +3,7 @@ set -euo pipefail
cd "$(dirname "$0")"
-echo "=== Installing nethermind ==="
+echo "=== Installing NetCore ==="
if ! command -v docker &> /dev/null; then
echo "Docker is required but not installed. Please install Docker first."
@@ -25,11 +25,11 @@ if [ ! -f .env ]; then
fi
fi
-echo "Building and starting nethermind on port 3000..."
+echo "Building and starting NetCore on port 3000..."
if docker compose version &> /dev/null; then
docker compose up --build -d
else
docker-compose up --build -d
fi
-echo "nethermind installed. Access: http://localhost:3000"
+echo "NetCore installed. Access: http://localhost:3000"
diff --git a/pyproject.toml b/pyproject.toml
index 7b892b8..5d994a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,12 @@ line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
-ignore = ["E501"]
+ignore = [
+ "E501", # line length is already capped by the formatter
+ # B008: FastAPI *requires* Depends()/File()/Query() to be called in argument
+ # defaults — that is the documented dependency-injection idiom, not a bug.
+ "B008",
+]
[tool.pytest.ini_options]
testpaths = ["tests"]
diff --git a/scripts/cli.py b/scripts/cli.py
index 8f100ae..2ab7b9d 100644
--- a/scripts/cli.py
+++ b/scripts/cli.py
@@ -2,7 +2,6 @@
from __future__ import annotations
import argparse
-import json
import logging
import sys
from pathlib import Path
@@ -10,11 +9,10 @@
# Add backend to path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
-from services.switch_config_model import SwitchConfig
-from services.connection import ConnParams, Connection
+from services.config_parser import parse_config, parse_file
+from services.connection import Connection, ConnParams
from services.deployer import Deployer
-from services.config_parser import parse_file, parse_config
-from services.switch_config_model import standard_access_switch, standard_core_switch
+from services.switch_config_model import SwitchConfig, standard_access_switch, standard_core_switch
def cmd_render(args):
@@ -94,10 +92,7 @@ def cmd_upload(args):
cfg = parse_file(str(file_path))
# Determine output path
- if args.out:
- out_path = Path(args.out)
- else:
- out_path = file_path.with_suffix('.json')
+ out_path = Path(args.out) if args.out else file_path.with_suffix('.json')
out_path.write_text(cfg.to_json())
print(f"Saved structured config to {out_path}")
@@ -128,7 +123,7 @@ def cmd_backup(args):
conn.connect()
# Get to privileged exec
- from services.connection import PRIV_PROMPT, OPER_PROMPT
+ from services.connection import OPER_PROMPT, PRIV_PROMPT
conn.read_until(PRIV_PROMPT + "|" + OPER_PROMPT, timeout=8.0)
# Pull running config
@@ -148,9 +143,7 @@ def cmd_backup(args):
continue
clean_lines.append(line)
- config_text = "
-".join(clean_lines).strip() + "
-"
+ config_text = "\n".join(clean_lines).strip() + "\n"
conn.close()
@@ -187,8 +180,8 @@ def cmd_backup(args):
def render_config(cfg):
"""Render a SwitchConfig using the Jinja2 template engine."""
+
from jinja2 import Environment, FileSystemLoader
- import os
template_dir = Path(__file__).resolve().parent.parent / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)), keep_trailing_newline=True)
tpl = env.get_template("arubaos_full_config.j2")
@@ -212,9 +205,8 @@ def render_config(cfg):
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
- prog="nethermind-cli",
- description="Nethermind — configure Aruba/ProCurve switches via "
- "console, SSH or Telnet.")
+ prog="netcore-cli",
+ description="NetCore — configure network switches via console, SSH or Telnet.")
sub = p.add_subparsers(dest="cmd", required=True)
diff --git a/scripts/nethermind.py b/scripts/nethermind.py
index ef038c7..7186837 100755
--- a/scripts/nethermind.py
+++ b/scripts/nethermind.py
@@ -17,6 +17,9 @@
NetCore dashboard health
NetCore server start [--backend-only] [--frontend-only]
NetCore server stop
+
+Note: SSH passwords should be managed through the .env file for security.
+Use environment variables instead of passing passwords via CLI.
"""
import argparse
import json
@@ -32,8 +35,8 @@
def _req(method, path, data=None):
- import urllib.request
import urllib.error
+ import urllib.request
url = f"{API_BASE}/api{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
@@ -77,9 +80,12 @@ def cmd_switch_list(args):
def cmd_switch_add(args):
payload = {"hostname": args.hostname, "ip_address": args.ip, "vendor": args.vendor}
- if args.ssh_port: payload["ssh_port"] = args.ssh_port
- if args.username: payload["ssh_username"] = args.username
- if args.password: payload["ssh_password"] = args.password
+ if args.ssh_port:
+ payload["ssh_port"] = args.ssh_port
+ if args.username:
+ payload["ssh_username"] = args.username
+ # Note: SSH passwords should be managed through the .env file, not passed via CLI
+ # for security reasons. Use environment variables instead.
result = _req("POST", "/switches/", payload)
if "error" in result:
print(f"Error: {result['error']}")
@@ -173,7 +179,7 @@ def cmd_security_findings(args):
def cmd_dashboard_stats(_args):
data = _req("GET", "/dashboard/stats")
- print("Nethermind Dashboard Stats:")
+ print("NetCore Dashboard Stats:")
print(f" Total Switches: {data.get('total_switches', 0)}")
print(f" Online: {data.get('online_switches', 0)}")
print(f" Offline: {data.get('offline_switches', 0)}")
@@ -221,9 +227,9 @@ def cmd_server_start(args):
print(f" Frontend PID: {proc.pid}")
if not backend_only and not frontend_only:
- print(f"\nDashboard: http://localhost:3000")
- print(f"API: http://localhost:8000")
- print(f"Docs: http://localhost:8000/docs")
+ print("\nDashboard: http://localhost:3000")
+ print("API: http://localhost:8000")
+ print("Docs: http://localhost:8000/docs")
def cmd_server_stop(_args):
@@ -243,6 +249,7 @@ def cmd_server_stop(_args):
# ── Main ──
def main():
+ global API_BASE
parser = argparse.ArgumentParser(description="NetCore — Network Switch Manager CLI")
parser.add_argument("--api", help=f"API base URL (default: {API_BASE})")
@@ -261,7 +268,8 @@ def main():
sw_add.add_argument("--vendor", default="aruba_os")
sw_add.add_argument("--ssh-port", type=int, default=22)
sw_add.add_argument("--username")
- sw_add.add_argument("--password")
+ # Note: SSH passwords should be managed through the .env file for security
+ # Use environment variables instead of passing passwords via CLI
sw_del = sw_sub.add_parser("delete")
sw_del.add_argument("id", type=int)
@@ -321,7 +329,6 @@ def main():
parser.print_help()
return
- global API_BASE
if args.api:
API_BASE = args.api
diff --git a/scripts/start.sh b/scripts/start.sh
index dcaba55..7f6f7d7 100755
--- a/scripts/start.sh
+++ b/scripts/start.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# nethermind — Start both backend and frontend services
+# NetCore — Start both backend and frontend services
set -e
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -7,7 +7,7 @@ BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
echo "╔══════════════════════════════════════════╗"
-echo "║ nethermind — Starting Services ║"
+echo "║ NetCore — Starting Services ║"
echo "╚══════════════════════════════════════════╝"
echo ""
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..5260b3a
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,34 @@
+"""Pytest configuration for NetCore.
+
+Isolates the database to a temporary SQLite file *before* `main` is imported, so a
+test run never touches the real `switches.db`.
+"""
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+BACKEND_DIR = REPO_ROOT / "backend"
+
+
+def pytest_configure():
+ """Before collection: make `import main` work and redirect the database."""
+ if str(BACKEND_DIR) not in sys.path:
+ sys.path.insert(0, str(BACKEND_DIR))
+ # settings/database read this at import time, so it must be set first.
+ # Use a temp directory outside the repo so no stray .db is created in-tree.
+ db_dir = tempfile.mkdtemp(prefix="netcore-tests-")
+ os.environ["DATABASE_URL"] = f"sqlite:///{db_dir}/test_switches.db"
+
+
+@pytest.fixture()
+def client():
+ """A TestClient bound to the app, created per test."""
+ from main import app
+
+ with TestClient(app) as c:
+ yield c
diff --git a/tests/mock_switch.py b/tests/mock_switch.py
index 5009394..1ce34b7 100644
--- a/tests/mock_switch.py
+++ b/tests/mock_switch.py
@@ -11,11 +11,10 @@
"""
from __future__ import annotations
+import contextlib
import socket
import sys
import threading
-import time
-
PROMPT_OPER = "switch> "
PROMPT_PRIV = "switch# "
@@ -23,7 +22,6 @@
def _session(sock: socket.socket):
- buf = b""
state = "oper"
hostname = "switch"
saved_hostname = ""
@@ -35,10 +33,8 @@ def prompt():
return f"{hostname}# " if state == "priv" else f"{hostname}> "
def _safe_send(data: bytes):
- try:
+ with contextlib.suppress(Exception):
sock.sendall(data)
- except Exception:
- pass
_safe_send(PROMPT_OPER.encode())
while True:
@@ -67,7 +63,7 @@ def _safe_send(data: bytes):
saved_hostname = m[1]
hostname = saved_hostname or hostname
elif low == "write mem" or low.startswith("write memory"):
- _safe_send(f"Configuration edited by operator saved\r\n".encode())
+ _safe_send(b"Configuration edited by operator saved\r\n")
elif low.startswith("show running-config"):
_safe_send(f'hostname "{saved_hostname}"\r\n'.encode())
elif low == "exit":
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..66a3a38
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,166 @@
+"""Real tests for the NetCore backend.
+
+Covers the API surface end-to-end through TestClient, plus pure-logic unit tests for
+the ArubaOS/ProCurve running-config parser (`services/config_parser.py`).
+"""
+
+
+# ── app metadata ────────────────────────────────────────────────────────────
+
+def test_root_returns_app_metadata(client):
+ r = client.get("/")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["app"] == "NetCore"
+ assert body["version"]
+ assert "features" in body and isinstance(body["features"], list)
+
+
+def test_openapi_lists_routes(client):
+ r = client.get("/openapi.json")
+ assert r.status_code == 200
+ spec = r.json()
+ paths = spec["paths"]
+ # a handful of routes that must exist for the product to be what it claims
+ for expected in ("/api/switches/", "/api/dashboard/stats"):
+ assert expected in paths
+ assert len(paths) > 20
+
+
+# ── dashboard ───────────────────────────────────────────────────────────────
+
+def test_dashboard_stats_shape(client):
+ r = client.get("/api/dashboard/stats")
+ assert r.status_code == 200
+ body = r.json()
+ for key in (
+ "total_switches",
+ "online_switches",
+ "offline_switches",
+ "total_configs",
+ "open_security_findings",
+ "active_workflows",
+ "total_topologies",
+ ):
+ assert key in body, f"missing {key}"
+ assert isinstance(body[key], int)
+
+
+# ── switches CRUD ───────────────────────────────────────────────────────────
+
+def test_switches_list_starts_as_list(client):
+ r = client.get("/api/switches/")
+ assert r.status_code == 200
+ assert isinstance(r.json(), list)
+
+
+def test_create_switch_then_read_back(client):
+ payload = {
+ "hostname": "sw-test-01",
+ "ip_address": "10.20.30.40",
+ "vendor": "cisco_ios",
+ "location": "lab",
+ "tags": "test",
+ }
+ r = client.post("/api/switches/", json=payload)
+ assert r.status_code in (200, 201), r.text
+ created = r.json()
+ assert created["hostname"] == "sw-test-01"
+ assert created["ip_address"] == "10.20.30.40"
+ new_id = created["id"]
+
+ r = client.get(f"/api/switches/{new_id}")
+ assert r.status_code == 200
+ fetched = r.json()
+ assert fetched["id"] == new_id
+ assert fetched["hostname"] == "sw-test-01"
+
+ # it must now be visible in the collection
+ r = client.get("/api/switches/")
+ assert any(s["id"] == new_id for s in r.json())
+
+
+def test_created_switch_counts_toward_dashboard(client):
+ client.post(
+ "/api/switches/",
+ json={"hostname": "sw-count-01", "ip_address": "10.20.30.41"},
+ )
+ stats = client.get("/api/dashboard/stats").json()
+ assert stats["total_switches"] >= 1
+
+
+# ── config parser (pure logic) ──────────────────────────────────────────────
+
+SAMPLE_RUNNING_CONFIG = """hostname "core-sw-01"
+time timezone -300
+ip default-gateway 10.0.0.1
+ip routing
+sntp server priority 1 10.0.0.10
+radius-server host 10.0.0.20 key "s3cret"
+dhcp-snooping authorized-server 10.0.0.30
+dhcp-snooping option 82
+
+vlan 10
+ name "USERS"
+ untagged 1-24
+ tagged 25-28
+ ip address 10.0.10.1 255.255.255.0
+ ip helper-address 10.0.0.5
+
+vlan 20
+ name "VOICE"
+ tagged 25-28
+"""
+
+
+def test_parse_config_extracts_scalar_fields():
+ from services.config_parser import parse_config
+
+ cfg = parse_config(SAMPLE_RUNNING_CONFIG)
+ assert cfg.hostname == "core-sw-01"
+ assert cfg.timezone == -300
+ assert cfg.default_gateway == "10.0.0.1"
+
+
+def test_parse_config_detects_role_and_services():
+ from services.config_parser import parse_config
+
+ cfg = parse_config(SAMPLE_RUNNING_CONFIG)
+ # "ip routing" present -> core
+ assert cfg.role == "core"
+ assert cfg.sntp_servers == ["10.0.0.10"]
+ assert cfg.dhcp_authorized_servers == ["10.0.0.30"]
+ assert cfg.dhcp_option82 is True
+ assert [(r.host, r.key) for r in cfg.radius_servers] == [("10.0.0.20", "s3cret")]
+
+
+def test_parse_config_parses_vlans():
+ from services.config_parser import parse_config
+
+ cfg = parse_config(SAMPLE_RUNNING_CONFIG)
+ by_id = {v.id: v for v in cfg.vlans}
+ assert 10 in by_id and 20 in by_id
+ assert by_id[10].name == "USERS"
+ assert by_id[10].untagged == "1-24"
+ assert by_id[10].tagged == "25-28"
+ assert by_id[10].ip == "10.0.10.1"
+ assert by_id[10].mask == "255.255.255.0"
+ assert by_id[10].helper == "10.0.0.5"
+ assert by_id[20].name == "VOICE"
+
+
+def test_parse_config_access_switch_is_not_core():
+ from services.config_parser import parse_config
+
+ cfg = parse_config('hostname "access-sw-09"\nvlan 1\n untagged 1-48\n')
+ assert cfg.role == "access"
+ assert cfg.hostname == "access-sw-09"
+
+
+def test_parse_config_empty_input_is_safe():
+ from services.config_parser import parse_config
+
+ cfg = parse_config("")
+ assert cfg.hostname == ""
+ assert cfg.vlans == []
+ assert cfg.radius_servers == []