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/`