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