Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2008f9467a | |||
| d9ac9462a8 | |||
| e14a9e87aa | |||
| e5d7260696 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/**"]
|
||||
}
|
||||
|
||||
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