Compare commits

..

6 Commits

Author SHA1 Message Date
Pouzor 2008f9467a fix: exclude test files from tsconfig.app.json to fix production build 2026-03-18 00:56:33 +01:00
Pouzor d9ac9462a8 feat: add update script for LXC installs and document update procedure 2026-03-18 00:52:43 +01:00
Pouzor e14a9e87aa fix: stop exposing JWT in WebSocket URL query param
Token was visible in server logs, browser history, and proxy access logs.
Backend now accepts the connection first, then validates a JSON auth
message {"token": "<jwt>"} sent by the client on open before adding
the socket to the active connections pool.
2026-03-18 00:49:03 +01:00
Pouzor e5d7260696 fix: resolve WebSocket failure and crypto.randomUUID crash on HTTP/LXC
- Replace crypto.randomUUID() with a polyfill (generateUUID) that falls
  back to crypto.getRandomValues or Math.random — fixes crash on HTTP
  non-secure contexts where randomUUID is unavailable
- Fix WebSocket URL hardcoding port 8000 — use window.location.host so
  connections go through Nginx proxy in Docker/LXC instead of bypassing it
- Add /api/v1/status/ws/ location block in nginx.conf with WebSocket
  upgrade headers (must precede /api/ to avoid missing Upgrade header)
2026-03-18 00:16:42 +01:00
Pouzor df3b7a8cb0 fix: show validation error when label is empty in NodeModal
Silent return when label was empty made the Add button appear broken.
Native browser required tooltip doesn't render in Radix Dialog portals.
Now shows inline red error message and highlights the field.
2026-03-16 01:01:47 +01:00
Remy 426af29180 Merge pull request #2 from Pouzor/feat/mcp-server
feat: MCP server for AI integration
2026-03-16 00:53:56 +01:00
13 changed files with 265 additions and 25 deletions
+14
View File
@@ -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
+14 -2
View File
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
@router.websocket("/ws/status")
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
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
await websocket.accept()
except WebSocketDisconnect:
return
_connections.append(websocket)
try:
while True:
+19 -10
View File
@@ -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)
# ---------------------------------------------------------------------------
+11 -1
View File
@@ -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;
+3 -2
View File
@@ -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',
+9 -4
View File
@@ -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()
})
})
+7 -2
View File
@@ -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)
+2 -1
View File
@@ -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,
+26
View File
@@ -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)
})
})
+25
View File
@@ -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)
})
}
+2 -1
View File
@@ -28,5 +28,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/__tests__/**", "src/test/**"]
}
+66
View File
@@ -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 ""