feat: Phase 1 scaffold — frontend canvas + backend skeleton
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
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+50
@@ -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/
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <password>")
|
||||
sys.exit(1)
|
||||
|
||||
password = sys.argv[1]
|
||||
print(pwd_context.hash(password))
|
||||
Submodule
+1
Submodule frontend added at a19a9632e2
Executable
+41
@@ -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!"
|
||||
Executable
+49
@@ -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!"
|
||||
Reference in New Issue
Block a user