diff --git a/.env.example b/.env.example index 11a238b..f9affad 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ STATUS_CHECKER_INTERVAL=60 # Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))" MCP_API_KEY=mcp_sk_changeme MCP_SERVICE_KEY=svc_changeme + +# Live view — read-only public canvas at /view?key= +# Off by default. Set to a random secret to enable. +# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +# LIVEVIEW_KEY= diff --git a/README.md b/README.md index 0e09f87..a5a4a45 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,31 @@ Homelable continuously monitors your nodes and displays their live status (onlin --- +## Live View (read-only public canvas) + +Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default. + +### Activation + +Add LIVEVIEW_KEY to your .env: + +`LIVEVIEW_KEY=your-secret-key` + + +Then restart the backend: + +`docker compose restart backend` + +### Usage + +Use this URL to view your canvas: + +http:///view?key=your-secret-key + +The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab. + +--- + ## MCP Server (AI Integration) (optionnal) Homelable can 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. diff --git a/backend/app/api/routes/liveview.py b/backend/app/api/routes/liveview.py new file mode 100644 index 0000000..6b9bad2 --- /dev/null +++ b/backend/app/api/routes/liveview.py @@ -0,0 +1,41 @@ +import hmac +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.db.database import get_db +from app.db.models import CanvasState, Edge, Node +from app.schemas.canvas import CanvasStateResponse +from app.schemas.edges import EdgeResponse +from app.schemas.nodes import NodeResponse + +router = APIRouter() + + +@router.get("", response_model=CanvasStateResponse) +async def liveview_canvas( + key: str | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> CanvasStateResponse: + """Read-only public canvas endpoint. + + Disabled by default — requires LIVEVIEW_KEY to be set in .env. + Always returns 403 when disabled, regardless of the key provided. + """ + if not settings.liveview_key: + raise HTTPException(status_code=403, detail="Live view is disabled") + if not key or not hmac.compare_digest(key, settings.liveview_key): + raise HTTPException(status_code=403, detail="Invalid live view key") + + nodes = (await db.execute(select(Node))).scalars().all() + edges = (await db.execute(select(Edge))).scalars().all() + state = await db.get(CanvasState, 1) + viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} + return CanvasStateResponse( + nodes=[NodeResponse.model_validate(n) for n in nodes], + edges=[EdgeResponse.model_validate(e) for e in edges], + viewport=viewport, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 80d51a2..7d6a1c6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -30,6 +30,11 @@ class Settings(BaseSettings): # Leave empty to disable MCP service key auth. mcp_service_key: str = "" + # Live view — optional read-only public canvas endpoint. + # Set to a random secret string to enable /api/v1/liveview?key=. + # Leave unset (or empty) to keep the feature disabled (default). + liveview_key: str | None = None + def _override_path(self) -> Path: return Path(self.sqlite_path).parent / "scan_config.json" diff --git a/backend/app/main.py b/backend/app/main.py index 9767ec6..9cd067d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import auth, canvas, edges, nodes, scan, status +from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status from app.core.config import settings from app.core.scheduler import start_scheduler, stop_scheduler from app.db.database import init_db @@ -40,6 +40,7 @@ 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.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"]) @app.get("/api/v1/health") diff --git a/backend/data/service_signatures.json b/backend/data/service_signatures.json deleted file mode 100644 index 412b14c..0000000 --- a/backend/data/service_signatures.json +++ /dev/null @@ -1,146 +0,0 @@ -[ - {"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"}, - - {"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, - {"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, - {"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, - {"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, - {"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, - {"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"}, - {"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"}, - {"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"}, - {"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"}, - - {"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, - {"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, - {"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, - {"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, - {"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, - {"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, - {"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"}, - {"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"}, - {"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"}, - - {"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"}, - {"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"}, - {"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"}, - {"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"}, - {"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"}, - {"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"}, - {"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"}, - {"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"}, - {"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"}, - {"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"}, - {"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"}, - - {"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"}, - {"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"}, - {"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"}, - {"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"}, - {"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"}, - {"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"}, - - {"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"}, - {"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"}, - {"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"}, - {"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"}, - {"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"}, - {"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"}, - {"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"}, - {"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"}, - {"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"}, - {"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"}, - {"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"}, - - {"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - {"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - {"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - {"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - {"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - {"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - {"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"}, - - {"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"}, - {"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"}, - {"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"}, - {"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"}, - {"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"}, - {"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"}, - {"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"}, - {"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"}, - - {"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"}, - {"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"}, - {"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"}, - {"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"}, - {"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"}, - {"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"}, - - {"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, - {"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"}, - - {"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"}, - {"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"}, - {"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"}, - {"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"}, - {"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"}, - - {"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"}, - {"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"}, - - {"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"}, - {"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"}, - {"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"}, - {"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"}, - {"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"}, - {"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"}, - - {"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"}, - {"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"}, - - {"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"}, - {"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"}, - {"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"}, - {"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"}, - {"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"}, - {"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"}, - {"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"}, - {"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"}, - {"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"}, - {"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"}, - - {"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"}, - {"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"}, - {"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"}, - - {"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"}, - {"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"}, - {"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"}, - {"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"}, - {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"} -] diff --git a/backend/tests/test_liveview.py b/backend/tests/test_liveview.py new file mode 100644 index 0000000..bf27b85 --- /dev/null +++ b/backend/tests/test_liveview.py @@ -0,0 +1,126 @@ +""" +Tests for the /api/v1/liveview read-only canvas endpoint. + +The endpoint is: + - Disabled by default (LIVEVIEW_KEY not set) → 403 + - Returns 403 for missing or wrong key even when enabled + - Returns canvas data for a valid key (no JWT required) +""" + +import pytest +from httpx import AsyncClient + +from app.core.config import settings + + +@pytest.fixture(autouse=True) +def reset_liveview_key(): + """Restore liveview_key after each test so tests are isolated.""" + original = settings.liveview_key + yield + settings.liveview_key = original + + +# ── Disabled (no key configured) ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_liveview_disabled_by_default(client: AsyncClient): + settings.liveview_key = None + res = await client.get("/api/v1/liveview?key=anything") + assert res.status_code == 403 + assert res.json()["detail"] == "Live view is disabled" + + +@pytest.mark.asyncio +async def test_liveview_disabled_when_key_empty(client: AsyncClient): + settings.liveview_key = "" + res = await client.get("/api/v1/liveview?key=anything") + assert res.status_code == 403 + assert res.json()["detail"] == "Live view is disabled" + + +# ── Enabled but wrong / missing key ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_liveview_wrong_key(client: AsyncClient): + settings.liveview_key = "correct-secret" + res = await client.get("/api/v1/liveview?key=wrong-key") + assert res.status_code == 403 + assert res.json()["detail"] == "Invalid live view key" + + +@pytest.mark.asyncio +async def test_liveview_missing_key_param(client: AsyncClient): + settings.liveview_key = "correct-secret" + res = await client.get("/api/v1/liveview") + assert res.status_code == 403 + assert res.json()["detail"] == "Invalid live view key" + + +# ── Valid key — no JWT needed ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_liveview_valid_key_returns_canvas(client: AsyncClient): + settings.liveview_key = "my-secret-key" + res = await client.get("/api/v1/liveview?key=my-secret-key") + assert res.status_code == 200 + data = res.json() + assert "nodes" in data + assert "edges" in data + assert "viewport" in data + assert isinstance(data["nodes"], list) + assert isinstance(data["edges"], list) + + +@pytest.mark.asyncio +async def test_liveview_does_not_require_jwt(client: AsyncClient): + """Accessing without Authorization header must work when key is correct.""" + settings.liveview_key = "open-sesame" + # client has no auth headers set here + res = await client.get("/api/v1/liveview?key=open-sesame") + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers): + """Canvas saved via POST /canvas/save appears in liveview response.""" + settings.liveview_key = "test-key" + headers = await auth_headers() + + # Save a canvas with one node + payload = { + "nodes": [{ + "id": "lv-node-1", + "type": "server", + "label": "Live Node", + "status": "online", + "services": [], + "pos_x": 10, + "pos_y": 20, + }], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + await client.post("/api/v1/canvas/save", json=payload, headers=headers) + + # Liveview should return the same node + res = await client.get("/api/v1/liveview?key=test-key") + assert res.status_code == 200 + nodes = res.json()["nodes"] + assert len(nodes) == 1 + assert nodes[0]["id"] == "lv-node-1" + assert nodes[0]["label"] == "Live Node" + + +# ── Re-disable after enabling ───────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_liveview_disabled_after_key_cleared(client: AsyncClient): + settings.liveview_key = "was-enabled" + res = await client.get("/api/v1/liveview?key=was-enabled") + assert res.status_code == 200 + + settings.liveview_key = None + res = await client.get("/api/v1/liveview?key=was-enabled") + assert res.status_code == 403 + assert res.json()["detail"] == "Live view is disabled" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e8d1145..bf4f566 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,6 +5,9 @@ export const api = axios.create({ baseURL: '/api/v1', }) +// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints) +const publicApi = axios.create({ baseURL: '/api/v1' }) + api.interceptors.request.use((config) => { const token = useAuthStore.getState().token if (token) config.headers.Authorization = `Bearer ${token}` @@ -44,6 +47,10 @@ export const edgesApi = { delete: (id: string) => api.delete(`/edges/${id}`), } +export const liveviewApi = { + load: (key: string) => publicApi.get('/liveview', { params: { key } }), +} + export const scanApi = { trigger: () => api.post('/scan/trigger'), pending: () => api.get('/scan/pending'), diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx new file mode 100644 index 0000000..8d428f7 --- /dev/null +++ b/frontend/src/components/LiveView.tsx @@ -0,0 +1,155 @@ +/** + * LiveView — read-only canvas accessible at /view?key=. + * + * - Non-standalone: fetches canvas from /api/v1/liveview?key=... (no JWT needed). + * Returns 403 when the feature is disabled or the key is wrong. + * - Standalone: loads canvas from localStorage directly (no key required, + * since there is no backend to validate against). + * + * Pan and zoom work. Editing is fully disabled. + * Clicking a node with an IP opens http:// in a new tab. + */ + +import { useCallback, useEffect, useState } from 'react' +import { + ReactFlowProvider, + ReactFlow, + Background, + BackgroundVariant, + Controls, + ConnectionMode, + type Node, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' +import { nodeTypes } from '@/components/canvas/nodes/nodeTypes' +import { edgeTypes } from '@/components/canvas/edges/edgeTypes' +import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' +import { liveviewApi } from '@/api/client' +import type { NodeData } from '@/types' + +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' +const STORAGE_KEY = 'homelable_canvas' + +type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready' + +function LiveViewCanvas() { + const { nodes, edges, loadCanvas } = useCanvasStore() + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] + // Derive initial view state synchronously (avoids calling setState inside an effect): + // - standalone → always ready (localStorage, no key required) + // - non-standalone, no ?key= → no-key error immediately + // - non-standalone, key present → loading (API call below) + const [viewState, setViewState] = useState(() => { + if (STANDALONE) return 'ready' + return new URLSearchParams(window.location.search).get('key') ? 'loading' : 'no-key' + }) + + useEffect(() => { + if (STANDALONE) { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) { + const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) + loadCanvas(savedNodes, savedEdges) + } + } catch { + // empty canvas on parse error — show empty canvas + } + return + } + + // Already handled synchronously in useState initializer + const key = new URLSearchParams(window.location.search).get('key') + if (!key) return + + liveviewApi.load(key) + .then((res) => { + const { nodes: apiNodes, edges: apiEdges } = res.data + const proxmoxMap = new Map( + (apiNodes as ApiNode[]) + .filter((n: ApiNode) => n.type === 'proxmox') + .map((n: ApiNode) => [n.id, n.container_mode !== false]) + ) + loadCanvas( + (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), + (apiEdges as ApiEdge[]).map(deserializeApiEdge), + ) + setViewState('ready') + }) + .catch((err) => { + if (!err.response) { setViewState('network-error'); return } + const detail: string = err.response.data?.detail ?? '' + setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key') + }) + }, [loadCanvas]) + + const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { + const ip = node.data.ip + if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer') + }, []) + + if (viewState === 'loading') { + return ( +
+ Loading… +
+ ) + } + + if (viewState !== 'ready') { + const messages: Record, string> = { + disabled: 'Live view is disabled on this instance.', + 'invalid-key': 'Invalid or expired live view key.', + 'no-key': 'Missing key — use ?key=your-secret in the URL.', + 'network-error': 'Could not reach the server. Check your connection.', + } + return ( +
+
+

Access Denied

+

{messages[viewState]}

+
+
+ ) + } + + return ( +
+ + + + +
+ ) +} + +export default function LiveView() { + return ( + + + + ) +} diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx new file mode 100644 index 0000000..8983796 --- /dev/null +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { useCanvasStore } from '@/stores/canvasStore' + +// ── Mock heavy dependencies ──────────────────────────────────────────────── + +vi.mock('@xyflow/react', () => ({ + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + ReactFlow: () =>
, + Background: () => null, + Controls: () => null, + BackgroundVariant: { Dots: 'dots' }, + ConnectionMode: { Loose: 'loose' }, +})) +vi.mock('@xyflow/react/dist/style.css', () => ({})) + +vi.mock('@/api/client', () => ({ + liveviewApi: { load: vi.fn() }, +})) + +import { liveviewApi } from '@/api/client' +import LiveView from '../LiveView' + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function setSearch(params: string) { + Object.defineProperty(window, 'location', { + writable: true, + value: { ...window.location, search: params, pathname: '/view' }, + }) +} + +const canvasPayload = { + data: { + nodes: [{ + id: 'n1', type: 'server', label: 'CI Node', status: 'online', + services: [], pos_x: 0, pos_y: 0, + created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', + }], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('LiveView (non-standalone)', () => { + beforeEach(() => { + vi.mocked(liveviewApi.load).mockReset() + useCanvasStore.setState({ nodes: [], edges: [] }) + }) + + // ── No key ──────────────────────────────────────────────────────────────── + + it('shows no-key error when ?key= is missing', async () => { + setSearch('') + render() + await waitFor(() => { + expect(screen.getByText('Access Denied')).toBeDefined() + expect(screen.getByText(/Missing key/)).toBeDefined() + }) + expect(liveviewApi.load).not.toHaveBeenCalled() + }) + + // ── Disabled ────────────────────────────────────────────────────────────── + + it('shows disabled error when backend returns "Live view is disabled"', async () => { + setSearch('?key=anything') + vi.mocked(liveviewApi.load).mockRejectedValue({ + response: { data: { detail: 'Live view is disabled' } }, + }) + render() + await waitFor(() => { + expect(screen.getByText(/disabled on this instance/)).toBeDefined() + }) + }) + + // ── Invalid key ─────────────────────────────────────────────────────────── + + it('shows invalid-key error when backend returns "Invalid live view key"', async () => { + setSearch('?key=wrong') + vi.mocked(liveviewApi.load).mockRejectedValue({ + response: { data: { detail: 'Invalid live view key' } }, + }) + render() + await waitFor(() => { + expect(screen.getByText(/Invalid or expired/)).toBeDefined() + }) + }) + + it('shows network-error for non-response errors (offline, CORS, 500)', async () => { + setSearch('?key=anything') + vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network')) + render() + await waitFor(() => { + expect(screen.getByText(/Could not reach the server/)).toBeDefined() + }) + }) + + // ── Valid key → canvas rendered ─────────────────────────────────────────── + + it('renders the canvas on valid key', async () => { + setSearch('?key=correct-key') + vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never) + render() + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + expect(liveviewApi.load).toHaveBeenCalledWith('correct-key') + }) + + it('loads nodes into the canvas store on success', async () => { + setSearch('?key=secret') + vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never) + render() + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + const { nodes } = useCanvasStore.getState() + expect(nodes.find((n) => n.id === 'n1')).toBeDefined() + }) + + // ── No editing props passed ─────────────────────────────────────────────── + + it('does not show any Access Denied when key is valid', async () => { + setSearch('?key=valid') + vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never) + render() + await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined()) + expect(screen.queryByText('Access Denied')).toBeNull() + }) +}) + +// ── Standalone mode ──────────────────────────────────────────────────────── + +describe('LiveView (standalone — localStorage)', () => { + beforeEach(() => { + localStorage.clear() + useCanvasStore.setState({ nodes: [], edges: [] }) + vi.mocked(liveviewApi.load).mockReset() + }) + + it('loads canvas from localStorage without calling the API', async () => { + const stored = { + nodes: [{ + id: 'ls-node', type: 'router', + position: { x: 10, y: 20 }, + data: { label: 'Router', type: 'router', status: 'unknown', services: [] }, + }], + edges: [], + } + localStorage.setItem('homelable_canvas', JSON.stringify(stored)) + + // Stub VITE_STANDALONE before re-importing + vi.stubEnv('VITE_STANDALONE', 'true') + vi.resetModules() + const { default: LiveViewStandalone } = await import('../LiveView') + + setSearch('') // no key needed in standalone + render() + + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + expect(liveviewApi.load).not.toHaveBeenCalled() + + vi.unstubAllEnvs() + }) + + it('shows canvas (empty) when localStorage has no saved data', async () => { + vi.stubEnv('VITE_STANDALONE', 'true') + vi.resetModules() + const { default: LiveViewStandalone } = await import('../LiveView') + + setSearch('') + render() + + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + expect(liveviewApi.load).not.toHaveBeenCalled() + + vi.unstubAllEnvs() + }) +}) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..ad53488 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import LiveView from './components/LiveView.tsx' + +const isLiveView = window.location.pathname === '/view' createRoot(document.getElementById('root')!).render( - + {isLiveView ? : } , )