Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2008f9467a | |||
| d9ac9462a8 | |||
| e14a9e87aa | |||
| e5d7260696 | |||
| df3b7a8cb0 | |||
| 426af29180 | |||
| f36bdfe878 | |||
| 300567c88d | |||
| e41dbe579c | |||
| e1d16b86e3 | |||
| 593335648f | |||
| e3f8c27a04 | |||
| ff69856d31 | |||
| 7935b671d3 |
@@ -15,3 +15,10 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
||||
|
||||
# Status checker interval in seconds
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
|
||||
# MCP server — used by the mcp service (port 8001)
|
||||
# MCP_API_KEY: authenticates AI clients (Claude Code, etc.) → MCP server
|
||||
# MCP_SERVICE_KEY: authenticates MCP server → backend (never exposed externally)
|
||||
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
MCP_API_KEY=mcp_sk_changeme
|
||||
MCP_SERVICE_KEY=svc_changeme
|
||||
|
||||
@@ -48,3 +48,4 @@ htmlcov/
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
Ideas.md
|
||||
|
||||
@@ -84,6 +84,20 @@ The backend runs as a systemd service, the frontend is served via nginx on port
|
||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||
> ```
|
||||
|
||||
### Update
|
||||
|
||||
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
||||
|
||||
```bash
|
||||
sudo bash /opt/homelable/scripts/update.sh
|
||||
```
|
||||
|
||||
Or directly from GitHub:
|
||||
|
||||
```bash
|
||||
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -162,6 +176,94 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
|
||||
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||
|
||||
### What the AI can do
|
||||
|
||||
| | Action |
|
||||
|---|---|
|
||||
| **Read** | List all nodes, edges, full canvas, pending devices, scan history |
|
||||
| **Write** | Add / update / delete nodes and edges, trigger a network scan, approve or hide discovered devices |
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Add the keys to your `.env`:**
|
||||
|
||||
```env
|
||||
# Authenticates AI clients (Claude Code, etc.) → MCP server
|
||||
MCP_API_KEY=mcp_sk_changeme
|
||||
|
||||
# Authenticates MCP server → backend (internal Docker network only, never exposed)
|
||||
MCP_SERVICE_KEY=svc_changeme
|
||||
|
||||
# Generate both with:
|
||||
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
No plain-text passwords involved — `AUTH_PASSWORD_HASH` is only used for the web UI login.
|
||||
|
||||
**2. Start the MCP service:**
|
||||
|
||||
```bash
|
||||
docker compose up -d mcp
|
||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||
```
|
||||
|
||||
**3. Configure your AI client:**
|
||||
|
||||
**Claude Code** — run this command in your terminal:
|
||||
```bash
|
||||
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
|
||||
--header "X-API-Key: mcp_sk_yourkey"
|
||||
```
|
||||
|
||||
Or add it manually to `~/.claude.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homelable": {
|
||||
"type": "sse",
|
||||
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "mcp_sk_yourkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homelable": {
|
||||
"type": "sse",
|
||||
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "mcp_sk_yourkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example prompts
|
||||
|
||||
- *"What nodes are currently offline?"*
|
||||
- *"Add a new LXC container named `pihole` at 192.168.1.5, connected to my switch."*
|
||||
- *"Trigger a network scan on 192.168.1.0/24 and show me the pending devices."*
|
||||
- *"Show me the full canvas topology."*
|
||||
|
||||
### Security
|
||||
|
||||
- The MCP server is **not** intended to be exposed to the internet — keep port 8001 firewalled to your LAN.
|
||||
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`.
|
||||
- The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
|
||||
**Backend (Python 3.13):**
|
||||
|
||||
+21
-3
@@ -1,12 +1,30 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
import hmac
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
|
||||
bearer = HTTPBearer()
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
x_mcp_service_key: str | None = Header(default=None),
|
||||
) -> str:
|
||||
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||
if x_mcp_service_key is not None:
|
||||
if not settings.mcp_service_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||
return "__mcp_service__"
|
||||
|
||||
# 2. Standard JWT bearer token
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
|
||||
|
||||
|
||||
@router.websocket("/ws/status")
|
||||
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
await websocket.accept()
|
||||
try:
|
||||
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
_connections.append(websocket)
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
||||
# Status checker
|
||||
status_checker_interval: int = 60
|
||||
|
||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||
# Used by the MCP server to authenticate against the backend without a user password.
|
||||
# Leave empty to disable MCP service key auth.
|
||||
mcp_service_key: str = ""
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class NodeUpdate(BaseModel):
|
||||
notes: str | None = None
|
||||
pos_x: float | None = None
|
||||
pos_y: float | None = None
|
||||
parent_id: str | None = None
|
||||
container_mode: bool | None = None
|
||||
custom_colors: dict[str, Any] | None = None
|
||||
custom_icon: str | None = None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient):
|
||||
res = await client.get("/api/v1/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
|
||||
|
||||
# --- MCP service key auth ---
|
||||
|
||||
@pytest.fixture
|
||||
def with_service_key():
|
||||
from app.core.config import settings
|
||||
settings.mcp_service_key = "test-service-key"
|
||||
yield "test-service-key"
|
||||
settings.mcp_service_key = ""
|
||||
|
||||
|
||||
async def test_service_key_grants_access(client: AsyncClient, with_service_key):
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": with_service_key})
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
async def test_service_key_wrong_value(client: AsyncClient, with_service_key):
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "wrong-key"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
||||
from app.core.config import settings
|
||||
settings.mcp_service_key = ""
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -102,6 +102,16 @@ async def test_update_node_container_mode(client: AsyncClient, headers: dict):
|
||||
assert res.json()["container_mode"] is True
|
||||
|
||||
|
||||
async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||
parent = await client.post("/api/v1/nodes", json={"type": "proxmox", "label": "PVE", "status": "unknown"}, headers=headers)
|
||||
parent_id = parent.json()["id"]
|
||||
child = await client.post("/api/v1/nodes", json={"type": "lxc", "label": "Child", "status": "unknown"}, headers=headers)
|
||||
child_id = child.json()["id"]
|
||||
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": parent_id}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["parent_id"] == parent_id
|
||||
|
||||
|
||||
async def test_create_node_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -22,24 +22,33 @@ def _make_token() -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_websocket_rejected_without_token():
|
||||
"""Connection with no token must be closed before being accepted."""
|
||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
|
||||
pass
|
||||
"""Connection that sends no token field must be closed with 1008."""
|
||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||
ws.send_text(json.dumps({})) # missing token field
|
||||
ws.receive_text() # triggers WebSocketDisconnect from server close
|
||||
|
||||
|
||||
def test_websocket_rejected_with_invalid_token():
|
||||
"""Connection with a garbage token must be closed."""
|
||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"):
|
||||
pass
|
||||
"""Connection that sends a garbage token must be closed."""
|
||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||
ws.send_text(json.dumps({"token": "not-a-valid-jwt"}))
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_rejected_with_malformed_json():
|
||||
"""Connection that sends non-JSON as auth must be closed."""
|
||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||
ws.send_text("not-json")
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_accepted_with_valid_token():
|
||||
"""Connection with a valid JWT must be accepted and kept open."""
|
||||
"""Connection that sends a valid JWT as first message must be accepted."""
|
||||
token = _make_token()
|
||||
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
|
||||
# Connection is open — we can send a ping and it should not raise
|
||||
with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||
ws.send_text(json.dumps({"token": token}))
|
||||
# Connection is open — subsequent messages should not raise
|
||||
ws.send_text("ping")
|
||||
# Server keeps the connection open (no disconnect expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,22 @@ services:
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
|
||||
mcp:
|
||||
build:
|
||||
context: ./mcp
|
||||
dockerfile: Dockerfile.mcp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BACKEND_URL: "http://backend:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
|
||||
+11
-1
@@ -4,6 +4,16 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy WebSocket (must be before /api/ to take priority)
|
||||
location /api/v1/status/ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy API to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
@@ -11,7 +21,7 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy WebSocket
|
||||
# Proxy legacy /ws/ path
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
Generated
+11
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -5474,9 +5474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5826,9 +5826,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -8770,9 +8770,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.24.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||
import { exportToPng } from '@/utils/export'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -238,7 +239,7 @@ export default function App() {
|
||||
|
||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||
snapshotHistory()
|
||||
const id = crypto.randomUUID()
|
||||
const id = generateUUID()
|
||||
const isProxmox = data.type === 'proxmox'
|
||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||
// Children position is relative to parent; place near top-left with padding
|
||||
@@ -260,7 +261,7 @@ export default function App() {
|
||||
|
||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
snapshotHistory()
|
||||
const id = crypto.randomUUID()
|
||||
const id = generateUUID()
|
||||
const newNode: Node<NodeData> = {
|
||||
id,
|
||||
type: 'groupRect',
|
||||
|
||||
@@ -43,13 +43,18 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.label?.trim()) return
|
||||
if (!form.label?.trim()) {
|
||||
setLabelError(true)
|
||||
return
|
||||
}
|
||||
setLabelError(false)
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
@@ -167,11 +172,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||
<Input
|
||||
value={form.label ?? ''}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||
placeholder="My Server"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
required
|
||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||
/>
|
||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||
</div>
|
||||
|
||||
{/* Hostname */}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NodeModal } from '../NodeModal'
|
||||
|
||||
describe('NodeModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form fields when open', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('Add Node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not call onSubmit when label is empty and shows error', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Label is required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data when label is filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears label error when user starts typing', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(screen.getByText('Label is required')).toBeDefined()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
||||
expect(screen.queryByText('Label is required')).toBeNull()
|
||||
})
|
||||
|
||||
it('pre-fills form from initial prop', () => {
|
||||
render(
|
||||
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
|
||||
)
|
||||
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
|
||||
expect(input.value).toBe('Pre-filled')
|
||||
})
|
||||
|
||||
it('shows Save button text when title is Edit Node', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
|
||||
expect(screen.getByText('Save')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -23,12 +23,17 @@ export function useStatusPolling() {
|
||||
if (STANDALONE || !isAuthenticated || !token) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.hostname
|
||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}`
|
||||
const host = window.location.host // includes port when non-standard
|
||||
const url = `${protocol}://${host}/api/v1/status/ws/status`
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
// Send token as first message (not in URL to avoid log/history exposure)
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: StatusMessage = JSON.parse(event.data)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
addEdge,
|
||||
} from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
|
||||
@@ -108,7 +109,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
if (state.clipboard.length === 0) return state
|
||||
const newNodes = state.clipboard.map((n) => ({
|
||||
...n,
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||
selected: false,
|
||||
parentId: undefined,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { generateUUID } from '../uuid'
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
describe('generateUUID', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns a valid v4 UUID using crypto.randomUUID when available', () => {
|
||||
const id = generateUUID()
|
||||
expect(id).toMatch(UUID_REGEX)
|
||||
})
|
||||
|
||||
it('returns a valid v4 UUID using crypto.getRandomValues fallback', () => {
|
||||
vi.spyOn(crypto, 'randomUUID' as never).mockImplementation(undefined as never)
|
||||
const id = generateUUID()
|
||||
expect(id).toMatch(UUID_REGEX)
|
||||
})
|
||||
|
||||
it('generates unique IDs', () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => generateUUID()))
|
||||
expect(ids.size).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Generates a UUID v4.
|
||||
* Falls back to a manual implementation when crypto.randomUUID is unavailable
|
||||
* (HTTP non-secure contexts, older browsers).
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// Fallback: RFC 4122 v4 UUID using crypto.getRandomValues if available
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
return [...bytes]
|
||||
.map((b, i) => ([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
// Last resort: Math.random based (not cryptographically secure)
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
@@ -28,5 +28,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/__tests__/**", "src/test/**"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# MCP Server — copy to .env and fill in values
|
||||
|
||||
# Authenticates AI clients (Claude Code, Claude Desktop, etc.) → MCP server
|
||||
# Generate: python3 -c "import secrets; print('mcp_sk_' + secrets.token_hex(24))"
|
||||
MCP_API_KEY=mcp_sk_changeme
|
||||
|
||||
# Authenticates MCP server → backend (must match MCP_SERVICE_KEY in backend .env)
|
||||
# Generate: python3 -c "import secrets; print('svc_' + secrets.token_hex(24))"
|
||||
MCP_SERVICE_KEY=svc_changeme
|
||||
|
||||
# Backend URL — use http://backend:8000 in Docker, http://localhost:8000 for local dev
|
||||
BACKEND_URL=http://localhost:8000
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,44 @@
|
||||
import hmac
|
||||
import json
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from .config import settings
|
||||
|
||||
_BYPASS_PATHS = {"/health", "/register"}
|
||||
|
||||
|
||||
class ApiKeyMiddleware:
|
||||
"""Pure ASGI middleware — compatible with SSE/streaming responses.
|
||||
|
||||
BaseHTTPMiddleware buffers the full response body and breaks SSE streams.
|
||||
This implementation operates at the ASGI scope level and never touches
|
||||
the response stream.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path: str = scope.get("path", "")
|
||||
|
||||
if path in _BYPASS_PATHS or path.startswith("/.well-known/"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = dict(scope.get("headers", []))
|
||||
key = headers.get(b"x-api-key", b"").decode()
|
||||
expected = settings.mcp_api_key
|
||||
|
||||
if not key or not hmac.compare_digest(key.encode(), expected.encode()):
|
||||
body = json.dumps({"detail": "Invalid or missing X-API-Key"}).encode()
|
||||
await send({"type": "http.response.start", "status": 401,
|
||||
"headers": [(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode())]})
|
||||
await send({"type": "http.response.body", "body": body, "more_body": False})
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
@@ -0,0 +1,40 @@
|
||||
import httpx
|
||||
from .config import settings
|
||||
|
||||
|
||||
class BackendClient:
|
||||
def __init__(self):
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def start(self):
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=settings.backend_url,
|
||||
headers={"X-MCP-Service-Key": settings.mcp_service_key},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def stop(self):
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs) -> dict:
|
||||
resp = await self._client.request(method, path, **kwargs)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
async def get(self, path: str) -> dict | list:
|
||||
return await self.request("GET", path)
|
||||
|
||||
async def post(self, path: str, body: dict) -> dict:
|
||||
return await self.request("POST", path, json=body)
|
||||
|
||||
async def patch(self, path: str, body: dict) -> dict:
|
||||
return await self.request("PATCH", path, json=body)
|
||||
|
||||
async def delete(self, path: str) -> dict:
|
||||
return await self.request("DELETE", path)
|
||||
|
||||
|
||||
backend = BackendClient()
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
mcp_api_key: str = "mcp_sk_changeme" # AI client → MCP server
|
||||
mcp_service_key: str = "svc_changeme" # MCP server → backend
|
||||
backend_url: str = "http://backend:8000"
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,42 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from mcp.server import Server
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
|
||||
from .auth import ApiKeyMiddleware
|
||||
from .backend_client import backend
|
||||
from .resources import register_resources
|
||||
from .tools import register_tools
|
||||
|
||||
|
||||
mcp_server = Server("homelable")
|
||||
register_resources(mcp_server)
|
||||
register_tools(mcp_server)
|
||||
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
app=mcp_server,
|
||||
json_response=False,
|
||||
stateless=True,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await backend.start()
|
||||
async with session_manager.run():
|
||||
yield
|
||||
await backend.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
|
||||
app.add_middleware(ApiKeyMiddleware)
|
||||
|
||||
|
||||
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
||||
async def mcp_endpoint(request: Request):
|
||||
await session_manager.handle_request(request.scope, request.receive, request._send)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from mcp.server import Server
|
||||
from mcp.types import Resource, TextContent
|
||||
from .backend_client import backend
|
||||
|
||||
RESOURCE_LIST = [
|
||||
Resource(uri="homelable://canvas", name="Canvas", description="Full canvas state (nodes + edges + viewport)", mimeType="application/json"),
|
||||
Resource(uri="homelable://nodes", name="Nodes", description="All nodes in the homelab", mimeType="application/json"),
|
||||
Resource(uri="homelable://edges", name="Edges", description="All network edges/links", mimeType="application/json"),
|
||||
Resource(uri="homelable://scan/pending", name="Pending devices", description="Discovered devices awaiting approval", mimeType="application/json"),
|
||||
Resource(uri="homelable://scan/runs", name="Scan history", description="Recent scan run history", mimeType="application/json"),
|
||||
]
|
||||
|
||||
ROUTES = {
|
||||
"homelable://canvas": "/api/v1/canvas",
|
||||
"homelable://nodes": "/api/v1/nodes",
|
||||
"homelable://edges": "/api/v1/edges",
|
||||
"homelable://scan/pending": "/api/v1/scan/pending",
|
||||
"homelable://scan/runs": "/api/v1/scan/runs",
|
||||
}
|
||||
|
||||
|
||||
async def read_resource(uri: str) -> list[TextContent]:
|
||||
if uri.startswith("homelable://nodes/") and uri != "homelable://nodes/":
|
||||
node_id = uri.split("/")[-1]
|
||||
data = await backend.get(f"/api/v1/nodes/{node_id}")
|
||||
return [TextContent(type="text", text=json.dumps(data, indent=2))]
|
||||
|
||||
if uri not in ROUTES:
|
||||
raise ValueError(f"Unknown resource URI: {uri}")
|
||||
|
||||
data = await backend.get(ROUTES[uri])
|
||||
return [TextContent(type="text", text=json.dumps(data, indent=2))]
|
||||
|
||||
|
||||
def register_resources(server: Server):
|
||||
@server.list_resources()
|
||||
async def _list():
|
||||
return RESOURCE_LIST
|
||||
|
||||
@server.read_resource()
|
||||
async def _read(uri: str):
|
||||
return await read_resource(uri)
|
||||
@@ -0,0 +1,154 @@
|
||||
import json
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
from .backend_client import backend
|
||||
|
||||
|
||||
def register_tools(server: Server):
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["type", "label"],
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
|
||||
"label": {"type": "string"},
|
||||
"ip": {"type": "string"},
|
||||
"hostname": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
|
||||
},
|
||||
}),
|
||||
Tool(name="update_node", description="Update an existing node", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"ip": {"type": "string"},
|
||||
"hostname": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
|
||||
},
|
||||
}),
|
||||
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["source", "target"],
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
}),
|
||||
Tool(name="delete_edge", description="Delete a network link", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
|
||||
},
|
||||
}),
|
||||
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
}),
|
||||
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
result = await _dispatch(name, arguments)
|
||||
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
||||
|
||||
|
||||
def _slim_canvas(raw: dict) -> dict:
|
||||
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
|
||||
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
|
||||
EDGE_KEEP = {"id", "source", "target", "type", "label"}
|
||||
|
||||
def slim_node(n: dict) -> dict:
|
||||
data = n.get("data", {})
|
||||
out = {k: v for k, v in data.items() if k in NODE_KEEP and v not in (None, "", [])}
|
||||
out["id"] = n.get("id")
|
||||
out["node_type"] = n.get("type")
|
||||
return out
|
||||
|
||||
def slim_edge(e: dict) -> dict:
|
||||
return {k: v for k, v in e.items() if k in EDGE_KEEP and v not in (None, "")}
|
||||
|
||||
return {
|
||||
"nodes": [slim_node(n) for n in raw.get("nodes", [])],
|
||||
"edges": [slim_edge(e) for e in raw.get("edges", [])],
|
||||
}
|
||||
|
||||
|
||||
async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "create_node":
|
||||
return await backend.post("/api/v1/nodes", args)
|
||||
|
||||
if name == "update_node":
|
||||
node_id = args.pop("id")
|
||||
return await backend.patch(f"/api/v1/nodes/{node_id}", args)
|
||||
|
||||
if name == "delete_node":
|
||||
return await backend.delete(f"/api/v1/nodes/{args['id']}")
|
||||
|
||||
if name == "create_edge":
|
||||
return await backend.post("/api/v1/edges", args)
|
||||
|
||||
if name == "delete_edge":
|
||||
return await backend.delete(f"/api/v1/edges/{args['id']}")
|
||||
|
||||
if name == "trigger_scan":
|
||||
body = {"ranges": args["ranges"]} if "ranges" in args else {}
|
||||
return await backend.post("/api/v1/scan/trigger", body)
|
||||
|
||||
if name == "approve_device":
|
||||
device_id = args.pop("id")
|
||||
return await backend.post(f"/api/v1/scan/pending/{device_id}/approve", args)
|
||||
|
||||
if name == "hide_device":
|
||||
return await backend.post(f"/api/v1/scan/pending/{args['id']}/hide", {})
|
||||
|
||||
if name == "get_canvas":
|
||||
raw = await backend.get("/api/v1/canvas")
|
||||
return _slim_canvas(raw)
|
||||
|
||||
if name == "list_nodes":
|
||||
return await backend.get("/api/v1/nodes")
|
||||
|
||||
if name == "list_pending_devices":
|
||||
return await backend.get("/api/v1/scan/pending")
|
||||
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,5 @@
|
||||
mcp[cli]>=1.0
|
||||
httpx>=0.27
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.0
|
||||
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
os.environ.setdefault("MCP_API_KEY", "test_key")
|
||||
os.environ.setdefault("BACKEND_URL", "http://testbackend")
|
||||
os.environ.setdefault("AUTH_USERNAME", "admin")
|
||||
os.environ.setdefault("AUTH_PASSWORD", "admin")
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key():
|
||||
return "test_key"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(api_key):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_backend():
|
||||
with patch("app.resources.backend") as mock_res, \
|
||||
patch("app.tools.backend") as mock_tools:
|
||||
mock_res.get = AsyncMock()
|
||||
mock_tools.post = AsyncMock()
|
||||
mock_tools.patch = AsyncMock()
|
||||
mock_tools.delete = AsyncMock()
|
||||
yield {"resources": mock_res, "tools": mock_tools}
|
||||
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_no_key(client):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_missing_api_key(client):
|
||||
resp = await client.get("/mcp")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wrong_api_key(client):
|
||||
resp = await client.get("/mcp", headers={"X-API-Key": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_valid_api_key_passes(client, api_key):
|
||||
# Auth passes — mock handle_request so we don't need a live MCP session
|
||||
with patch("app.main.session_manager.handle_request", new_callable=AsyncMock):
|
||||
resp = await client.get("/mcp", headers={"X-API-Key": api_key})
|
||||
assert resp.status_code != 401
|
||||
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.resources import read_resource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_backend():
|
||||
with patch("app.resources.backend") as m:
|
||||
m.get = AsyncMock(return_value={"data": "ok"})
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_canvas(mock_backend):
|
||||
result = await read_resource("homelable://canvas")
|
||||
mock_backend.get.assert_called_once_with("/api/v1/canvas")
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_nodes(mock_backend):
|
||||
await read_resource("homelable://nodes")
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_edges(mock_backend):
|
||||
await read_resource("homelable://edges")
|
||||
mock_backend.get.assert_called_once_with("/api/v1/edges")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_single_node(mock_backend):
|
||||
await read_resource("homelable://nodes/abc123")
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes/abc123")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_scan_pending(mock_backend):
|
||||
await read_resource("homelable://scan/pending")
|
||||
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_unknown_uri(mock_backend):
|
||||
with pytest.raises(ValueError, match="Unknown resource URI"):
|
||||
await read_resource("homelable://unknown")
|
||||
@@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.tools import _dispatch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_backend():
|
||||
with patch("app.tools.backend") as m:
|
||||
m.post = AsyncMock(return_value={"id": "1"})
|
||||
m.patch = AsyncMock(return_value={"id": "1"})
|
||||
m.delete = AsyncMock(return_value={})
|
||||
m.get = AsyncMock(return_value=[])
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_node(mock_backend):
|
||||
result = await _dispatch("create_node", {"type": "server", "label": "Proxmox"})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/nodes", {"type": "server", "label": "Proxmox"})
|
||||
assert result == {"id": "1"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_node(mock_backend):
|
||||
await _dispatch("update_node", {"id": "42", "label": "New name"})
|
||||
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"label": "New name"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_node_parent_id(mock_backend):
|
||||
await _dispatch("update_node", {"id": "42", "parent_id": "proxmox-1"})
|
||||
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_node(mock_backend):
|
||||
await _dispatch("delete_node", {"id": "42"})
|
||||
mock_backend.delete.assert_called_once_with("/api/v1/nodes/42")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_edge(mock_backend):
|
||||
await _dispatch("create_edge", {"source": "1", "target": "2", "type": "ethernet"})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/edges", {"source": "1", "target": "2", "type": "ethernet"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_edge(mock_backend):
|
||||
await _dispatch("delete_edge", {"id": "99"})
|
||||
mock_backend.delete.assert_called_once_with("/api/v1/edges/99")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_scan_no_ranges(mock_backend):
|
||||
await _dispatch("trigger_scan", {})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/scan/trigger", {})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trigger_scan_with_ranges(mock_backend):
|
||||
await _dispatch("trigger_scan", {"ranges": ["192.168.1.0/24"]})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/scan/trigger", {"ranges": ["192.168.1.0/24"]})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_approve_device(mock_backend):
|
||||
await _dispatch("approve_device", {"id": "5", "type": "server", "label": "MyServer"})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/approve", {"type": "server", "label": "MyServer"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_hide_device(mock_backend):
|
||||
await _dispatch("hide_device", {"id": "5"})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/hide", {})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_canvas(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value={
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"type": "router",
|
||||
"position": {"x": 100, "y": 200},
|
||||
"width": 160,
|
||||
"height": 80,
|
||||
"data": {"label": "Freebox", "ip": "192.168.1.1", "status": "online"},
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "n1", "target": "n2", "type": "ethernet", "animated": True, "style": {"stroke": "#fff"}},
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
})
|
||||
result = await _dispatch("get_canvas", {})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/canvas")
|
||||
# Layout/style fields stripped, only semantic data kept
|
||||
assert result["nodes"] == [{"id": "n1", "node_type": "router", "label": "Freebox", "ip": "192.168.1.1", "status": "online"}]
|
||||
assert result["edges"] == [{"id": "e1", "source": "n1", "target": "n2", "type": "ethernet"}]
|
||||
assert "viewport" not in result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_nodes(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
|
||||
result = await _dispatch("list_nodes", {})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes")
|
||||
assert result == [{"id": "1", "label": "Freebox"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_pending_devices(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "p1", "ip": "192.168.1.50"}])
|
||||
result = await _dispatch("list_pending_devices", {})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
|
||||
assert result == [{"id": "p1", "ip": "192.168.1.50"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unknown_tool():
|
||||
with pytest.raises(ValueError, match="Unknown tool"):
|
||||
await _dispatch("nonexistent", {})
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Homelable — update to latest version
|
||||
# Run inside the LXC / any Linux host where lxc-install.sh was used:
|
||||
# bash /opt/homelable/scripts/update.sh
|
||||
# Or pull-and-run directly:
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR=/opt/homelable
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
||||
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
|
||||
|
||||
# ── Pull latest code ──────────────────────────────────────────────────────────
|
||||
info "Pulling latest code..."
|
||||
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||
git -C "$INSTALL_DIR" pull --quiet
|
||||
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||
|
||||
if [[ "$BEFORE" == "$AFTER" ]]; then
|
||||
info "Already up to date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Changes since last update:"
|
||||
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
|
||||
echo ""
|
||||
|
||||
# ── Stop backend ─────────────────────────────────────────────────────────────
|
||||
info "Stopping backend service..."
|
||||
systemctl stop homelable-backend
|
||||
|
||||
# ── Backend deps ─────────────────────────────────────────────────────────────
|
||||
info "Updating Python dependencies..."
|
||||
cd "$INSTALL_DIR/backend"
|
||||
.venv/bin/pip install --quiet -r requirements.txt
|
||||
|
||||
# ── Frontend build ────────────────────────────────────────────────────────────
|
||||
info "Rebuilding frontend..."
|
||||
cd "$INSTALL_DIR/frontend"
|
||||
npm ci --silent
|
||||
npm run build
|
||||
|
||||
# ── nginx config ─────────────────────────────────────────────────────────────
|
||||
info "Updating nginx config..."
|
||||
sed \
|
||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
||||
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
# ── Restart backend ───────────────────────────────────────────────────────────
|
||||
info "Starting backend service..."
|
||||
systemctl start homelable-backend
|
||||
|
||||
echo ""
|
||||
echo -e " ${GREEN}Homelable updated successfully!${NC}"
|
||||
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user