From 4310a5cc2d720edee0b1097480842c84f132af54 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 6 Mar 2026 23:14:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20scaffold=20=E2=80=94=20fron?= =?UTF-8?q?tend=20canvas=20+=20backend=20skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - Vite + React 18 + TypeScript + Tailwind v4 + Shadcn/ui - React Flow v12 canvas with all 11 node types and 5 edge types - Dark theme with project design system (cyan, green, orange, purple accents) - Collapsible sidebar, toolbar, detail panel - Zustand store for canvas state - Demo data with 10 nodes and 10 edges Backend: - FastAPI + SQLAlchemy async + SQLite (Python 3.13) - DB models: Node, Edge, CanvasState, PendingDevice, ScanRun - REST API routes: auth, nodes, edges, canvas, scan, status (WebSocket) - JWT auth + bcrypt via config.yml - Pydantic v2 schemas Infra: - GitHub Actions quality + security workflows - .gitignore, .env.example, verify-tooling.sh, security-check.sh --- .env.example | 5 ++ .github/workflows/quality.yml | 40 ++++++++++++++ .github/workflows/security.yml | 36 ++++++++++++ .gitignore | 50 +++++++++++++++++ backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/deps.py | 13 +++++ backend/app/api/routes/__init__.py | 0 backend/app/api/routes/auth.py | 34 ++++++++++++ backend/app/api/routes/canvas.py | 42 ++++++++++++++ backend/app/api/routes/edges.py | 46 +++++++++++++++ backend/app/api/routes/nodes.py | 54 ++++++++++++++++++ backend/app/api/routes/scan.py | 44 +++++++++++++++ backend/app/api/routes/status.py | 33 +++++++++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 17 ++++++ backend/app/core/security.py | 30 ++++++++++ backend/app/db/__init__.py | 0 backend/app/db/database.py | 25 +++++++++ backend/app/db/models.py | 89 ++++++++++++++++++++++++++++++ backend/app/main.py | 41 ++++++++++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/canvas.py | 21 +++++++ backend/app/schemas/edges.py | 30 ++++++++++ backend/app/schemas/nodes.py | 51 +++++++++++++++++ backend/app/schemas/scan.py | 30 ++++++++++ backend/config.yml | 13 +++++ backend/requirements.txt | 23 ++++++++ backend/scripts/hash_password.py | 12 ++++ frontend | 1 + scripts/security-check.sh | 41 ++++++++++++++ scripts/verify-tooling.sh | 49 ++++++++++++++++ 32 files changed, 870 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/quality.yml create mode 100644 .github/workflows/security.yml create mode 100644 .gitignore create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/routes/__init__.py create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/canvas.py create mode 100644 backend/app/api/routes/edges.py create mode 100644 backend/app/api/routes/nodes.py create mode 100644 backend/app/api/routes/scan.py create mode 100644 backend/app/api/routes/status.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/database.py create mode 100644 backend/app/db/models.py create mode 100644 backend/app/main.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/canvas.py create mode 100644 backend/app/schemas/edges.py create mode 100644 backend/app/schemas/nodes.py create mode 100644 backend/app/schemas/scan.py create mode 100644 backend/config.yml create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/hash_password.py create mode 160000 frontend create mode 100755 scripts/security-check.sh create mode 100755 scripts/verify-tooling.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9dc76fd --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Backend - server-side only (NEVER commit .env) +SECRET_KEY=change_me_in_production +SQLITE_PATH=./data/homelab.db +CONFIG_PATH=./config.yml +CORS_ORIGINS=http://localhost:5173 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..f173891 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,40 @@ +name: Quality + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test -- --run + + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install -r requirements.txt + - run: ruff check . + - run: mypy app/ + - run: pytest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..2198824 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,36 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 9 * * 1' # Weekly on Monday + +jobs: + secrets-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect secrets + uses: trufflesecurity/trufflehog@main + with: + path: ./ + + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: NPM Audit + run: cd frontend && npm audit --audit-level=high + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Safety check + run: pip install safety && safety check -r backend/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3856de0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Claude / project meta — never commit +CLAUDE.md +FEATURES.md +.claude/ +_project_specs/ + +# Environment files - NEVER commit +.env +.env.* +!.env.example + +# Secrets +*.pem +*.key +*.p12 +credentials.json +secrets.json +service-account*.json + +# Dependencies +node_modules/ +__pycache__/ +*.pyc +.venv/ +venv/ + +# Build outputs +dist/ +build/ + +# IDE +.idea/ +.vscode/settings.json +.DS_Store + +# Python +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage + +# SQLite +*.db +*.db-shm +*.db-wal + +# Docker +.docker/ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..d398576 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,13 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.security import decode_token + +bearer = HTTPBearer() + + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str: + username = decode_token(credentials.credentials) + if not username: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + return username diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..892fb89 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,34 @@ +import yaml +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from app.core.config import settings +from app.core.security import create_access_token, verify_password + +router = APIRouter() + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +def _load_credentials() -> tuple[str, str]: + with open(settings.config_path) as f: + cfg = yaml.safe_load(f) + auth = cfg.get("auth", {}) + return auth.get("username", "admin"), auth.get("password_hash", "") + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest): + username, password_hash = _load_credentials() + if body.username != username or not verify_password(body.password, password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + token = create_access_token(body.username) + return TokenResponse(access_token=token) diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py new file mode 100644 index 0000000..064eb94 --- /dev/null +++ b/backend/app/api/routes/canvas.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.database import get_db +from app.db.models import CanvasState, Edge, Node +from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse + +router = APIRouter() + + +@router.get("/", response_model=CanvasStateResponse) +async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + nodes = (await db.execute(select(Node))).scalars().all() + edges = (await db.execute(select(Edge))).scalars().all() + state = await db.get(CanvasState, 1) + viewport = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} + return CanvasStateResponse(nodes=list(nodes), edges=list(edges), viewport=viewport) + + +@router.post("/save") +async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + # Update node positions from canvas + for node_pos in body.node_positions: + node = await db.get(Node, node_pos.id) + if node: + node.pos_x = node_pos.x + node.pos_y = node_pos.y + + # Upsert viewport + state = await db.get(CanvasState, 1) + if state: + state.viewport = body.viewport + state.saved_at = datetime.now(timezone.utc) + else: + db.add(CanvasState(id=1, viewport=body.viewport)) + + await db.commit() + return {"saved": True} diff --git a/backend/app/api/routes/edges.py b/backend/app/api/routes/edges.py new file mode 100644 index 0000000..c94d5d8 --- /dev/null +++ b/backend/app/api/routes/edges.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.database import get_db +from app.db.models import Edge +from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate + +router = APIRouter() + + +@router.get("/", response_model=list[EdgeResponse]) +async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + result = await db.execute(select(Edge)) + return result.scalars().all() + + +@router.post("/", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED) +async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + edge = Edge(**body.model_dump()) + db.add(edge) + await db.commit() + await db.refresh(edge) + return edge + + +@router.delete("/{edge_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + edge = await db.get(Edge, edge_id) + if not edge: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found") + await db.delete(edge) + await db.commit() + + +@router.patch("/{edge_id}", response_model=EdgeResponse) +async def update_edge(edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + edge = await db.get(Edge, edge_id) + if not edge: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(edge, field, value) + await db.commit() + await db.refresh(edge) + return edge diff --git a/backend/app/api/routes/nodes.py b/backend/app/api/routes/nodes.py new file mode 100644 index 0000000..f8a5f50 --- /dev/null +++ b/backend/app/api/routes/nodes.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.database import get_db +from app.db.models import Node +from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate + +router = APIRouter() + + +@router.get("/", response_model=list[NodeResponse]) +async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + result = await db.execute(select(Node)) + return result.scalars().all() + + +@router.post("/", response_model=NodeResponse, status_code=status.HTTP_201_CREATED) +async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + node = Node(**body.model_dump()) + db.add(node) + await db.commit() + await db.refresh(node) + return node + + +@router.get("/{node_id}", response_model=NodeResponse) +async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + node = await db.get(Node, node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + return node + + +@router.patch("/{node_id}", response_model=NodeResponse) +async def update_node(node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + node = await db.get(Node, node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(node, field, value) + await db.commit() + await db.refresh(node) + return node + + +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + node = await db.get(Node, node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + await db.delete(node) + await db.commit() diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py new file mode 100644 index 0000000..0df8030 --- /dev/null +++ b/backend/app/api/routes/scan.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.database import get_db +from app.db.models import PendingDevice, ScanRun +from app.schemas.scan import PendingDeviceResponse, ScanRunResponse + +router = APIRouter() + + +@router.post("/trigger", response_model=ScanRunResponse) +async def trigger_scan(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + run = ScanRun(status="running", ranges=[]) + db.add(run) + await db.commit() + await db.refresh(run) + # TODO: launch scanner in background thread + return run + + +@router.get("/pending", response_model=list[PendingDeviceResponse]) +async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) + return result.scalars().all() + + +@router.post("/pending/{device_id}/approve") +async def approve_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + device = await db.get(PendingDevice, device_id) + if device: + device.status = "approved" + await db.commit() + return {"approved": True} + + +@router.post("/pending/{device_id}/hide") +async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + device = await db.get(PendingDevice, device_id) + if device: + device.status = "hidden" + await db.commit() + return {"hidden": True} diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py new file mode 100644 index 0000000..214c380 --- /dev/null +++ b/backend/app/api/routes/status.py @@ -0,0 +1,33 @@ +import json + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter() + +# Active WebSocket connections +_connections: list[WebSocket] = [] + + +@router.websocket("/ws/status") +async def ws_status(websocket: WebSocket): + await websocket.accept() + _connections.append(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + _connections.remove(websocket) + + +async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None): + payload = json.dumps({ + "node_id": node_id, + "status": status, + "checked_at": checked_at, + "response_time_ms": response_time_ms, + }) + for conn in list(_connections): + try: + await conn.send_text(payload) + except Exception: + _connections.remove(conn) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..96f40bc --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + secret_key: str = "change_me_in_production" + sqlite_path: str = "./data/homelab.db" + config_path: str = "./config.yml" + cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] + + # JWT + algorithm: str = "HS256" + access_token_expire_minutes: int = 1440 # 24h + + +settings = Settings() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..4166451 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = {"sub": subject, "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def decode_token(token: str) -> str | None: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + return payload.get("sub") + except JWTError: + return None diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 0000000..323fbf9 --- /dev/null +++ b/backend/app/db/database.py @@ -0,0 +1,25 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +engine = create_async_engine( + f"sqlite+aiosqlite:///{settings.sqlite_path}", + echo=False, +) + +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def init_db() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..745a224 --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,89 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +class Node(Base): + __tablename__ = "nodes" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) + type: Mapped[str] = mapped_column(String, nullable=False) + label: Mapped[str] = mapped_column(String, nullable=False) + hostname: Mapped[str | None] = mapped_column(String) + ip: Mapped[str | None] = mapped_column(String) + mac: Mapped[str | None] = mapped_column(String) + os: Mapped[str | None] = mapped_column(String) + status: Mapped[str] = mapped_column(String, default="unknown") + check_method: Mapped[str | None] = mapped_column(String) + check_target: Mapped[str | None] = mapped_column(String) + services: Mapped[list] = mapped_column(JSON, default=list) + notes: Mapped[str | None] = mapped_column(Text) + pos_x: Mapped[float] = mapped_column(Float, default=0) + pos_y: Mapped[float] = mapped_column(Float, default=0) + parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) + last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + response_time_ms: Mapped[int | None] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + children: Mapped[list["Node"]] = relationship("Node", back_populates="parent") + parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id]) + + +class Edge(Base): + __tablename__ = "edges" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) + source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) + target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) + type: Mapped[str] = mapped_column(String, default="ethernet") + label: Mapped[str | None] = mapped_column(String) + vlan_id: Mapped[int | None] = mapped_column(Integer) + speed: Mapped[str | None] = mapped_column(String) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class CanvasState(Base): + __tablename__ = "canvas_state" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + viewport: Mapped[dict] = mapped_column(JSON, default=dict) + saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class PendingDevice(Base): + __tablename__ = "pending_devices" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) + ip: Mapped[str] = mapped_column(String, nullable=False) + mac: Mapped[str | None] = mapped_column(String) + hostname: Mapped[str | None] = mapped_column(String) + os: Mapped[str | None] = mapped_column(String) + services: Mapped[list] = mapped_column(JSON, default=list) + suggested_type: Mapped[str | None] = mapped_column(String) + status: Mapped[str] = mapped_column(String, default="pending") + discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class ScanRun(Base): + __tablename__ = "scan_runs" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) + status: Mapped[str] = mapped_column(String, default="running") + ranges: Mapped[list] = mapped_column(JSON, default=list) + devices_found: Mapped[int] = mapped_column(Integer, default=0) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + error: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..01f8e8c --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,41 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.db.database import init_db +from app.api.routes import auth, nodes, edges, canvas, scan, status + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI( + title="Homelable API", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"]) +app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"]) +app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"]) +app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"]) +app.include_router(status.router, prefix="/api/v1/status", tags=["status"]) + + +@app.get("/api/v1/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py new file mode 100644 index 0000000..7062289 --- /dev/null +++ b/backend/app/schemas/canvas.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + +from app.schemas.edges import EdgeResponse +from app.schemas.nodes import NodeResponse + + +class NodePosition(BaseModel): + id: str + x: float + y: float + + +class CanvasSaveRequest(BaseModel): + node_positions: list[NodePosition] = [] + viewport: dict = {} + + +class CanvasStateResponse(BaseModel): + nodes: list[NodeResponse] + edges: list[EdgeResponse] + viewport: dict diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py new file mode 100644 index 0000000..a8380f1 --- /dev/null +++ b/backend/app/schemas/edges.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class EdgeBase(BaseModel): + source: str + target: str + type: str = "ethernet" + label: str | None = None + vlan_id: int | None = None + speed: str | None = None + + +class EdgeCreate(EdgeBase): + pass + + +class EdgeUpdate(BaseModel): + type: str | None = None + label: str | None = None + vlan_id: int | None = None + speed: str | None = None + + +class EdgeResponse(EdgeBase): + id: str + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py new file mode 100644 index 0000000..4f6a6b7 --- /dev/null +++ b/backend/app/schemas/nodes.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + + +class NodeBase(BaseModel): + type: str + label: str + hostname: str | None = None + ip: str | None = None + mac: str | None = None + os: str | None = None + status: str = "unknown" + check_method: str | None = None + check_target: str | None = None + services: list[Any] = [] + notes: str | None = None + pos_x: float = 0 + pos_y: float = 0 + parent_id: str | None = None + + +class NodeCreate(NodeBase): + pass + + +class NodeUpdate(BaseModel): + type: str | None = None + label: str | None = None + hostname: str | None = None + ip: str | None = None + mac: str | None = None + os: str | None = None + status: str | None = None + check_method: str | None = None + check_target: str | None = None + services: list[Any] | None = None + notes: str | None = None + pos_x: float | None = None + pos_y: float | None = None + + +class NodeResponse(NodeBase): + id: str + last_seen: datetime | None = None + response_time_ms: int | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py new file mode 100644 index 0000000..e16d580 --- /dev/null +++ b/backend/app/schemas/scan.py @@ -0,0 +1,30 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + + +class PendingDeviceResponse(BaseModel): + id: str + ip: str + mac: str | None + hostname: str | None + os: str | None + services: list[Any] + suggested_type: str | None + status: str + discovered_at: datetime + + model_config = {"from_attributes": True} + + +class ScanRunResponse(BaseModel): + id: str + status: str + ranges: list[str] + devices_found: int + started_at: datetime + finished_at: datetime | None + error: str | None + + model_config = {"from_attributes": True} diff --git a/backend/config.yml b/backend/config.yml new file mode 100644 index 0000000..6a8153e --- /dev/null +++ b/backend/config.yml @@ -0,0 +1,13 @@ +auth: + username: admin + # Default password: "admin" — change this before deploying! + # Generate a new hash: python scripts/hash_password.py yourpassword + password_hash: "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW" + +scanner: + ranges: + - "192.168.1.0/24" + interval: null # null = manual only, "hourly" or "daily" for scheduled + +status_checker: + interval_seconds: 60 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3ed7c6e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,23 @@ +# Requires Python 3.13+ (3.14 not yet supported by pydantic-core) +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy[asyncio]==2.0.35 +aiosqlite==0.20.0 +alembic==1.13.3 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.12 +apscheduler==3.10.4 +python-nmap==0.7.1 +pyyaml==6.0.2 +websockets==13.1 +httpx==0.27.2 + +# Dev +ruff==0.6.9 +mypy==1.11.2 +pytest==8.3.3 +pytest-asyncio==0.24.0 +pytest-cov==5.0.0 diff --git a/backend/scripts/hash_password.py b/backend/scripts/hash_password.py new file mode 100644 index 0000000..98b0834 --- /dev/null +++ b/backend/scripts/hash_password.py @@ -0,0 +1,12 @@ +"""Generate a bcrypt password hash for config.yml.""" +import sys +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +if len(sys.argv) < 2: + print("Usage: python scripts/hash_password.py ") + sys.exit(1) + +password = sys.argv[1] +print(pwd_context.hash(password)) diff --git a/frontend b/frontend new file mode 160000 index 0000000..a19a963 --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit a19a9632e20594833dc86149ce9998a4ae044c7f diff --git a/scripts/security-check.sh b/scripts/security-check.sh new file mode 100755 index 0000000..8cb254f --- /dev/null +++ b/scripts/security-check.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +echo "Running security checks..." + +# Check .env is not staged +if git diff --cached --name-only | grep -E '^\.env$|^\.env\.' | grep -v '\.example$'; then + echo "ERROR: .env file is staged for commit!" + exit 1 +fi + +# Check for common secret patterns in staged files +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) +if [ -n "$STAGED_FILES" ]; then + if echo "$STAGED_FILES" | xargs grep -l -E '(password|secret|api_key|apikey|token)\s*[:=]\s*["\047][^"\047]{8,}["\047]' 2>/dev/null; then + echo "WARNING: Possible secrets found in staged files - please verify" + fi +fi + +# Check for SQLite DB being committed +if git diff --cached --name-only | grep -E '\.db$'; then + echo "ERROR: SQLite database file is staged for commit!" + exit 1 +fi + +# Dependency audit (frontend) +if [ -f "frontend/package.json" ]; then + echo "Checking npm dependencies..." + cd frontend && npm audit --audit-level=high 2>/dev/null || echo "Warning: npm audit found issues" + cd .. +fi + +# Python dependency check +if [ -f "backend/requirements.txt" ]; then + if command -v safety &> /dev/null; then + echo "Checking Python dependencies..." + safety check -r backend/requirements.txt 2>/dev/null || echo "Warning: safety found issues" + fi +fi + +echo "Security checks complete!" diff --git a/scripts/verify-tooling.sh b/scripts/verify-tooling.sh new file mode 100755 index 0000000..6e9cff7 --- /dev/null +++ b/scripts/verify-tooling.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -e + +echo "Verifying project tooling..." + +# GitHub CLI +if command -v gh &> /dev/null; then + if gh auth status &> /dev/null; then + echo "✓ GitHub CLI authenticated" + else + echo "✗ GitHub CLI not authenticated. Run: gh auth login" + exit 1 + fi +else + echo "⚠ GitHub CLI not installed. Run: brew install gh" +fi + +# Python +if command -v python3 &> /dev/null; then + echo "✓ Python $(python3 --version)" +else + echo "✗ Python not installed" + exit 1 +fi + +# Node +if command -v node &> /dev/null; then + echo "✓ Node $(node --version)" +else + echo "✗ Node not installed" + exit 1 +fi + +# nmap (required for scanner service) +if command -v nmap &> /dev/null; then + echo "✓ nmap $(nmap --version | head -1)" +else + echo "⚠ nmap not installed. Run: brew install nmap (required for scanner)" +fi + +# Docker +if command -v docker &> /dev/null; then + echo "✓ Docker $(docker --version)" +else + echo "⚠ Docker not installed. Required for production deployment." +fi + +echo "" +echo "Tooling verification complete!"