* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat(network): share-listener module — LAN enumeration + PIN + lifecycle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): loopback-only control endpoints + /system/info sharing fields Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat(network): PIN middleware — gate non-loopback API access when sharing on Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): remote PIN gate on 401 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(network): add qrcode dep for share QR Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tailscale): CLI status + serve enable/disable + endpoints Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b4f238aeb1
commit
fa1503c4eb
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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://<ip>:<port>`), 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://<machine>.<tailnet>.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.
|
||||
@@ -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/`<audio>`/`<img>` GETs (which can't send custom headers) authenticate automatically. Invalid/absent → `401`.
|
||||
|
||||
### 4.2 Tailscale (`backend/services/tailscale.py` + endpoints)
|
||||
|
||||
- `status() -> {installed, running, magic_dns_name, tailnet_ips}` — shell out to `tailscale status --json` (and `tailscale version`); all calls wrapped so a missing CLI degrades gracefully to `installed: false`.
|
||||
- `serve_enable(port) -> {url}` — `tailscale serve --bg --https=443 http://127.0.0.1:<port>` (proxies the **primary loopback** backend; no LAN listener and no model restart needed). Parse/return the `https://<machine>.<tailnet>.ts.net` URL.
|
||||
- `serve_disable()` — `tailscale serve reset` (or targeted `--https=443 off`).
|
||||
- Endpoints (loopback-only): `GET /system/tailscale/status`, `POST /system/tailscale/enable`, `POST /system/tailscale/disable`.
|
||||
|
||||
### 4.3 Frontend
|
||||
|
||||
- **`frontend/src/components/NetworkToggle.jsx`** (mounted in `LogsFooter.jsx`): a pill showing the current state (`● Local` / `● Network`) and a toggle.
|
||||
- Local→Network: confirm dialog ("Other devices on your network will be able to reach OmniVoice with the access PIN"), then `POST /system/network/enable`; a "switching…" state covers the call.
|
||||
- Network→Local: immediate (going safer), `POST /system/network/disable`.
|
||||
- Expandable panel when enabled: **every LAN address** with per-row **copy** + **open-in-browser**, a **QR** encoding `http://<ip>:<share_port>/?pin=<pin>`, and the **PIN** shown for manual entry.
|
||||
- **Visibility:** the toggle is shown when the backend is loopback-bound (the desktop app and local runs — `/system/info.bind_host == 127.0.0.1`). In a server deployment where the operator already bound the backend to `0.0.0.0` (`OMNIVOICE_BIND_HOST=0.0.0.0`), the toggle is hidden because exposure is already the operator's concern. The default Local behavior remains identical on every platform.
|
||||
- **Settings → "Sharing & Remote Access" panel** (`frontend/src/components/settings/SharingPanel.jsx`): the full surface — the LAN toggle (mirrors the footer), plus the **Tailscale** section (status, enable/disable, the `*.ts.net` URL with copy/open/QR, and an install link when the CLI is absent).
|
||||
- **Remote PIN gate** (`frontend/src/components/RemoteAuthGate.jsx`): when the SPA is loaded from a non-loopback origin and an API call returns `401`, show a PIN entry screen. On mount, read `?pin=` from the URL (populated by the QR) to auto-authenticate. The PIN is held in `sessionStorage` and attached by the api client.
|
||||
- **API client header injection:** the shared fetch wrapper (`frontend/src/api/client.*`) attaches `X-OmniVoice-Pin` from `sessionStorage` when present. Loopback (the desktop app) never sets it and never needs it.
|
||||
|
||||
## 5. Data flow
|
||||
|
||||
**Enable LAN share (desktop):** footer toggle → confirm → `POST /system/network/enable` (loopback) → backend starts `0.0.0.0:P+1` listener (same app), generates PIN → returns `{share_port, pin, lan_addresses}` → panel renders addresses + QR(`http://<ip>:<P+1>/?pin=<pin>`) + PIN.
|
||||
|
||||
**Remote device:** scans QR → loads SPA from `http://<ip>:<P+1>/?pin=<pin>` (SPA shell served PIN-free) → SPA reads `?pin=`, stores it, sends `X-OmniVoice-Pin` (and gets `ov_pin` cookie) → middleware validates against the same running backend → user sees the **same projects, voices, jobs, loaded model**.
|
||||
|
||||
**Tailscale:** Settings → enable → `POST /system/tailscale/enable` → `tailscale serve` proxies `127.0.0.1:P` → returns `https://<machine>.<tailnet>.ts.net` → user opens it from any tailnet device; requests arrive at the backend from loopback (via Tailscale's proxy) → PIN bypassed → identity enforced by Tailscale.
|
||||
|
||||
**Disable:** footer/Settings → `POST .../disable` → listener stops / `tailscale serve reset` → `0.0.0.0` closed; running model and jobs unaffected throughout.
|
||||
|
||||
## 6. Security model
|
||||
|
||||
- **Default is loopback-only, every launch.** Nothing is bound to `0.0.0.0` until the user explicitly enables it; no persisted auto-exposure.
|
||||
- **Control endpoints are loopback-only** via the existing `require_loopback` dependency on the `system` router (non-spoofable `request.client.host`, confirmed in the #157 security review) — a LAN client cannot enable sharing, read the PIN, or reach `/system/set-env` (which can set executable paths → would be RCE if exposed).
|
||||
- **`access_pin` is returned by the API only to loopback callers.**
|
||||
- **PIN gating is client-IP based:** loopback (incl. Tailscale-proxied) trusted; direct LAN requires PIN. Enforcement is inert unless a PIN is set → docker `0.0.0.0` deploys are unaffected (backward compatible).
|
||||
- **Tailscale path needs no open port and no PIN** — it proxies loopback and relies on tailnet identity + TLS.
|
||||
- The PIN is a session secret regenerated on each enable; it never persists to disk.
|
||||
|
||||
## 7. Error handling
|
||||
|
||||
- **Share enable fails** (no free port / bind error): return the error, stay Local, toast in the UI; never leave a half-open state.
|
||||
- **No LAN address found** (offline/no NIC): enable still succeeds but the panel shows "No reachable network interface — connect to Wi-Fi/Ethernet"; QR omitted.
|
||||
- **Tailscale CLI missing / not logged in:** Settings shows "Tailscale not detected" with an install/login link; enable is disabled.
|
||||
- **`tailscale serve` failure:** surface stderr to the user; leave LAN sharing independent (the two paths don't depend on each other).
|
||||
- **Disable is idempotent** and always safe (no-op if already off).
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **Backend (`tests/`):**
|
||||
- `network_share`: enable picks a free port and starts a listener; `get_state` reflects it; disable closes it; PIN is 6 digits; `lan_ipv4_addresses` filters loopback/link-local (mock `psutil.net_if_addrs`).
|
||||
- Middleware: pass-through when no PIN set; loopback bypass; non-loopback + no PIN → 401; non-loopback + valid PIN (header/query/cookie) → 200 and sets cookie; SPA shell + `/health` open; `access_pin` present only for loopback callers.
|
||||
- Control endpoints reject non-loopback callers with 403.
|
||||
- Tailscale module: parse `tailscale status --json` fixtures; graceful `installed:false` when CLI absent (mock subprocess).
|
||||
- **Frontend (Vitest):** `NetworkToggle` defaults to Local; disabled outside desktop context; panel renders addresses + QR + PIN when enabled; `RemoteAuthGate` shows on 401 and auto-fills from `?pin=`; api client attaches `X-OmniVoice-Pin` only when set.
|
||||
|
||||
## 9. Dependencies
|
||||
|
||||
- **Backend:** none new (`psutil`, `uvicorn`, `secrets` already present; `tailscale` is an external CLI invoked via subprocess, optional).
|
||||
- **Frontend:** one small QR library (`qrcode`), used by both the footer panel and the Tailscale URL display.
|
||||
|
||||
## 10. Cross-platform parity
|
||||
|
||||
- The **default (Local)** behavior is byte-for-byte identical on macOS / Windows / Linux — the strict default-parity rule is satisfied because exposure is **explicit opt-in** (footer toggle / Settings).
|
||||
- LAN sharing is pure Python/asyncio + psutil → identical across platforms.
|
||||
- Tailscale uses the same `tailscale` CLI on all three OSes and degrades gracefully where it isn't installed (platform-agnostic absence handling, not platform-divergent behavior).
|
||||
|
||||
## 11. Implementation order (for the plan)
|
||||
|
||||
1. Backend `network_share` module + control endpoints + `/system/info` fields (+ tests).
|
||||
2. `NetworkAccessMiddleware` + PIN cookie + SPA-shell allowlist (+ tests).
|
||||
3. Frontend api-client PIN header + `RemoteAuthGate` (+ tests).
|
||||
4. `NetworkToggle` footer component + panel + QR (+ tests).
|
||||
5. Tailscale module + endpoints (+ tests).
|
||||
6. Settings `SharingPanel` (LAN mirror + Tailscale) (+ tests).
|
||||
7. Docs: a "Sharing & remote access" page (LAN PIN/QR + Tailscale), and a comment on PR #125 explaining this supersedes the default-flip.
|
||||
@@ -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",
|
||||
|
||||
@@ -30,6 +30,7 @@ import Header from './components/Header';
|
||||
import NavRail from './components/NavRail';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import FloatingPill from './components/FloatingPill';
|
||||
import RemoteAuthGate from './components/RemoteAuthGate';
|
||||
|
||||
import useRealtimeEvents from './hooks/useRealtimeEvents';
|
||||
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
|
||||
@@ -821,6 +822,7 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<RemoteAuthGate>
|
||||
<div
|
||||
className={[
|
||||
'app-container',
|
||||
@@ -1142,6 +1144,7 @@ function App() {
|
||||
</Suspense>
|
||||
|
||||
</div>
|
||||
</RemoteAuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('apiFetch PIN header', () => {
|
||||
let realFetch: typeof globalThis.fetch;
|
||||
beforeEach(() => { realFetch = globalThis.fetch; sessionStorage.clear(); });
|
||||
afterEach(() => { globalThis.fetch = realFetch; sessionStorage.clear(); });
|
||||
|
||||
it('attaches X-OmniVoice-Pin when present in sessionStorage', async () => {
|
||||
sessionStorage.setItem('ov_pin', '424242');
|
||||
const seen: any = {};
|
||||
globalThis.fetch = vi.fn((_url, opts) => { Object.assign(seen, opts); return Promise.resolve({ ok: true, json: async () => ({}) }); }) as any;
|
||||
const { apiFetch } = await import('./client');
|
||||
await apiFetch('/system/info');
|
||||
expect((seen.headers || {})['X-OmniVoice-Pin']).toBe('424242');
|
||||
});
|
||||
|
||||
it('omits the header when no pin', async () => {
|
||||
const seen: any = {};
|
||||
globalThis.fetch = vi.fn((_url, opts) => { Object.assign(seen, opts); return Promise.resolve({ ok: true, json: async () => ({}) }); }) as any;
|
||||
const { apiFetch } = await import('./client');
|
||||
await apiFetch('/system/info');
|
||||
expect((seen.headers || {})['X-OmniVoice-Pin']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,16 @@ const _host =
|
||||
: (typeof window !== 'undefined' ? window.location.hostname : '127.0.0.1');
|
||||
export const API = viteEnv.VITE_API_URL || `http://${_host}:${_port}`;
|
||||
|
||||
// Capture a QR-supplied PIN once on load. When LAN sharing is on, the host's
|
||||
// QR code links to `http://<lan-ip>:<port>/?pin=<pin>`; stash it in
|
||||
// sessionStorage so apiFetch attaches it to every request automatically.
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const p = new URL(window.location.href).searchParams.get('pin');
|
||||
if (p) sessionStorage.setItem('ov_pin', p);
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
detail?: unknown;
|
||||
@@ -37,8 +47,19 @@ async function readError(res: Response): Promise<string> {
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const res = await fetch(apiUrl(path), opts);
|
||||
const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
|
||||
// Only modify the request when a PIN is set, so the default call shape
|
||||
// (e.g. FormData posts with no headers / no Content-Type override) is
|
||||
// preserved exactly.
|
||||
const finalOpts: RequestInit = pin
|
||||
? { ...opts, headers: { ...(opts.headers as Record<string, string> || {}), 'X-OmniVoice-Pin': pin } }
|
||||
: opts;
|
||||
const res = await fetch(apiUrl(path), finalOpts);
|
||||
if (!res.ok) {
|
||||
// 401 from the LAN PIN middleware on a remote device → surface the gate.
|
||||
if (res.status === 401 && typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('ov:pin-required'));
|
||||
}
|
||||
const detail = await readError(res);
|
||||
throw new ApiError(`${res.status} ${res.statusText}: ${detail}`, { status: res.status, detail });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
|
||||
import { clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSystemLogs, useTauriLogs, useClearLogs, useClearTauriLogs } from '../api/hooks';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
import './LogsFooter.css';
|
||||
|
||||
/**
|
||||
@@ -387,6 +388,7 @@ export default function LogsFooter() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<NetworkToggle />
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__discord"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Footer Local/Network share toggle. Matches the logs-footer pill chrome
|
||||
(gruvbox palette, 20px pill height); the "Network" active state uses the
|
||||
accent green #b8bb26. The panel is an upward-opening dropdown card. */
|
||||
|
||||
.net-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Pill ─────────────────────────────────────────────────────────────── */
|
||||
.net-toggle__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: #a89984;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.net-toggle__pill:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #ebdbb2;
|
||||
}
|
||||
.net-toggle__pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.net-toggle__pill--on {
|
||||
background: rgba(184, 187, 38, 0.12);
|
||||
border-color: rgba(184, 187, 38, 0.4);
|
||||
color: #b8bb26;
|
||||
}
|
||||
.net-toggle__pill--on:hover {
|
||||
background: rgba(184, 187, 38, 0.18);
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
/* ── Dropdown panel (opens upward, since the footer is pinned to bottom) ── */
|
||||
.net-toggle__panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: 248px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--chrome-bg, #1d2021);
|
||||
border: 1px solid rgba(184, 187, 38, 0.35);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
color: #ebdbb2;
|
||||
}
|
||||
|
||||
.net-toggle__panel-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
.net-toggle__hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #a89984;
|
||||
}
|
||||
|
||||
/* ── Address row ──────────────────────────────────────────────────────── */
|
||||
.net-toggle__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.net-toggle__row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.net-toggle__addr {
|
||||
font-family: var(--chrome-font-mono, var(--font-mono, monospace));
|
||||
font-size: 11.5px;
|
||||
color: #ebdbb2;
|
||||
word-break: break-all;
|
||||
}
|
||||
.net-toggle__row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.net-toggle__iconbtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a89984;
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.net-toggle__iconbtn:hover {
|
||||
color: #b8bb26;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.net-toggle__qr {
|
||||
display: block;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ── PIN + stop ───────────────────────────────────────────────────────── */
|
||||
.net-toggle__pin {
|
||||
font-size: 12px;
|
||||
color: #a89984;
|
||||
text-align: center;
|
||||
}
|
||||
.net-toggle__pin strong {
|
||||
font-family: var(--chrome-font-mono, var(--font-mono, monospace));
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.12em;
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
.net-toggle__off {
|
||||
background: rgba(251, 73, 52, 0.12);
|
||||
border: 1px solid rgba(251, 73, 52, 0.35);
|
||||
color: #fb4934;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.net-toggle__off:hover {
|
||||
background: rgba(251, 73, 52, 0.2);
|
||||
}
|
||||
.net-toggle__off:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// frontend/src/components/NetworkToggle.jsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Wifi, WifiOff, Copy, ExternalLink } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiJson, apiPost } from '../api/client';
|
||||
import { openExternal } from '../api/external';
|
||||
import './NetworkToggle.css';
|
||||
|
||||
export default function NetworkToggle() {
|
||||
const [st, setSt] = useState({ enabled: false });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [qrs, setQrs] = useState({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try { setSt(await apiJson('/system/network/state')); } catch { /* loopback only; ignore */ }
|
||||
}, []);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!st.enabled || !st.pin) { setQrs({}); return; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const next = {};
|
||||
for (const ip of st.lan_addresses || []) {
|
||||
next[ip] = await QRCode.toDataURL(`http://${ip}:${st.share_port}/?pin=${st.pin}`);
|
||||
}
|
||||
if (!cancelled) setQrs(next);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [st.enabled, st.pin, st.share_port, st.lan_addresses]);
|
||||
|
||||
const enable = async () => {
|
||||
if (!window.confirm('Share OmniVoice on your local network? Other devices will be able to reach it with the access PIN.')) return;
|
||||
setBusy(true);
|
||||
try { setSt(await apiPost('/system/network/enable')); setOpen(true); }
|
||||
catch (e) { toast.error(`Could not enable sharing: ${e.message}`); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
try { await apiPost('/system/network/disable'); await refresh(); setOpen(false); }
|
||||
catch (e) { toast.error(`Could not disable: ${e.message}`); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const copy = (text) => { navigator.clipboard?.writeText(text); toast.success('Copied'); };
|
||||
|
||||
return (
|
||||
<div className="net-toggle">
|
||||
<button
|
||||
className={`net-toggle__pill ${st.enabled ? 'net-toggle__pill--on' : ''}`}
|
||||
onClick={st.enabled ? () => setOpen((o) => !o) : enable}
|
||||
disabled={busy}
|
||||
title={st.enabled ? 'Sharing on — click for details' : 'Share on your network'}
|
||||
>
|
||||
{st.enabled ? <Wifi size={12} /> : <WifiOff size={12} />}
|
||||
<span>{busy ? 'Switching…' : st.enabled ? 'Network' : 'Local'}</span>
|
||||
</button>
|
||||
|
||||
{st.enabled && open && (
|
||||
<div className="net-toggle__panel">
|
||||
<div className="net-toggle__panel-title">Shared on your network</div>
|
||||
{(st.lan_addresses || []).length === 0 && (
|
||||
<p className="net-toggle__hint">No reachable network interface — connect to Wi-Fi/Ethernet.</p>
|
||||
)}
|
||||
{(st.lan_addresses || []).map((ip) => {
|
||||
const url = `http://${ip}:${st.share_port}/?pin=${st.pin}`;
|
||||
return (
|
||||
<div key={ip} className="net-toggle__row">
|
||||
<div className="net-toggle__row-main">
|
||||
<code className="net-toggle__addr">{ip}:{st.share_port}</code>
|
||||
<div className="net-toggle__row-actions">
|
||||
<button type="button" className="net-toggle__iconbtn" onClick={() => copy(url)} aria-label={`Copy ${ip}`} title="Copy link"><Copy size={12} /></button>
|
||||
<button type="button" className="net-toggle__iconbtn" onClick={() => openExternal(url)} aria-label={`Open ${ip}`} title="Open in browser"><ExternalLink size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{qrs[ip] && <img className="net-toggle__qr" src={qrs[ip]} alt={`QR for ${ip}`} width={104} height={104} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="net-toggle__pin">PIN: <strong>{st.pin}</strong></div>
|
||||
<button type="button" className="net-toggle__off" onClick={disable} disabled={busy}>Stop sharing</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// frontend/src/components/NetworkToggle.test.jsx
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
|
||||
describe('NetworkToggle', () => {
|
||||
let realFetch;
|
||||
beforeEach(() => { realFetch = global.fetch; });
|
||||
afterEach(() => { global.fetch = realFetch; });
|
||||
|
||||
it('defaults to Local when state reports disabled', async () => {
|
||||
global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: async () => ({ enabled: false }) }));
|
||||
render(<NetworkToggle />);
|
||||
await waitFor(() => expect(screen.getByText(/local/i)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// When LAN sharing is on, a non-loopback request that lacks the PIN gets a 401
|
||||
// from the backend middleware. `client.ts` dispatches `ov:pin-required` on that
|
||||
// 401; this gate listens for it and swaps the app tree for a PIN entry form.
|
||||
// `forceGate` is test-only. Submitting stores the PIN in sessionStorage (read
|
||||
// by apiFetch on every subsequent request) and reloads so the gated requests
|
||||
// retry with the header attached.
|
||||
export default function RemoteAuthGate({ children, forceGate = false }) {
|
||||
const [gated, setGated] = useState(forceGate);
|
||||
const [pin, setPin] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const onRequired = () => setGated(true);
|
||||
window.addEventListener('ov:pin-required', onRequired);
|
||||
return () => window.removeEventListener('ov:pin-required', onRequired);
|
||||
}, []);
|
||||
|
||||
if (!gated) return children;
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
const v = pin.trim();
|
||||
if (!v) return;
|
||||
sessionStorage.setItem('ov_pin', v);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="remote-auth-gate" role="dialog" aria-modal="true">
|
||||
<form onSubmit={submit} className="remote-auth-gate__card">
|
||||
<h2>Enter access PIN</h2>
|
||||
<p>This OmniVoice instance is shared on the network. Enter the PIN shown on the host.</p>
|
||||
<label htmlFor="ov-pin">Access PIN</label>
|
||||
<input id="ov-pin" inputMode="numeric" value={pin} onChange={(e) => setPin(e.target.value)} autoFocus />
|
||||
<button type="submit">Connect</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RemoteAuthGate from './RemoteAuthGate';
|
||||
|
||||
describe('RemoteAuthGate', () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => sessionStorage.clear());
|
||||
|
||||
it('renders children when not gated', () => {
|
||||
render(<RemoteAuthGate><div>app-content</div></RemoteAuthGate>);
|
||||
expect(screen.getByText('app-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stores the entered PIN', () => {
|
||||
render(<RemoteAuthGate forceGate><div>app-content</div></RemoteAuthGate>);
|
||||
fireEvent.change(screen.getByLabelText(/access pin/i), { target: { value: '999111' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /connect/i }));
|
||||
expect(sessionStorage.getItem('ov_pin')).toBe('999111');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
.sharingpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border, #3c3836);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
.sharingpanel__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sharingpanel__help {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.sharingpanel__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border, #3c3836);
|
||||
}
|
||||
.sharingpanel__subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.sharingpanel__subhelp {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.sharingpanel__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sharingpanel__addr {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
border: 1px solid var(--border, #504945);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg, rgba(0, 0, 0, 0.2));
|
||||
color: inherit;
|
||||
word-break: break-all;
|
||||
}
|
||||
.sharingpanel__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border, #504945);
|
||||
border-radius: 6px;
|
||||
background: var(--accent, #83a598);
|
||||
color: #1d2021;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sharingpanel__btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.sharingpanel__btn--ghost { background: transparent; color: inherit; }
|
||||
.sharingpanel__iconbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border, #504945);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sharingpanel__iconbtn:hover { background: var(--input-bg, rgba(0, 0, 0, 0.2)); }
|
||||
.sharingpanel__tailscale-absent,
|
||||
.sharingpanel__tailscale-present,
|
||||
.sharingpanel__tailscale-url {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sharingpanel__qr {
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Settings → Sharing & Remote Access panel.
|
||||
*
|
||||
* Surfaces the two ways to reach this running backend from another machine,
|
||||
* without restarting it:
|
||||
* - LAN sharing (PIN + QR) — reuses the footer <NetworkToggle/> control so
|
||||
* there is a single source of truth for the /system/network/* endpoints.
|
||||
* - Tailscale private remote access — drives the loopback-only
|
||||
* /system/tailscale/{status,enable,disable} endpoints.
|
||||
*
|
||||
* Loopback-only stays the default; nothing here changes that until the user
|
||||
* explicitly enables a share.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /system/tailscale/status → {installed, running, magic_dns_name, tailnet_ips}
|
||||
* POST /system/tailscale/enable → {ok, url} | {ok:false, error}
|
||||
* POST /system/tailscale/disable → {ok}
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Wifi, Globe, Copy, ExternalLink } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiJson, apiPost } from '../../api/client';
|
||||
import { openExternal } from '../../api/external';
|
||||
import NetworkToggle from '../NetworkToggle';
|
||||
import './SharingPanel.css';
|
||||
|
||||
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download';
|
||||
|
||||
export default function SharingPanel() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [qr, setQr] = useState('');
|
||||
|
||||
const refresh = useCallback(async (signal) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const s = await apiJson('/system/tailscale/status');
|
||||
if (signal?.aborted) return;
|
||||
setStatus(s);
|
||||
} catch {
|
||||
// Loopback-only control surface; if it can't be reached, treat as absent.
|
||||
if (!signal?.aborted) setStatus({ installed: false });
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch Tailscale status once on mount; cancel-safe.
|
||||
useEffect(() => {
|
||||
const ctrl = { aborted: false };
|
||||
refresh(ctrl);
|
||||
return () => { ctrl.aborted = true; };
|
||||
}, [refresh]);
|
||||
|
||||
// Render a QR for the Tailscale URL when one is available; cancel-safe.
|
||||
useEffect(() => {
|
||||
if (!url) { setQr(''); return; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await QRCode.toDataURL(url);
|
||||
if (!cancelled) setQr(data);
|
||||
} catch {
|
||||
if (!cancelled) setQr('');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
const enable = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiPost('/system/tailscale/enable');
|
||||
if (r?.ok) {
|
||||
setUrl(r.url || '');
|
||||
toast.success('Tailscale serve enabled');
|
||||
await refresh();
|
||||
} else {
|
||||
toast.error(r?.error || 'Could not enable Tailscale');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`Could not enable Tailscale: ${e.message}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiPost('/system/tailscale/disable');
|
||||
if (r && r.ok === false) {
|
||||
toast.error(r.error || 'Could not disable Tailscale');
|
||||
} else {
|
||||
setUrl('');
|
||||
toast.success('Tailscale serve disabled');
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(`Could not disable Tailscale: ${e.message}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (text) => { navigator.clipboard?.writeText(text); toast.success('Copied'); };
|
||||
|
||||
const installed = !!status?.installed;
|
||||
const running = !!status?.running;
|
||||
|
||||
return (
|
||||
<section className="sharingpanel" aria-labelledby="sharingpanel-heading">
|
||||
<h3 id="sharingpanel-heading" className="sharingpanel__title">
|
||||
<Wifi size={14} /> Sharing & Remote Access
|
||||
</h3>
|
||||
|
||||
<p className="sharingpanel__help">
|
||||
Expose this running OmniVoice instance to your other machines without
|
||||
restarting it. Loopback-only is the default — nothing is shared until
|
||||
you turn it on here.
|
||||
</p>
|
||||
|
||||
{/* ── LAN sharing ──────────────────────────────────────────────── */}
|
||||
<div className="sharingpanel__section" data-testid="sharing-lan">
|
||||
<h4 className="sharingpanel__subtitle">
|
||||
<Wifi size={12} /> Local network
|
||||
</h4>
|
||||
<p className="sharingpanel__subhelp">
|
||||
Share on your Wi-Fi / Ethernet with a one-time access PIN. Other
|
||||
devices scan the QR code or open the link.
|
||||
</p>
|
||||
<NetworkToggle />
|
||||
</div>
|
||||
|
||||
{/* ── Tailscale ────────────────────────────────────────────────── */}
|
||||
<div className="sharingpanel__section" data-testid="sharing-tailscale">
|
||||
<h4 className="sharingpanel__subtitle">
|
||||
<Globe size={12} /> Tailscale (private remote access)
|
||||
</h4>
|
||||
|
||||
{loading && !status && (
|
||||
<p className="sharingpanel__subhelp">Checking for Tailscale…</p>
|
||||
)}
|
||||
|
||||
{status && !installed && (
|
||||
<div className="sharingpanel__tailscale-absent" data-testid="tailscale-absent">
|
||||
<p className="sharingpanel__subhelp">
|
||||
Tailscale not detected. Install it to reach OmniVoice securely
|
||||
from anywhere on your private tailnet.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="sharingpanel__btn"
|
||||
onClick={() => openExternal(TAILSCALE_DOWNLOAD_URL)}
|
||||
data-testid="tailscale-install"
|
||||
>
|
||||
<ExternalLink size={12} /> Install Tailscale
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && installed && (
|
||||
<div className="sharingpanel__tailscale-present">
|
||||
<p className="sharingpanel__subhelp">
|
||||
{running
|
||||
? 'Tailscale is running. Serve OmniVoice over your private tailnet.'
|
||||
: 'Tailscale is installed but not logged in. Start and sign in to Tailscale first.'}
|
||||
</p>
|
||||
|
||||
{!url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sharingpanel__btn"
|
||||
onClick={enable}
|
||||
disabled={busy}
|
||||
data-testid="tailscale-enable"
|
||||
>
|
||||
{busy ? 'Enabling…' : 'Enable Tailscale serve'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="sharingpanel__tailscale-url">
|
||||
<div className="sharingpanel__row">
|
||||
<code className="sharingpanel__addr">{url}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="sharingpanel__iconbtn"
|
||||
onClick={() => copy(url)}
|
||||
aria-label="Copy Tailscale URL"
|
||||
title="Copy link"
|
||||
data-testid="tailscale-copy"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sharingpanel__iconbtn"
|
||||
onClick={() => openExternal(url)}
|
||||
aria-label="Open Tailscale URL"
|
||||
title="Open in browser"
|
||||
data-testid="tailscale-open"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{qr && (
|
||||
<img
|
||||
className="sharingpanel__qr"
|
||||
src={qr}
|
||||
alt="QR code for the Tailscale URL"
|
||||
width={104}
|
||||
height={104}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sharingpanel__btn sharingpanel__btn--ghost"
|
||||
onClick={disable}
|
||||
disabled={busy}
|
||||
data-testid="tailscale-disable"
|
||||
>
|
||||
{busy ? 'Disabling…' : 'Stop Tailscale serve'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import SharingPanel from './SharingPanel';
|
||||
|
||||
describe('SharingPanel', () => {
|
||||
let realFetch;
|
||||
beforeEach(() => { realFetch = global.fetch; });
|
||||
afterEach(() => { global.fetch = realFetch; });
|
||||
|
||||
it('shows Tailscale "not detected" when CLI absent', async () => {
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('tailscale/status')) return Promise.resolve({ ok: true, json: async () => ({ installed: false }) });
|
||||
return Promise.resolve({ ok: true, json: async () => ({ enabled: false }) });
|
||||
});
|
||||
render(<SharingPanel />);
|
||||
// Both the explanatory copy and the install button surface the phrase, so
|
||||
// assert at least one node renders rather than requiring a unique match.
|
||||
await waitFor(() => expect(screen.getAllByText(/not detected|install tailscale/i).length).toBeGreaterThan(0));
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,7 @@
|
||||
"language_desc": "Select the interface language",
|
||||
"engines": "Engines",
|
||||
"capture": "Capture",
|
||||
"sharing": "Sharing",
|
||||
"credentials": "Credentials",
|
||||
"proxy": "Proxy",
|
||||
"proxy_desc": "HTTP/SOCKS5 proxy for downloads (yt-dlp, HuggingFace). Supports http://, https://, socks5://. Restart required if changed after backend start.",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
|
||||
CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound,
|
||||
Keyboard,
|
||||
Keyboard, Wifi,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { openExternal } from '../api/external';
|
||||
@@ -29,6 +29,7 @@ import ApiKeysPanel from '../components/settings/ApiKeysPanel';
|
||||
import PerformancePanel from '../components/settings/PerformancePanel';
|
||||
import AppearancePanel from '../components/settings/AppearancePanel';
|
||||
import StoragePanel from '../components/settings/StoragePanel';
|
||||
import SharingPanel from '../components/settings/SharingPanel';
|
||||
import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix';
|
||||
import DictationDemo from '../components/DictationDemo';
|
||||
import ReportBugButton from '../components/ReportBugButton';
|
||||
@@ -39,6 +40,7 @@ const TAB_DEFS = [
|
||||
{ id: 'models', icon: Cpu, accent: '#f3a5b6' },
|
||||
{ id: 'engines', icon: Plug, accent: '#d3869b' },
|
||||
{ id: 'capture', icon: Keyboard, accent: '#83a598' },
|
||||
{ id: 'sharing', icon: Wifi, accent: '#83a598' },
|
||||
{ id: 'credentials', icon: KeyRound, accent: '#fe8019' },
|
||||
{ id: 'logs', icon: FileText, accent: '#fabd2f' },
|
||||
{ id: 'about', icon: Info, accent: '#8ec07c' },
|
||||
@@ -1252,6 +1254,8 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'sharing' && <SharingPanel />}
|
||||
|
||||
{activeTab === 'credentials' && <CredentialsTab info={info} />}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# tests/test_network_middleware.py
|
||||
from fastapi.testclient import TestClient
|
||||
from services import network_share as ns
|
||||
|
||||
|
||||
def _app_with_pin(pin="123456"):
|
||||
from main import app
|
||||
app.state.network_share = ns.ShareState(enabled=True, share_port=3901, pin=pin, lan_addresses=["10.0.0.9"])
|
||||
return app
|
||||
|
||||
|
||||
def teardown_function():
|
||||
from main import app
|
||||
app.state.network_share = ns.ShareState() # reset → middleware inert
|
||||
|
||||
|
||||
def test_inert_when_no_pin():
|
||||
from main import app
|
||||
app.state.network_share = ns.ShareState() # no pin
|
||||
c = TestClient(app, client=("10.0.0.5", 1)) # non-loopback
|
||||
assert c.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_loopback_bypasses_pin():
|
||||
c = TestClient(_app_with_pin(), client=("127.0.0.1", 1))
|
||||
assert c.get("/system/info").status_code == 200 # loopback → ok
|
||||
|
||||
|
||||
def test_non_loopback_without_pin_401_on_api():
|
||||
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
|
||||
r = c.get("/api/voices") # any non-shell API path
|
||||
assert r.status_code in (401,) # PIN required
|
||||
|
||||
|
||||
def test_non_loopback_with_valid_pin_passes():
|
||||
c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
|
||||
r = c.get("/api/voices", headers={"X-OmniVoice-Pin": "654321"})
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_spa_shell_served_without_pin():
|
||||
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
|
||||
assert c.get("/health").status_code == 200
|
||||
@@ -0,0 +1,54 @@
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from services import network_share as ns
|
||||
|
||||
|
||||
def _addr(ip):
|
||||
class A: # mimic psutil snicaddr
|
||||
family = socket.AF_INET
|
||||
address = ip
|
||||
return A()
|
||||
|
||||
|
||||
def test_lan_ipv4_filters_loopback_and_linklocal():
|
||||
fake = {
|
||||
"lo0": [_addr("127.0.0.1")],
|
||||
"en0": [_addr("192.168.1.42")],
|
||||
"en1": [_addr("169.254.5.5"), _addr("10.0.0.9")],
|
||||
}
|
||||
with patch("services.network_share.psutil.net_if_addrs", return_value=fake):
|
||||
out = ns.lan_ipv4_addresses()
|
||||
assert out == ["192.168.1.42", "10.0.0.9"]
|
||||
|
||||
|
||||
def test_gen_pin_is_six_digits():
|
||||
pin = ns._gen_pin()
|
||||
assert pin.isdigit() and len(pin) == 6
|
||||
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _loopback_client():
|
||||
from main import app
|
||||
return TestClient(app, client=("127.0.0.1", 50000))
|
||||
|
||||
|
||||
def test_network_state_endpoint_defaults_disabled():
|
||||
c = _loopback_client()
|
||||
r = c.get("/system/network/state")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["enabled"] is False
|
||||
|
||||
|
||||
def test_network_control_rejects_non_loopback():
|
||||
from main import app
|
||||
c = TestClient(app, client=("10.0.0.5", 9999))
|
||||
assert c.post("/system/network/enable").status_code == 403
|
||||
|
||||
|
||||
def test_system_info_has_sharing_fields():
|
||||
c = _loopback_client()
|
||||
body = c.get("/system/info").json()
|
||||
for k in ("share_enabled", "share_port", "lan_addresses", "pin_required"):
|
||||
assert k in body
|
||||
@@ -92,6 +92,24 @@ def _is_allowed(rel: str) -> bool:
|
||||
|
||||
|
||||
def _iter_source_files():
|
||||
# Scan only git-TRACKED files: the rule governs the committed codebase,
|
||||
# not local untracked/vendored experiments (which also never reach CI).
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "ls-files", "-z"],
|
||||
cwd=str(_REPO), capture_output=True, text=True, timeout=30, check=True,
|
||||
).stdout
|
||||
names = [n for n in out.split("\0") if n]
|
||||
if names:
|
||||
for n in names:
|
||||
if os.path.splitext(n)[1].lower() in _SKIP_EXT:
|
||||
continue
|
||||
yield _REPO / n
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback (not a git checkout): filesystem walk.
|
||||
for dirpath, dirnames, filenames in os.walk(_REPO):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
||||
for name in filenames:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# tests/test_tailscale_service.py
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
from services import tailscale as ts
|
||||
|
||||
|
||||
def test_status_absent_cli_is_graceful():
|
||||
with patch("services.tailscale.shutil.which", return_value=None):
|
||||
s = ts.status()
|
||||
assert s["installed"] is False and s["running"] is False
|
||||
|
||||
|
||||
def test_status_parses_json():
|
||||
payload = {"BackendState": "Running", "Self": {"DNSName": "box.tail1234.ts.net.", "TailscaleIPs": ["100.64.0.1"]}}
|
||||
with patch("services.tailscale.shutil.which", return_value="/usr/bin/tailscale"), \
|
||||
patch("services.tailscale.subprocess.run", return_value=MagicMock(returncode=0, stdout=json.dumps(payload))):
|
||||
s = ts.status()
|
||||
assert s["installed"] and s["running"]
|
||||
assert s["magic_dns_name"] == "box.tail1234.ts.net"
|
||||
assert s["tailnet_ips"] == ["100.64.0.1"]
|
||||
Reference in New Issue
Block a user