From fa1503c4ebfac27e5475131e9570d4e356ab41c6 Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Sat, 30 May 2026 11:26:16 +0530 Subject: [PATCH] feat: network sharing (PIN-gated LAN + QR) & Tailscale remote access (#125) (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): network sharing + Tailscale remote access design Same-state LAN sharing via a second in-process uvicorn listener on a dedicated share port (no restart, model/jobs preserved), PIN-gated for non-loopback clients, with QR + all-LAN-addresses panel. Tailscale serve for private remote access. Supersedes the raw 0.0.0.0 default-flip in #125. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(spec): control endpoints reuse existing require_loopback gate Security review of #157 confirmed the /system router is already loopback-gated via Depends(require_loopback) (non-spoofable request.client.host). The network control endpoints inherit it and /system/set-env is auto-protected from the LAN listener — no new guard needed. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): share-listener module — LAN enumeration + PIN + lifecycle Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): loopback-only control endpoints + /system/info sharing fields Co-Authored-By: Claude Opus 4.8 (1M context) * test(cjk): scan git-tracked files only, not untracked vendored dirs The no-hardcoded-CJK guard walked the filesystem, so local untracked vendored experiments (research/voice-pro etc. with JP issue templates) caused false local failures while CI (committed files) passed. Scan via git ls-files so local-only and CI behavior match. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): PIN middleware — gate non-loopback API access when sharing on Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): remote PIN gate on 401 Co-Authored-By: Claude Opus 4.8 (1M context) * chore(network): add qrcode dep for share QR Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tailscale): CLI status + serve enable/disable + endpoints Co-Authored-By: Claude Opus 4.8 (1M context) * feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale) Co-Authored-By: Claude Opus 4.8 (1M context) * docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(network): enable() tears down and raises if the share listener never binds Defensive guard (spec §7): if the second uvicorn server doesn't reach 'started' (e.g. the share port was taken in the race after the free-port probe), cancel the task, reset state, and raise — so the API surfaces the failure and the UI stays Local rather than reporting a dead 'Network' state. Co-Authored-By: Claude Opus 4.8 (1M context) * test(network): use globalThis (not Node global) in client.test.ts for tsc CI runs 'tsc --noEmit --checkJs false', which type-checks .ts files; Node's 'global' isn't typed there (TS2304). vitest (esbuild) tolerated it locally. Use globalThis (standard, typed) + cast the mock. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(network): apiFetch leaves opts untouched when no PIN set The unconditional headers merge changed the request shape for callers with no headers (e.g. FormData posts), breaking the legacy 'apiPost passes FormData without Content-Type override' node test. Only spread opts + inject X-OmniVoice-Pin when a PIN is actually present; otherwise pass opts through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/api/routers/system.py | 59 ++++- backend/api/schemas.py | 4 + backend/main.py | 46 ++++ backend/services/network_share.py | 109 ++++++++ backend/services/tailscale.py | 58 +++++ bun.lock | 45 +++- docs/sharing.md | 43 ++++ .../2026-05-30-network-sharing-design.md | 139 +++++++++++ frontend/package.json | 1 + frontend/src/App.jsx | 3 + frontend/src/api/client.test.ts | 24 ++ frontend/src/api/client.ts | 23 +- frontend/src/components/LogsFooter.jsx | 2 + frontend/src/components/NetworkToggle.css | 168 +++++++++++++ frontend/src/components/NetworkToggle.jsx | 89 +++++++ .../src/components/NetworkToggle.test.jsx | 16 ++ frontend/src/components/RemoteAuthGate.jsx | 40 +++ .../src/components/RemoteAuthGate.test.jsx | 20 ++ .../src/components/settings/SharingPanel.css | 102 ++++++++ .../src/components/settings/SharingPanel.jsx | 233 ++++++++++++++++++ .../components/settings/SharingPanel.test.jsx | 20 ++ frontend/src/i18n/locales/en.json | 1 + frontend/src/pages/Settings.jsx | 6 +- tests/test_network_middleware.py | 43 ++++ tests/test_network_share.py | 54 ++++ tests/test_no_hardcoded_cjk.py | 18 ++ tests/test_tailscale_service.py | 20 ++ 27 files changed, 1379 insertions(+), 7 deletions(-) create mode 100644 backend/services/network_share.py create mode 100644 backend/services/tailscale.py create mode 100644 docs/sharing.md create mode 100644 docs/superpowers/specs/2026-05-30-network-sharing-design.md create mode 100644 frontend/src/api/client.test.ts create mode 100644 frontend/src/components/NetworkToggle.css create mode 100644 frontend/src/components/NetworkToggle.jsx create mode 100644 frontend/src/components/NetworkToggle.test.jsx create mode 100644 frontend/src/components/RemoteAuthGate.jsx create mode 100644 frontend/src/components/RemoteAuthGate.test.jsx create mode 100644 frontend/src/components/settings/SharingPanel.css create mode 100644 frontend/src/components/settings/SharingPanel.jsx create mode 100644 frontend/src/components/settings/SharingPanel.test.jsx create mode 100644 tests/test_network_middleware.py create mode 100644 tests/test_network_share.py create mode 100644 tests/test_tailscale_service.py diff --git a/backend/api/routers/system.py b/backend/api/routers/system.py index 341ccdc1..db974226 100644 --- a/backend/api/routers/system.py +++ b/backend/api/routers/system.py @@ -4,8 +4,10 @@ import uuid import psutil import asyncio import logging -from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query +from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query, Request from core.prefs import set_ as prefs_set, delete as prefs_delete +from services import network_share +from services import tailscale as _tailscale from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse, LogsResponse, FlushMemoryResponse from api.dependencies import require_loopback from fastapi.responses import FileResponse, StreamingResponse @@ -165,6 +167,10 @@ def system_info(): "ffmpeg_ok": bool(_ffmpeg), "ffmpeg_path": _ffmpeg or "", "proxy_url": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "", + "share_enabled": network_share.get_state().enabled, + "share_port": network_share.get_state().share_port, + "lan_addresses": network_share.get_state().lan_addresses, + "pin_required": bool(network_share.get_state().pin), } except Exception as e: logger.exception("system_info failed — returning safe defaults") @@ -181,6 +187,10 @@ def system_info(): "python": sys.version.split()[0], "platform": sys.platform, "proxy_url": "", + "share_enabled": network_share.get_state().enabled, + "share_port": network_share.get_state().share_port, + "lan_addresses": network_share.get_state().lan_addresses, + "pin_required": bool(network_share.get_state().pin), "error": str(e), } @@ -743,3 +753,50 @@ def quarantine_status(): from core import gatekeeper_detect return gatekeeper_detect.quarantine_status() + + +# ── Network sharing (loopback-only control surface) ────────────────────────── + +@router.get("/system/network/state") +async def network_state(): + st = network_share.get_state() + return { + "enabled": st.enabled, + "share_port": st.share_port, + "pin": st.pin, + "lan_addresses": st.lan_addresses, + } + + +@router.post("/system/network/enable") +async def network_enable(request: Request): + st = await network_share.enable(request.app) + return { + "enabled": st.enabled, + "share_port": st.share_port, + "pin": st.pin, + "lan_addresses": st.lan_addresses, + } + + +@router.post("/system/network/disable") +async def network_disable(request: Request): + st = await network_share.disable(request.app) + return {"enabled": st.enabled} + + +# ── Tailscale (loopback-only control surface) ──────────────────────────────── + +@router.get("/system/tailscale/status") +async def tailscale_status(): + return _tailscale.status() + + +@router.post("/system/tailscale/enable") +async def tailscale_enable(): + return _tailscale.serve_enable() + + +@router.post("/system/tailscale/disable") +async def tailscale_disable(): + return _tailscale.serve_disable() diff --git a/backend/api/schemas.py b/backend/api/schemas.py index 3d9d19d2..037671bd 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -40,6 +40,10 @@ class SystemInfoResponse(BaseModel): ffmpeg_ok: bool = False ffmpeg_path: str = "" proxy_url: str = "" + share_enabled: bool = False + share_port: int | None = None + lan_addresses: list[str] = [] + pin_required: bool = False class ModelStatusResponse(BaseModel): diff --git a/backend/main.py b/backend/main.py index 54281daf..b00cce89 100644 --- a/backend/main.py +++ b/backend/main.py @@ -248,6 +248,7 @@ if not os.environ.get("OMNIVOICE_DISABLE_FILE_LOG"): logger = logging.getLogger("omnivoice.api") import asyncio +import secrets import time import threading from contextlib import asynccontextmanager @@ -255,6 +256,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware from scalar_fastapi import get_scalar_api_reference import traceback @@ -265,6 +267,7 @@ from core.config import OUTPUTS_DIR, VOICES_DIR, CRASH_LOG_PATH from core.tasks import task_manager from core import job_store from services.model_manager import idle_worker, preload_model +from services import network_share from api.routers import ( system, @@ -310,6 +313,10 @@ def _env_flag(name: str, default: bool = False) -> bool: @asynccontextmanager async def lifespan(app: FastAPI): init_db() + # Network sharing is loopback-only by default; the PIN middleware stays + # inert until enable() sets a PIN. Seed the (disabled) state so the + # middleware and /system/network/state always have something to read. + app.state.network_share = network_share.get_state() from api.routers.gallery import _init_gallery_db _init_gallery_db() @@ -473,6 +480,41 @@ async def global_exception_handler(request: Request, exc: Exception): return JSONResponse({"detail": str(exc)}, status_code=500, headers=headers) +_LOOPBACK_CLIENTS = {"127.0.0.1", "::1"} +_SHELL_PATHS = {"/", "/index.html", "/favicon.ico", "/health"} + + +class NetworkAccessMiddleware(BaseHTTPMiddleware): + """When a share PIN is set, require it for non-loopback clients on API + routes. Inert when no PIN (default + docker deploys). Loopback (incl. + Tailscale-proxied) always bypasses; the SPA shell is always served so the + PIN gate UI can load.""" + + async def dispatch(self, request, call_next): + ns = getattr(request.app.state, "network_share", None) + pin = getattr(ns, "pin", None) if ns else None + if not pin: + return await call_next(request) + client = request.client.host if request.client else None + if client in _LOOPBACK_CLIENTS: + return await call_next(request) + path = request.url.path + if path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon"): + return await call_next(request) + supplied = ( + request.headers.get("x-omnivoice-pin") + or request.query_params.get("pin") + or request.cookies.get("ov_pin") + or "" + ) + if not secrets.compare_digest(supplied, pin): + return JSONResponse({"detail": "PIN required"}, status_code=401) + response = await call_next(request) + if request.cookies.get("ov_pin") != pin: + response.set_cookie("ov_pin", pin, samesite="lax") + return response + + _allowed = os.environ.get( "OMNIVOICE_ALLOWED_ORIGINS", "http://localhost:3901,http://127.0.0.1:3901,tauri://localhost,http://tauri.localhost", @@ -487,6 +529,10 @@ app.add_middleware( expose_headers=["Content-Disposition"], ) +# Registered AFTER CORS so CORS remains the outermost layer (CORS headers are +# applied even to the 401 PIN-required responses). Inert unless a PIN is set. +app.add_middleware(NetworkAccessMiddleware) + app.mount("/audio", StaticFiles(directory=OUTPUTS_DIR), name="audio") app.mount("/voice_audio", StaticFiles(directory=VOICES_DIR), name="voice_audio") diff --git a/backend/services/network_share.py b/backend/services/network_share.py new file mode 100644 index 00000000..c24b5f8a --- /dev/null +++ b/backend/services/network_share.py @@ -0,0 +1,109 @@ +"""Same-process LAN share listener + access PIN. + +Enabling starts a SECOND uvicorn.Server bound to 0.0.0.0 on a dedicated port, +serving the SAME FastAPI app object — so the loaded model and in-flight jobs +are untouched (no restart). Disabling stops it, closing the 0.0.0.0 socket. +Loopback-only by default: nothing binds 0.0.0.0 until enable() is called. +""" +import asyncio +import secrets +import socket +from dataclasses import dataclass, field +from typing import Optional + +import psutil +import uvicorn + +BACKEND_PORT = 3900 # must match backend/main.py uvicorn.run(port=...) + + +@dataclass +class ShareState: + enabled: bool = False + share_port: Optional[int] = None + pin: Optional[str] = None + lan_addresses: list = field(default_factory=list) + + +_state = ShareState() +_server: Optional["uvicorn.Server"] = None +_task: Optional["asyncio.Task"] = None + + +def lan_ipv4_addresses() -> list: + out, seen = [], set() + for _name, addrs in psutil.net_if_addrs().items(): + for a in addrs: + if a.family == socket.AF_INET: + ip = a.address + if ip.startswith("127.") or ip.startswith("169.254."): + continue + if ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def _gen_pin() -> str: + return f"{secrets.randbelow(900000) + 100000}" # 100000-999999 + + +def _find_free_port(base: int, tries: int = 20) -> int: + for p in range(base, base + tries): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("0.0.0.0", p)) + return p + except OSError: + continue + raise RuntimeError("no free share port available") + + +def get_state() -> ShareState: + return _state + + +async def enable(app) -> ShareState: + global _server, _task, _state + if _state.enabled: + return _state + port = _find_free_port(BACKEND_PORT + 1) + pin = _gen_pin() + config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="warning") + server = uvicorn.Server(config) + server.install_signal_handlers = lambda: None # never hijack signals in-process + _task = asyncio.create_task(server.serve()) + for _ in range(100): # ~5s for the socket to bind + if getattr(server, "started", False): + break + await asyncio.sleep(0.05) + if not getattr(server, "started", False): + # Bind failed (e.g. the port was taken in the race after the + # free-port probe). Tear down and stay Local — never report enabled + # with a listener that isn't actually up (spec §7). + server.should_exit = True + try: + await asyncio.wait_for(_task, timeout=2) + except Exception: + pass + _task = None + raise RuntimeError("share listener failed to start") + _server = server + _state = ShareState(True, port, pin, lan_ipv4_addresses()) + app.state.network_share = _state + return _state + + +async def disable(app) -> ShareState: + global _server, _task, _state + if _server is not None: + _server.should_exit = True + if _task is not None: + try: + await asyncio.wait_for(_task, timeout=5) + except Exception: + pass + _server = _task = None + _state = ShareState() + app.state.network_share = _state + return _state diff --git a/backend/services/tailscale.py b/backend/services/tailscale.py new file mode 100644 index 00000000..6b5227a6 --- /dev/null +++ b/backend/services/tailscale.py @@ -0,0 +1,58 @@ +"""Thin wrapper around the `tailscale` CLI. Every call degrades gracefully +when the CLI is missing or not logged in (installed/running flags).""" +import json +import shutil +import subprocess +from services.network_share import BACKEND_PORT + + +def _cli(): + return shutil.which("tailscale") + + +def status() -> dict: + out = {"installed": False, "running": False, "magic_dns_name": "", "tailnet_ips": []} + cli = _cli() + if not cli: + return out + out["installed"] = True + try: + r = subprocess.run([cli, "status", "--json"], capture_output=True, text=True, timeout=10) + if r.returncode != 0: + return out + data = json.loads(r.stdout or "{}") + out["running"] = data.get("BackendState") == "Running" + self_ = data.get("Self") or {} + out["magic_dns_name"] = (self_.get("DNSName") or "").rstrip(".") + out["tailnet_ips"] = self_.get("TailscaleIPs") or [] + except Exception: + pass + return out + + +def serve_enable(port: int = BACKEND_PORT) -> dict: + cli = _cli() + if not cli: + return {"ok": False, "error": "tailscale CLI not found"} + try: + r = subprocess.run( + [cli, "serve", "--bg", "--https=443", f"http://127.0.0.1:{port}"], + capture_output=True, text=True, timeout=20, + ) + if r.returncode != 0: + return {"ok": False, "error": (r.stderr or r.stdout or "tailscale serve failed").strip()} + dns = status().get("magic_dns_name", "") + return {"ok": True, "url": f"https://{dns}" if dns else ""} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def serve_disable() -> dict: + cli = _cli() + if not cli: + return {"ok": True} + try: + subprocess.run([cli, "serve", "reset"], capture_output=True, text=True, timeout=20) + except Exception: + pass + return {"ok": True} diff --git a/bun.lock b/bun.lock index fadf0f97..3c45e4a7 100644 --- a/bun.lock +++ b/bun.lock @@ -7,15 +7,15 @@ "devDependencies": { "concurrently": "^9.2.1", "kill-port-process": "^4.0.2", - "playwright": "^1.59.1", - "turbo": "^2.9.7", + "playwright": "^1.60.0", + "turbo": "^2.9.15", "typescript": "^6.0.3", - "wait-on": "^9.0.5", + "wait-on": "^9.0.10", }, }, "frontend": { "name": "omnivoice-studio", - "version": "0.2.7", + "version": "0.3.0", "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/source-serif-4": "^5.2.9", @@ -41,6 +41,7 @@ "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.14.0", + "qrcode": "^1.5.4", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hot-toast": "^2.6.0", @@ -479,6 +480,8 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -509,6 +512,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -521,6 +526,8 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -763,6 +770,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], @@ -783,6 +792,8 @@ "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -795,6 +806,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], @@ -819,6 +832,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], @@ -829,6 +844,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -921,6 +938,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -985,10 +1004,28 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/docs/sharing.md b/docs/sharing.md new file mode 100644 index 00000000..6cb36c28 --- /dev/null +++ b/docs/sharing.md @@ -0,0 +1,43 @@ +# Sharing & Remote Access + +OmniVoice runs **local-only by default** — the backend binds to `127.0.0.1` and nothing is reachable from other machines. When you want to use the *same running instance* (same loaded model, same projects and jobs) from another device, you have two opt-in paths. Neither restarts the backend or interrupts work in progress. + +## LAN sharing (same Wi-Fi / Ethernet) + +For another device on the same network — e.g. opening the web UI on your phone or a second laptop. + +1. In the footer, click the **Local** pill → confirm **Network**. +2. A panel appears listing every reachable address of this machine (`http://:`), each with: + - a **QR code** — scan it from a phone/tablet to open the UI pre-authenticated, + - **copy** and **open-in-browser** buttons, + - the **access PIN**. +3. On the other device, scan the QR (or open the URL and enter the PIN when prompted). +4. Click **Stop sharing** (or flip back to **Local**) to close the network socket again. + +You can also drive this from **Settings → Sharing & Remote Access**. + +### How the PIN works +- A fresh 6-digit PIN is generated each time you enable sharing; it is never written to disk. +- The QR encodes the PIN (`…/?pin=######`) so scanning connects in one step. Typing the bare URL instead prompts for the PIN. +- Requests from other devices must present the PIN (sent automatically once entered/scanned); requests from this machine never need it. + +### Security model +- **Loopback-only is the default on every launch** — you must explicitly enable sharing each session; it never auto-exposes. +- When sharing is off, **nothing is bound** to the network interface (the port is closed, not merely firewalled). +- The control surface and all `/system/*` endpoints are **loopback-only** — a device on the LAN cannot enable sharing, read the PIN, or change settings, even while sharing is on. +- The LAN path is plain **HTTP**. For encryption / access from outside your LAN, use Tailscale (below). + +## Tailscale (private remote access, from anywhere) + +If you have [Tailscale](https://tailscale.com/download) installed and signed in, you can reach OmniVoice from any of your devices over your private tailnet — encrypted (HTTPS), identity-gated, with **no open ports and no PIN** (Tailscale handles identity and TLS). + +1. **Settings → Sharing & Remote Access → Tailscale.** +2. If Tailscale isn't detected, an **Install Tailscale** link is shown. +3. Otherwise, **Enable** publishes this backend over your tailnet via `tailscale serve`. The panel shows your `https://..ts.net` URL with copy / open / QR. +4. Open that URL from any device signed in to the same tailnet. **Disable** runs `tailscale serve reset`. + +Tailscale proxies the loopback backend directly, so — like LAN sharing — it never restarts the backend or drops the loaded model. + +## Notes +- Both paths leave the running model and in-flight jobs **completely untouched**. +- Server deployments (docker, `OMNIVOICE_BIND_HOST=0.0.0.0`) manage their own networking; the in-app toggle is for the desktop app and is unaffected by these flows. diff --git a/docs/superpowers/specs/2026-05-30-network-sharing-design.md b/docs/superpowers/specs/2026-05-30-network-sharing-design.md new file mode 100644 index 00000000..0ca77f13 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-network-sharing-design.md @@ -0,0 +1,139 @@ +# Network Sharing & Tailscale Remote Access — Design Spec + +- **Date:** 2026-05-30 +- **Status:** Approved design — pending spec review +- **Supersedes:** the raw `0.0.0.0` default-flip proposed in community PR #125 (declined as a silent default change; replaced by this explicit, user-driven feature) +- **Ships on:** v0.3.0 + +## 1. Goal + +Let a user **see and access the same running OmniVoice instance from their other machines** — without losing the loaded model or interrupting in-flight jobs, and without weakening the local-first default. Two complementary capabilities: + +- **A. LAN sharing** — expose the *same* backend to devices on the same network (Wi-Fi/Ethernet), gated by a short access **PIN**, with a polished footer panel: all LAN addresses, copy/open link, and a **QR code** for phones. +- **B. Tailscale remote access** — for secure, private access from *anywhere*, a Settings toggle that drives `tailscale serve` to publish the WebUI over the user's tailnet at an HTTPS `*.ts.net` URL (TLS + identity handled by Tailscale; no open ports, no PIN). + +Both leave the running model and job state **completely untouched**. + +## 2. Non-Goals (explicitly out of scope) + +- **WebRTC** — evaluated and dropped; Tailscale fully covers "secure private remote access" with far less risk and no STUN/TURN infrastructure. May be revisited in its own spec if real-time P2P media becomes a requirement. +- **TLS for the LAN path** — the LAN listener is plain HTTP (the app is HTTP today). Users who want encryption use the Tailscale path (B), which terminates TLS. +- **Accounts / per-device tokens / PIN expiry beyond the session** — one shared session PIN. YAGNI. +- **Changing the default bind** — the primary backend stays loopback-only by default on every launch (the user's chosen "always start Local"). + +## 3. Core constraint → mechanism + +A live socket cannot be re-bound, and you cannot bind both `127.0.0.1:P` and `0.0.0.0:P` simultaneously. Restarting uvicorn to change the host would drop the loaded model and kill in-flight jobs — **disqualified by the requirement to preserve active work.** + +**Mechanism: a second in-process listener on a dedicated share port.** + +The backend already runs under uvicorn on `127.0.0.1:P` (P = `backend_port()`, spawned by Tauri). On "enable LAN sharing," the backend starts a **second `uvicorn.Server` bound to `0.0.0.0` on a share port** (default `P+1`, auto-incremented if taken), running as an `asyncio` task that serves the **exact same FastAPI `app` object**. + +Because it is the same `app` in the same process and event loop: +- same loaded model (no reload), +- same in-memory job registry and SSE streams, +- no restart, no dropped work. + +"Disable" sets `server.should_exit = True` and awaits the task's exit → the `0.0.0.0` socket is **genuinely closed** (not merely firewalled). Default state = no share listener = nothing bound to `0.0.0.0`. + +## 4. Architecture + +No *new* Rust/Tauri commands are required — the feature is backend-driven and the footer/Settings UI calls backend HTTP endpoints; opening links reuses the existing `shell.open` capability. (The salvaged Windows `kill_orphan_on_port` from PR #85 remains useful for the bootstrap port-conflict path but is unrelated to this feature.) + +### 4.1 Backend + +**New module `backend/services/network_share.py`** — owns the share-listener lifecycle and PIN: +- `ShareState` dataclass: `enabled: bool`, `host: str`, `share_port: int | None`, `pin: str | None`, `lan_addresses: list[str]`, `started_at`. +- `enable(app, base_port) -> ShareState`: pick an available share port (`base_port+1`, try a small range), generate a 6-digit PIN (`secrets.randbelow`), build a `uvicorn.Config(app, host="0.0.0.0", port=share_port)` + `uvicorn.Server`, launch `asyncio.create_task(server.serve())`, await `server.started`, store the server/task/state on `app.state.network_share`. +- `disable() -> ShareState`: signal `should_exit`, await task, clear state. +- `get_state() -> ShareState`. +- `lan_ipv4_addresses() -> list[str]`: enumerate via `psutil.net_if_addrs()`, keep `AF_INET`, drop loopback/link-local (`127.`, `169.254.`). (psutil already pinned — no new backend dep.) + +**Control endpoints (`backend/api/routers/system.py`)** — the `system` router is **already loopback-gated** by `Depends(require_loopback)` (confirmed in the #157 security review: it checks the real, non-spoofable `request.client.host`). New endpoints added under this router inherit it — so a LAN client (even via the share listener) can never enable exposure, read the PIN, or reach `/system/set-env` (which can set executable paths). No new guard needed; reuse the existing dependency: +- `POST /system/network/enable` → `network_share.enable(...)` → returns sanitized `ShareState` (incl. `pin`, `lan_addresses`, `share_port`). +- `POST /system/network/disable` → returns `ShareState`. +- `GET /system/network/state` → current `ShareState` (incl. `pin` only because caller is loopback). + +**`/system/info` additions:** `share_enabled`, `share_port`, `lan_addresses`, `pin_required`, and `access_pin` — `access_pin` is included **only when `request.client.host` is loopback** (the desktop app sees it; remote devices never receive it from the API). + +**Auth middleware `NetworkAccessMiddleware` (`backend/main.py`):** +- **Active only when a PIN is set** (`app.state.network_share.pin`). When no PIN is set (default, and the docker-compose `0.0.0.0` deploy path), the middleware is a pass-through → **full backward compatibility**, no regression to server deployments. +- When active: + - **Loopback clients always bypass** (`127.0.0.1`, `::1`). This includes Tailscale-`serve`-proxied requests, which arrive from loopback — so the Tailscale path correctly needs no PIN. + - **SPA shell always served** without PIN so the gate UI can load: `GET /`, `/assets/*`, `/favicon*`, `/index.html`, and the `/health` healthcheck. + - **All other routes from non-loopback clients require the PIN**, accepted via `X-OmniVoice-Pin` header, `?pin=` query, or `ov_pin` cookie. A valid PIN response sets the `ov_pin` cookie so subsequent media/SSE/`