Files
Mark-LIV/dashboard/server.py
T
2026-09-16 18:48:54 +03:00

885 lines
38 KiB
Python

"""
dashboard/server.py — JARVIS Local HTTP Dashboard
Plain HTTP on port 8000 (no SSL warnings, no firewall issues).
Security at the application layer: AES-256-CBC with session-key-derived key.
CryptoJS is auto-downloaded once and served locally — no CDN needed after that.
Install deps: pip install fastapi "uvicorn[standard]" cryptography
"""
import asyncio
import base64
import hashlib
import re
import secrets
import socket
import string
import time
from pathlib import Path
_DEPS_OK = False
try:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
import uvicorn
_DEPS_OK = True
except ImportError:
pass
# python-multipart is required for file uploads — optional dependency
_UPLOAD_OK = False
try:
from fastapi import UploadFile, File as FastAPIFile
_UPLOAD_OK = True
except Exception:
pass
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = Path(__file__).parent / "static"
PORT = 8000
MAX_UPLOAD_MB = 500
def _make_uploads_dir() -> Path:
"""Return (and create) the cross-platform uploads folder."""
for candidate in [
Path.home() / "Downloads" / "JARVIS Uploads",
Path.home() / "Documents" / "JARVIS Uploads",
BASE_DIR / "uploads",
]:
try:
candidate.mkdir(parents=True, exist_ok=True)
return candidate
except Exception:
pass
return BASE_DIR / "uploads"
UPLOADS_DIR = _make_uploads_dir()
def _get_gemini_key() -> str | None:
try:
import json as _json
with open(BASE_DIR / "config" / "api_keys.json", "r", encoding="utf-8") as f:
return _json.load(f).get("gemini_api_key")
except Exception:
return None
_KEY_CHARS = [c for c in (string.ascii_uppercase + string.digits)
if c not in ('O', 'I', 'L', '0', '1')]
# ── AES-256-CBC ───────────────────────────────────────────────────────────────
_AES_SALT = b'JARVIS-DASHBOARD-v1'
def _derive_key(session_key: str) -> bytes:
"""SHA-256(sessionKey‖salt) → 32-byte AES-256 key (microseconds, no PBKDF2 needed)."""
return hashlib.sha256(session_key.encode('utf-8') + _AES_SALT).digest()
def _decrypt_cbc(aes_key: bytes, enc_b64: str) -> str:
"""Decrypt base64(IV[16] ‖ ciphertext) with AES-256-CBC + PKCS7."""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as sym_pad
raw = base64.b64decode(enc_b64)
iv, ct = raw[:16], raw[16:]
dec = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor()
padded = dec.update(ct) + dec.finalize()
unpadder = sym_pad.PKCS7(128).unpadder()
return (unpadder.update(padded) + unpadder.finalize()).decode('utf-8')
# ── CryptoJS (auto-download once, served locally) ─────────────────────────────
_CRYPTOJS_CDN = ("https://cdnjs.cloudflare.com/ajax/libs/"
"crypto-js/4.2.0/crypto-js.min.js")
_CRYPTOJS_FILE = STATIC_DIR / "crypto-js.min.js"
def _ensure_network_access(port: int) -> None:
"""Cross-platform, best-effort: open port in the OS firewall for LAN access.
Runs in a background thread — never blocks uvicorn startup.
Windows : writes a .bat file, runs it elevated via Windows ShellExecuteW
(native UAC dialog, guaranteed to appear). One-time setup.
macOS : osascript admin dialog if the Application Firewall is on.
Linux : pkexec GUI → sudo -n → prints manual command as fallback.
"""
import sys, subprocess, os, tempfile, threading
# ── Windows ──────────────────────────────────────────────────────────────
if sys.platform == "win32":
import ctypes, time
port_rule = f"JARVIS Dashboard Port {port}"
prog_rule = "JARVIS Dashboard Python"
py_exe = sys.executable
def _netsh_rule_exists(name: str) -> bool:
try:
r = subprocess.run(
["netsh", "advfirewall", "firewall", "show", "rule", f"name={name}"],
capture_output=True, text=True, timeout=5,
)
return r.returncode == 0 and "No rules match" not in r.stdout
except Exception:
return False
def _network_is_public() -> bool:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"(Get-NetConnectionProfile | "
"Where-Object {$_.NetworkCategory -eq 'Public'} | "
"Measure-Object).Count"],
capture_output=True, text=True, timeout=6,
)
return r.stdout.strip() not in ("", "0")
except Exception:
return False
need_port = not _netsh_rule_exists(port_rule)
need_prog = not _netsh_rule_exists(prog_rule)
need_private = _network_is_public()
if not need_port and not need_prog and not need_private:
return # already fully configured
# Build a .bat file — netsh + powershell, runs fast when elevated
bat_lines = ["@echo off"]
if need_private:
bat_lines.append(
'powershell -NoProfile -NonInteractive -Command "'
'Get-NetConnectionProfile | '
"Where-Object {$_.NetworkCategory -eq 'Public'} | "
'Set-NetConnectionProfile -NetworkCategory Private"'
)
if need_port:
bat_lines.append(
f'netsh advfirewall firewall add rule '
f'name="{port_rule}" protocol=TCP dir=in '
f'localport={port} action=allow'
)
if need_prog:
bat_lines.append(
f'netsh advfirewall firewall add rule '
f'name="{prog_rule}" dir=in action=allow '
f'program="{py_exe}" enable=yes'
)
bat_body = "\r\n".join(bat_lines) + "\r\n"
fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="jarvis_fw_")
try:
os.write(fd, bat_body.encode("mbcs")) # Windows cmd.exe expects ANSI
os.close(fd)
except Exception:
try:
os.close(fd)
except Exception:
pass
return
# ── Try running directly (succeeds when already admin) ────────────────
try:
r = subprocess.run(
[bat_path], capture_output=True, timeout=8, shell=True
)
if r.returncode == 0:
print(f"[Dashboard] Firewall configured for port {port}.")
try:
os.unlink(bat_path)
except Exception:
pass
return
except Exception:
pass
# ── ShellExecuteW: native UAC elevation (most reliable on Windows) ────
# ShellExecuteW with verb "runas" always shows the UAC dialog regardless
# of UAC level settings. Non-blocking — uvicorn is already running.
print("[Dashboard] One-time network setup required.")
print("[Dashboard] >>> A Windows security dialog will appear — click 'Yes' <<<")
try:
ret = ctypes.windll.shell32.ShellExecuteW(
None, # hwnd (no parent window)
"runas", # verb (request elevation)
bat_path, # file (our .bat)
None, # params
None, # working dir
0, # SW_HIDE (run without a visible cmd window)
)
if int(ret) > 32:
# ShellExecuteW returns immediately; bat finishes in ~1 second.
# Sleep briefly so the rules are in place before the first retry.
time.sleep(2)
print(f"[Dashboard] Network setup complete — port {port} is open.")
print("[Dashboard] Refresh your phone browser to connect.")
else:
print("[Dashboard] Setup was not allowed.")
print("[Dashboard] Phone connections may fail until JARVIS is run as Administrator.")
except Exception as e:
print(f"[Dashboard] Firewall setup error: {e}")
finally:
# Cleanup after the bat has had time to run
def _cleanup(path: str) -> None:
time.sleep(5)
try:
os.unlink(path)
except Exception:
pass
threading.Thread(target=_cleanup, args=(bat_path,), daemon=True).start()
return
# ── macOS ─────────────────────────────────────────────────────────────────
if sys.platform == "darwin":
fw_ctl = "/usr/libexec/ApplicationFirewall/socketfilterfw"
try:
r = subprocess.run(
[fw_ctl, "--getglobalstate"], capture_output=True, text=True, timeout=5,
)
if "disabled" in r.stdout.lower():
return # firewall off — nothing to do
py = sys.executable
listed = subprocess.run(
[fw_ctl, "--listapps"], capture_output=True, text=True, timeout=5,
)
if py in listed.stdout:
return # already allowed
print("[Dashboard] One-time network setup — enter your password in the macOS dialog.")
subprocess.run(
["osascript", "-e",
f'do shell script "{fw_ctl} --add {py} && {fw_ctl} --unblockapp {py}"'
f' with administrator privileges'],
timeout=60,
)
except Exception:
pass # macOS firewall is off by default — silent failure is fine
return
# ── Linux ─────────────────────────────────────────────────────────────────
def _privileged(cmd: list[str]) -> bool:
for prefix in (["pkexec"], ["sudo", "-n"]):
try:
r = subprocess.run(prefix + cmd, capture_output=True, timeout=30)
if r.returncode == 0:
return True
except Exception:
pass
return False
try: # ufw
r = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5)
if "active" in r.stdout.lower():
if _privileged(["ufw", "allow", f"{port}/tcp"]):
print(f"[Dashboard] ufw: port {port} allowed.")
else:
print(f"[Dashboard] Run manually: sudo ufw allow {port}/tcp")
return
except FileNotFoundError:
pass
try: # firewalld
r = subprocess.run(
["firewall-cmd", "--state"], capture_output=True, text=True, timeout=5,
)
if "running" in r.stdout.lower():
ok = (_privileged(["firewall-cmd", "--add-port", f"{port}/tcp", "--permanent"])
and _privileged(["firewall-cmd", "--reload"]))
if ok:
print(f"[Dashboard] firewalld: port {port} allowed.")
else:
print(f"[Dashboard] Run manually: sudo firewall-cmd --add-port={port}/tcp --permanent && sudo firewall-cmd --reload")
return
except FileNotFoundError:
pass
try: # iptables (not persistent but works until reboot)
r = subprocess.run(["iptables", "-L", "INPUT", "-n"], capture_output=True, timeout=5)
if r.returncode == 0:
if _privileged(["iptables", "-A", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]):
print(f"[Dashboard] iptables: port {port} opened.")
else:
print(f"[Dashboard] Run manually: sudo iptables -A INPUT -p tcp --dport {port} -j ACCEPT")
except FileNotFoundError:
pass # no iptables means firewall is probably off — nothing to do
def _ensure_crypto_js() -> None:
if _CRYPTOJS_FILE.exists():
return
try:
import urllib.request
print("[Dashboard] Downloading CryptoJS (one-time setup)…")
urllib.request.urlretrieve(_CRYPTOJS_CDN, str(_CRYPTOJS_FILE))
print("[Dashboard] CryptoJS cached — will serve locally from now on.")
except Exception as e:
print(f"[Dashboard] CryptoJS download failed: {e}")
print(f"[Dashboard] Encryption will fall back to CDN load on client.")
_ensure_crypto_js()
# ── helpers ───────────────────────────────────────────────────────────────────
def _local_ip() -> str:
"""Return the best LAN-facing IPv4 address, no internet required."""
# Method 1: route trick (fast, works when internet is available)
for probe in ("8.8.8.8", "1.1.1.1", "192.168.1.1"):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
s.connect((probe, 80))
ip = s.getsockname()[0]
s.close()
if not ip.startswith("127."):
return ip
except Exception:
pass
# Method 2: hostname resolution (works offline on most systems)
try:
ip = socket.gethostbyname(socket.gethostname())
if not ip.startswith("127."):
return ip
except Exception:
pass
# Method 3: enumerate all interfaces (fully offline, no external deps)
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if not ip.startswith("127.") and not ip.startswith("169.254."):
return ip
except Exception:
pass
return "127.0.0.1"
def _ensure_certs() -> bool:
"""
Make sure config/certs holds a TLS key pair, generating a self-signed one the
first time the dashboard runs.
The pair is deliberately NOT shipped in the repository. A private key that
every user downloads is the same as having no private key at all: anyone can
present a certificate that matches it. Generating locally gives each install
its own key, costs about a second, and happens exactly once.
Returns True when a usable pair exists afterwards; False leaves the caller on
plain HTTP, which still works — the QR code simply encodes http:// instead.
"""
certs = BASE_DIR / "config" / "certs"
key_p = certs / "jarvis.key"
crt_p = certs / "jarvis.crt"
if key_p.exists() and crt_p.exists():
return True
try:
import datetime
import ipaddress
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
except ImportError:
print("[Dashboard] cryptography not installed — serving over plain HTTP.")
print("[Dashboard] For HTTPS run: pip install cryptography")
return False
try:
certs.mkdir(parents=True, exist_ok=True)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
who = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "JARVIS Dashboard"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "JARVIS"),
])
# The SAN has to cover every address the phone might use: the LAN IP the
# QR code encodes, plus localhost when testing on the machine itself.
alt = [x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1"))]
try:
lan = _local_ip()
if not lan.startswith("127."):
alt.append(x509.IPAddress(ipaddress.IPv4Address(lan)))
except Exception:
pass # no LAN address resolvable — localhost entries still work
# Timezone-aware UTC: datetime.utcnow() is deprecated from Python 3.12 on,
# and the builder normalises aware values to UTC itself.
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(who)
.issuer_name(who)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.SubjectAlternativeName(alt), critical=False)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
key_p.write_bytes(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
crt_p.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
try:
import os as _os
_os.chmod(key_p, 0o600) # best effort — largely a no-op on Windows
except Exception:
pass
print(f"[Dashboard] Generated a self-signed certificate for this machine: {certs}")
return True
except Exception as e:
print(f"[Dashboard] Certificate generation failed ({e}) — serving over plain HTTP.")
return False
def _read(name: str) -> str:
return (STATIC_DIR / name).read_text(encoding="utf-8")
# ── DashboardServer ───────────────────────────────────────────────────────────
class DashboardServer:
def __init__(self):
self._ip = _local_ip()
self._tokens: set[str] = set()
self._token_keys: dict[str, str] = {} # auth_token → session_key
self._aes_cache: dict[str, bytes]= {} # session_key → AES bytes
self._clients: set[WebSocket] = set()
self._history: list[dict] = []
self._command_queue = asyncio.Queue()
self._wake_callback = None
self._connect_callback = None
self._pending_keys: dict[str, float] = {}
self._device_sessions: dict[str, dict] = {} # device_token → {session_key}
self._phone_audio_queue: asyncio.Queue = asyncio.Queue(maxsize=200)
self._uploads_dir = UPLOADS_DIR
self._login_html = _read("login.html")
self._app_html = _read("app.html")
self.app = self._build_app()
# ── one-time key management ───────────────────────────────────────────
def new_key(self, expiry_secs: int = 600) -> str:
now = time.time()
self._pending_keys = {k: v for k, v in self._pending_keys.items() if v > now}
key = ''.join(secrets.choice(_KEY_CHARS) for _ in range(6))
self._pending_keys[key] = now + expiry_secs
return key
@staticmethod
def _ssl_enabled() -> bool:
certs = BASE_DIR / "config" / "certs"
return (certs / "jarvis.key").exists() and (certs / "jarvis.crt").exists()
def get_url(self) -> str:
proto = "https" if self._ssl_enabled() else "http"
return f"{proto}://{self._ip}:{PORT}"
def get_manual_url(self) -> str:
"""URL for manual browser entry. When HTTPS active, points to alias port (also HTTPS)."""
if self._ssl_enabled():
return f"{self._ip}:{PORT + 1}"
return f"{self._ip}:{PORT}"
def _aes_key(self, session_key: str) -> bytes:
if session_key not in self._aes_cache:
self._aes_cache[session_key] = _derive_key(session_key)
return self._aes_cache[session_key]
def _decrypt(self, token: str, enc_b64: str) -> str | None:
sk = self._token_keys.get(token)
if not sk:
return None
try:
return _decrypt_cbc(self._aes_key(sk), enc_b64)
except Exception:
return None
# ── callbacks ────────────────────────────────────────────────────────
def set_wake_callback(self, fn) -> None:
self._wake_callback = fn
def set_connect_callback(self, fn) -> None:
self._connect_callback = fn
# ── broadcast ────────────────────────────────────────────────────────
async def broadcast(self, msg: dict) -> None:
self._history.append(msg)
if len(self._history) > 300:
self._history = self._history[-300:]
dead: set[WebSocket] = set()
for ws in list(self._clients):
try:
await ws.send_json(msg)
except Exception:
dead.add(ws)
self._clients -= dead
# ── FastAPI app ───────────────────────────────────────────────────────
def _build_app(self) -> "FastAPI":
app = FastAPI(docs_url=None, redoc_url=None)
def _auth(req: Request) -> bool:
tok = req.headers.get("authorization", "").removeprefix("Bearer ").strip()
return bool(tok) and tok in self._tokens
# serve CryptoJS from local cache, fallback to CDN redirect
@app.get("/static/crypto.js")
async def serve_crypto():
if _CRYPTOJS_FILE.exists():
return FileResponse(str(_CRYPTOJS_FILE),
media_type="application/javascript")
from fastapi.responses import RedirectResponse
return RedirectResponse(_CRYPTOJS_CDN)
@app.get("/login", response_class=HTMLResponse)
async def login_page():
return HTMLResponse(self._login_html)
@app.get("/", response_class=HTMLResponse)
async def index():
# Auth is handled client-side via sessionStorage bearer token.
# Server-side header auth can't work here because browser navigations
# don't send custom headers (location.href doesn't carry Authorization).
html = (self._app_html
.replace("__IP__", self._ip)
.replace("__PORT__", str(PORT)))
return HTMLResponse(html)
@app.post("/login")
async def login(req: Request):
body = await req.json()
entered = str(body.get("pin", "")).strip().upper()
now = time.time()
if entered in self._pending_keys and self._pending_keys[entered] > now:
del self._pending_keys[entered] # one-time use
tok = secrets.token_urlsafe(32)
self._tokens.add(tok)
self._token_keys[tok] = entered
self._aes_key(entered) # pre-derive & cache
if self._connect_callback:
self._connect_callback()
asyncio.create_task(self.broadcast(
{"type": "sys", "text": "Remote connection established."}
))
# Bearer token in response body — no cookies needed (works on any browser/HTTP)
return JSONResponse({"ok": True, "token": tok})
return JSONResponse({"ok": False, "error": "Invalid or expired key"},
status_code=401)
@app.get("/auto-login")
async def auto_login(key: str = ""):
"""QR code target — validates one-time key, creates session, redirects phone."""
now = time.time()
if not key or key not in self._pending_keys or self._pending_keys[key] <= now:
return HTMLResponse("""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width">
<style>
body{background:#07090f;color:#dde3ed;font-family:sans-serif;
display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center}
h2{color:#f87171;margin-bottom:12px}p{color:#5e6a7e;font-size:14px}
</style></head>
<body><div><h2>Link Expired</h2>
<p>Press <strong style="color:#dde3ed">Remote Control</strong> in JARVIS to get a new QR code.</p>
</div></body></html>""")
del self._pending_keys[key]
tok = secrets.token_urlsafe(32)
dev_tok = secrets.token_urlsafe(32)
self._tokens.add(tok)
self._token_keys[tok] = key
self._aes_key(key)
self._device_sessions[dev_tok] = {"session_key": key}
if self._connect_callback:
self._connect_callback()
asyncio.create_task(self.broadcast(
{"type": "sys", "text": "Remote connection established via QR code."}
))
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width">
<style>
body{{background:#07090f;color:#dde3ed;font-family:sans-serif;
display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center}}
p{{color:#5e6a7e;font-size:14px}}
</style></head>
<body>
<script>
sessionStorage.setItem('jarvis_token','{tok}');
sessionStorage.setItem('jarvis_key','{key}');
localStorage.setItem('jarvis_device_token','{dev_tok}');
setTimeout(function(){{location.replace('/')}},400);
</script>
<p>Connecting to JARVIS…</p>
</body></html>""")
@app.post("/api/device-login")
async def device_login_ep(req: Request):
"""Return a fresh auth token for a previously paired device token."""
try:
body = await req.json()
except Exception:
return JSONResponse({"ok": False}, status_code=400)
dev_tok = (body.get("device_token") or "").strip()
if not dev_tok or dev_tok not in self._device_sessions:
return JSONResponse({"ok": False}, status_code=401)
session_key = self._device_sessions[dev_tok]["session_key"]
tok = secrets.token_urlsafe(32)
self._tokens.add(tok)
self._token_keys[tok] = session_key
self._aes_key(session_key)
if self._connect_callback:
self._connect_callback()
asyncio.create_task(self.broadcast(
{"type": "sys", "text": "Known device reconnected automatically."}
))
return JSONResponse({"ok": True, "token": tok, "key": session_key})
@app.post("/api/revoke-devices")
async def revoke_devices(req: Request):
"""Invalidate all persistent device tokens (admin action)."""
if not _auth(req):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
count = len(self._device_sessions)
self._device_sessions.clear()
return JSONResponse({"ok": True, "revoked": count})
@app.post("/api/command")
async def command(req: Request):
if not _auth(req):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
body = await req.json()
token = req.headers.get("authorization", "").removeprefix("Bearer ").strip()
enc = body.get("enc", "")
if enc:
text = self._decrypt(token, enc)
if text is None:
return JSONResponse({"error": "Decryption failed"}, status_code=400)
else:
text = (body.get("text") or "").strip()
if text:
await self._command_queue.put(text)
if self._wake_callback:
self._wake_callback()
return JSONResponse({"ok": True})
@app.post("/api/wake")
async def wake_ep(req: Request):
if not _auth(req):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
if self._wake_callback:
self._wake_callback()
return JSONResponse({"ok": True})
# ── Phone mic real-time audio → Gemini Live ──────────────────────────
@app.websocket("/ws/phone-audio")
async def phone_audio_ws(websocket: WebSocket, token: str = ""):
tok = token.strip()
if not tok or tok not in self._tokens:
await websocket.close(code=4001)
return
await websocket.accept()
asyncio.create_task(self.broadcast(
{"type": "sys", "text": "Phone microphone live."}
))
try:
while True:
data = await websocket.receive_bytes()
try:
self._phone_audio_queue.put_nowait(
{"data": data, "mime_type": "audio/pcm"}
)
except asyncio.QueueFull:
pass # drop frame rather than block
except WebSocketDisconnect:
pass
finally:
asyncio.create_task(self.broadcast(
{"type": "sys", "text": "Phone microphone stopped."}
))
# ── File sharing ──────────────────────────────────────────────────────
def _safe_filename(raw: str) -> str:
name = Path(raw).name # strip path components
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name).strip(". ")
return name or "upload"
if _UPLOAD_OK:
@app.post("/api/upload")
async def upload_file(req: Request, file: UploadFile = FastAPIFile(...)):
if not _auth(req):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
safe = _safe_filename(file.filename or "upload")
dest = self._uploads_dir / safe
stem, suffix = Path(safe).stem, Path(safe).suffix
counter = 1
while dest.exists():
dest = self._uploads_dir / f"{stem}_{counter}{suffix}"
counter += 1
size = 0
max_bytes = MAX_UPLOAD_MB * 1024 * 1024
try:
with open(dest, "wb") as fout:
while True:
chunk = await file.read(65536)
if not chunk:
break
size += len(chunk)
if size > max_bytes:
fout.close()
dest.unlink(missing_ok=True)
return JSONResponse(
{"error": f"File too large (max {MAX_UPLOAD_MB} MB)"},
status_code=413,
)
fout.write(chunk)
except Exception as exc:
try:
dest.unlink(missing_ok=True)
except Exception:
pass
return JSONResponse({"error": str(exc)}, status_code=500)
asyncio.create_task(self.broadcast({
"type": "file_received",
"name": dest.name,
"size": size,
"saved_to": str(self._uploads_dir),
}))
return JSONResponse({"ok": True, "name": dest.name, "size": size})
else:
@app.post("/api/upload")
async def upload_unavailable(req: Request):
return JSONResponse(
{"error": "File uploads require: pip install python-multipart"},
status_code=503,
)
@app.get("/api/files")
async def list_files(req: Request):
if not _auth(req):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
files = []
try:
for f in sorted(
(p for p in self._uploads_dir.iterdir() if p.is_file()),
key=lambda p: p.stat().st_mtime,
reverse=True,
):
files.append({"name": f.name, "size": f.stat().st_size})
except Exception:
pass
return JSONResponse({"files": files})
@app.get("/uploads/{filename}")
async def download_file(filename: str, token: str = ""):
# Auth via query param — browser <a download> can't send custom headers
tok = token.strip()
if not tok or tok not in self._tokens:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
safe = re.sub(r'[/\\]', '', filename)
path = self._uploads_dir / safe
if not path.exists() or not path.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
return FileResponse(str(path), filename=safe)
@app.websocket("/ws")
async def ws_ep(websocket: WebSocket, token: str = ""):
tok = token.strip()
if not tok or tok not in self._tokens:
await websocket.close(code=4001)
return
await websocket.accept()
self._clients.add(websocket)
for entry in self._history[-50:]:
try:
await websocket.send_json(entry)
except Exception:
break
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "command":
enc = data.get("enc", "")
t = self._decrypt(tok, enc) if enc else (data.get("text") or "").strip()
if t:
await self._command_queue.put(t)
if self._wake_callback:
self._wake_callback()
except WebSocketDisconnect:
pass
finally:
self._clients.discard(websocket)
return app
# ── serve ─────────────────────────────────────────────────────────────
async def _serve_alias(self) -> None:
"""Second HTTPS server on PORT+1 sharing the same app and in-memory state.
Chrome HTTPS-upgrades any bare IP:PORT the user types, so this port also needs TLS.
User types IP:8001 → Chrome tries https → self-signed cert warning → accept once → done."""
ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key"
ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt"
asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT + 1)
cfg = uvicorn.Config(
self.app, host="0.0.0.0", port=PORT + 1, log_level="warning",
ssl_keyfile=str(ssl_key), ssl_certfile=str(ssl_cert),
)
print(f"[Dashboard] Manual entry: {self._ip}:{PORT + 1} (type in browser, accept cert once)")
await uvicorn.Server(cfg).serve()
async def serve(self) -> None:
if not _DEPS_OK:
print("[Dashboard] fastapi/uvicorn not installed — dashboard disabled.")
print("[Dashboard] Run: pip install fastapi 'uvicorn[standard]' cryptography")
return
# Firewall setup runs in a thread — uvicorn starts immediately,
# no waiting for UAC dialogs or subprocess timeouts.
asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT)
# Generate the TLS pair on first run so no private key ships in the repo.
_ensure_certs()
use_ssl = self._ssl_enabled()
ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key"
ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt"
if use_ssl:
asyncio.create_task(self._serve_alias())
cfg = uvicorn.Config(
self.app, host="0.0.0.0", port=PORT, log_level="warning",
**({"ssl_keyfile": str(ssl_key), "ssl_certfile": str(ssl_cert)} if use_ssl else {}),
)
proto = "https" if use_ssl else "http"
print(f"[Dashboard] {proto}://{self._ip}:{PORT}")
print("[Dashboard] Press 'Remote Control' in JARVIS UI to get the QR code.")
await uvicorn.Server(cfg).serve()