forked from enderofwings/NexusOS
feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""Email backend for the NexusOS mail client.
|
||||
|
||||
IMAP read via imap-tools, SMTP send via the stdlib. Defaults target iCloud
|
||||
(imap.mail.me.com / smtp.mail.me.com); with a custom-domain iCloud account the
|
||||
LOGIN is the primary Apple ID + an app-specific password, and the custom-domain
|
||||
address is the From alias.
|
||||
|
||||
Multiple accounts are supported (e.g. two iCloud aliases sharing the same
|
||||
server config) — each gets its own id, credentials, and cached IMAP
|
||||
connection. Credentials live in a gitignored file under runtime/ — NEVER the
|
||||
settings table, which bin/sync.py dumps to git. Password is write-only over
|
||||
the API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
import threading
|
||||
import uuid
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from synapse.nexus_config import RUNTIME_DIR
|
||||
|
||||
_ACCOUNT_FILE = RUNTIME_DIR / "mail_accounts.json"
|
||||
|
||||
# Both imaplib.IMAP4_SSL and smtplib.SMTP.starttls() fall back to
|
||||
# ssl._create_stdlib_context() when handed no context, and that one sets
|
||||
# verify_mode=CERT_NONE with check_hostname=False -- encrypted, but to nobody in
|
||||
# particular. Anything positioned to intercept the connection can present its
|
||||
# own certificate and collect the app-specific password on login. The default
|
||||
# context verifies the chain and the hostname.
|
||||
_TLS = ssl.create_default_context()
|
||||
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"label": "", # optional nickname shown in the account list
|
||||
"imap_host": "imap.mail.me.com",
|
||||
"imap_port": 993,
|
||||
"smtp_host": "smtp.mail.me.com",
|
||||
"smtp_port": 587,
|
||||
"username": "", # primary Apple ID address (login), not the alias
|
||||
"password": "", # app-specific password
|
||||
"from_addr": "", # e.g. nexus@enderofwings.com
|
||||
"from_name": "",
|
||||
}
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def _write_accounts(accounts: List[Dict[str, Any]]) -> None:
|
||||
_ACCOUNT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Create the file 0600 and rename it into place, rather than write-then-chmod:
|
||||
# that left it at the umask (usually world-readable) for the length of the
|
||||
# write, and a crash partway through truncated the real account file. The
|
||||
# temp file inherits the mode through os.replace.
|
||||
tmp = _ACCOUNT_FILE.with_name(_ACCOUNT_FILE.name + ".tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump({"accounts": accounts}, fh, indent=2)
|
||||
os.replace(tmp, _ACCOUNT_FILE)
|
||||
|
||||
|
||||
def load_accounts() -> List[Dict[str, Any]]:
|
||||
try:
|
||||
raw = json.loads(_ACCOUNT_FILE.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
if isinstance(raw, dict) and "accounts" in raw:
|
||||
accounts = raw["accounts"]
|
||||
elif isinstance(raw, dict) and raw.get("username"):
|
||||
# Legacy single-account file (pre-multi-account) — migrate in place.
|
||||
accounts = [{**_DEFAULTS, **raw, "id": _new_id()}]
|
||||
_write_accounts(accounts)
|
||||
else:
|
||||
accounts = []
|
||||
return [{**_DEFAULTS, **a} for a in accounts]
|
||||
|
||||
|
||||
def get_account(account_id: str) -> Dict[str, Any]:
|
||||
for a in load_accounts():
|
||||
if a["id"] == account_id:
|
||||
return a
|
||||
raise KeyError(account_id)
|
||||
|
||||
|
||||
def save_account(account_id: Optional[str], update: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create (account_id=None) or merge-update an account. A blank/missing
|
||||
password on update keeps the stored one."""
|
||||
accounts = load_accounts()
|
||||
if account_id is None:
|
||||
data = dict(_DEFAULTS)
|
||||
data["id"] = _new_id()
|
||||
for k in _DEFAULTS:
|
||||
if k in update:
|
||||
data[k] = update[k]
|
||||
accounts.append(data)
|
||||
else:
|
||||
for a in accounts:
|
||||
if a["id"] != account_id:
|
||||
continue
|
||||
for k in _DEFAULTS:
|
||||
if k == "password":
|
||||
if update.get("password"):
|
||||
a["password"] = update["password"]
|
||||
elif k in update:
|
||||
a[k] = update[k]
|
||||
data = a
|
||||
break
|
||||
else:
|
||||
raise KeyError(account_id)
|
||||
_write_accounts(accounts)
|
||||
_reset_conn(data["id"]) # creds/host may have changed
|
||||
return public_account(data)
|
||||
|
||||
|
||||
def delete_account(account_id: str) -> None:
|
||||
accounts = [a for a in load_accounts() if a["id"] != account_id]
|
||||
_write_accounts(accounts)
|
||||
_reset_conn(account_id)
|
||||
|
||||
|
||||
def public_account(a: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Account config for the UI — password replaced by a has_password flag."""
|
||||
configured = _is_configured(a)
|
||||
data = dict(a)
|
||||
pw = data.pop("password", "")
|
||||
data["has_password"] = bool(pw)
|
||||
data["configured"] = configured
|
||||
return data
|
||||
|
||||
|
||||
def public_accounts() -> List[Dict[str, Any]]:
|
||||
return [public_account(a) for a in load_accounts()]
|
||||
|
||||
|
||||
def _is_configured(a: Dict[str, Any]) -> bool:
|
||||
return bool(a.get("username") and a.get("password") and a.get("imap_host"))
|
||||
|
||||
|
||||
def is_configured(account_id: str) -> bool:
|
||||
try:
|
||||
return _is_configured(get_account(account_id))
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
|
||||
# --- IMAP ----------------------------------------------------------------------
|
||||
# One authenticated connection per account, reused across requests — a fresh
|
||||
# login per click is the ~1-2s iCloud handshake, and that was the visible lag.
|
||||
# imap-tools/imaplib is single-command-at-a-time, so a lock serialises the
|
||||
# thread-pooled endpoints.
|
||||
_conns: Dict[str, Any] = {}
|
||||
_conn_lock = threading.Lock()
|
||||
|
||||
|
||||
def _new_mailbox(a: Dict[str, Any]):
|
||||
from imap_tools import MailBox
|
||||
return MailBox(a["imap_host"], a["imap_port"], ssl_context=_TLS).login(a["username"], a["password"])
|
||||
|
||||
|
||||
def _reset_conn(account_id: str) -> None:
|
||||
with _conn_lock:
|
||||
conn = _conns.pop(account_id, None)
|
||||
try:
|
||||
if conn is not None:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run(account_id: str, fn: Callable):
|
||||
"""Run fn(mailbox) on the account's shared connection, rebuilding it once
|
||||
if the connection was dropped (iCloud closes idle sockets)."""
|
||||
a = get_account(account_id)
|
||||
with _conn_lock:
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
conn = _conns.get(account_id)
|
||||
if conn is None:
|
||||
conn = _conns[account_id] = _new_mailbox(a)
|
||||
return fn(conn)
|
||||
except Exception:
|
||||
conn = _conns.pop(account_id, None)
|
||||
try:
|
||||
if conn is not None:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
if attempt == 2:
|
||||
raise
|
||||
|
||||
|
||||
def test_connection(account_id: str) -> Dict[str, Any]:
|
||||
_reset_conn(account_id) # force a fresh login so the test reflects the saved creds
|
||||
try:
|
||||
return {"ok": True, "folders": len(_run(account_id, lambda mb: mb.folder.list()))}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_folders(account_id: str) -> List[str]:
|
||||
return _run(account_id, lambda mb: [f.name for f in mb.folder.list()])
|
||||
|
||||
|
||||
def _summary(msg) -> Dict[str, Any]:
|
||||
return {
|
||||
"uid": msg.uid,
|
||||
"subject": msg.subject,
|
||||
"from": msg.from_,
|
||||
"to": list(msg.to),
|
||||
"date": msg.date.isoformat() if msg.date else None,
|
||||
"seen": "\\Seen" in msg.flags,
|
||||
"preview": (msg.text or msg.html or "")[:160].strip(),
|
||||
}
|
||||
|
||||
|
||||
def list_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
def op(mb):
|
||||
mb.folder.set(folder)
|
||||
# Newest first; over-fetch by offset then slice (fine at personal scale).
|
||||
msgs = list(mb.fetch(reverse=True, limit=offset + limit, headers_only=True, bulk=True, mark_seen=False))
|
||||
return [_summary(m) for m in msgs[offset:offset + limit]]
|
||||
return _run(account_id, op)
|
||||
|
||||
|
||||
def get_message(account_id: str, folder: str, uid: str) -> Optional[Dict[str, Any]]:
|
||||
from imap_tools import AND
|
||||
|
||||
def op(mb):
|
||||
mb.folder.set(folder)
|
||||
for msg in mb.fetch(AND(uid=uid), mark_seen=True, bulk=True):
|
||||
return {
|
||||
"uid": msg.uid,
|
||||
"subject": msg.subject,
|
||||
"from": msg.from_,
|
||||
"to": list(msg.to),
|
||||
"cc": list(msg.cc),
|
||||
"date": msg.date.isoformat() if msg.date else None,
|
||||
"html": msg.html,
|
||||
"text": msg.text,
|
||||
"attachments": [{"name": a.filename, "size": a.size} for a in msg.attachments],
|
||||
}
|
||||
return None
|
||||
return _run(account_id, op)
|
||||
|
||||
|
||||
def set_seen(account_id: str, folder: str, uid: str, seen: bool = True) -> None:
|
||||
def op(mb):
|
||||
mb.folder.set(folder)
|
||||
mb.flag(uid, "\\Seen", seen)
|
||||
_run(account_id, op)
|
||||
|
||||
|
||||
def delete_message(account_id: str, folder: str, uid: str) -> None:
|
||||
def op(mb):
|
||||
mb.folder.set(folder)
|
||||
mb.delete(uid)
|
||||
_run(account_id, op)
|
||||
|
||||
|
||||
# --- SMTP ----------------------------------------------------------------------
|
||||
def send_message(account_id: str, to: str, subject: str, body: str,
|
||||
cc: str = "", from_addr: str = "") -> Dict[str, Any]:
|
||||
a = get_account(account_id)
|
||||
sender = from_addr or a["from_addr"] or a["username"]
|
||||
msg = EmailMessage()
|
||||
msg["From"] = f'{a["from_name"]} <{sender}>' if a["from_name"] else sender
|
||||
msg["To"] = to
|
||||
if cc:
|
||||
msg["Cc"] = cc
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
|
||||
recipients = [r.strip() for r in (to + "," + cc).split(",") if r.strip()]
|
||||
with smtplib.SMTP(a["smtp_host"], a["smtp_port"]) as s:
|
||||
s.starttls(context=_TLS)
|
||||
s.login(a["username"], a["password"])
|
||||
s.send_message(msg, from_addr=sender, to_addrs=recipients)
|
||||
return {"sent": True, "to": recipients}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""HTTP routes for the mail module — mounted onto the main app via modules/registry.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio as _asyncio
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from . import backend as mail
|
||||
|
||||
MANIFEST = {"key": "mail", "label": "Mail", "icon": "✉️"}
|
||||
|
||||
router = APIRouter(prefix="/mail", tags=["mail"])
|
||||
|
||||
|
||||
def _require_mail(account_id: str):
|
||||
if not mail.is_configured(account_id):
|
||||
raise HTTPException(status_code=400, detail="mail account not configured")
|
||||
|
||||
|
||||
# --- accounts --------------------------------------------------------------------
|
||||
@router.get("/accounts")
|
||||
async def mail_accounts():
|
||||
return {"accounts": mail.public_accounts()}
|
||||
|
||||
|
||||
@router.post("/accounts")
|
||||
async def mail_account_create(payload: Dict[str, Any] = Body(...)):
|
||||
return mail.save_account(None, payload)
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}")
|
||||
async def mail_account_update(account_id: str, payload: Dict[str, Any] = Body(...)):
|
||||
try:
|
||||
return mail.save_account(account_id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="account not found")
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
async def mail_account_delete(account_id: str):
|
||||
mail.delete_account(account_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/test")
|
||||
async def mail_account_test(account_id: str):
|
||||
_require_mail(account_id)
|
||||
return await _asyncio.to_thread(mail.test_connection, account_id)
|
||||
|
||||
|
||||
# --- mailbox -----------------------------------------------------------------------
|
||||
@router.get("/folders")
|
||||
async def mail_folders(account_id: str):
|
||||
_require_mail(account_id)
|
||||
try:
|
||||
return {"folders": await _asyncio.to_thread(mail.list_folders, account_id)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def mail_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0):
|
||||
_require_mail(account_id)
|
||||
try:
|
||||
msgs = await _asyncio.to_thread(mail.list_messages, account_id, folder, limit, offset)
|
||||
return {"messages": msgs}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
||||
|
||||
|
||||
@router.get("/message")
|
||||
async def mail_message(account_id: str, folder: str, uid: str):
|
||||
_require_mail(account_id)
|
||||
try:
|
||||
msg = await _asyncio.to_thread(mail.get_message, account_id, folder, uid)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="message not found")
|
||||
return msg
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
async def mail_send(payload: Dict[str, Any] = Body(...)):
|
||||
account_id = payload.get("account_id", "")
|
||||
_require_mail(account_id)
|
||||
to = (payload.get("to") or "").strip()
|
||||
if not to:
|
||||
raise HTTPException(status_code=400, detail="'to' is required")
|
||||
try:
|
||||
return await _asyncio.to_thread(
|
||||
mail.send_message, account_id, to, payload.get("subject", ""), payload.get("body", ""),
|
||||
payload.get("cc", ""), payload.get("from_addr", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"SMTP error: {e}")
|
||||
|
||||
|
||||
@router.post("/seen")
|
||||
async def mail_seen(payload: Dict[str, Any] = Body(...)):
|
||||
account_id = payload.get("account_id", "")
|
||||
_require_mail(account_id)
|
||||
await _asyncio.to_thread(mail.set_seen, account_id, payload["folder"], payload["uid"], payload.get("seen", True))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
async def mail_delete(payload: Dict[str, Any] = Body(...)):
|
||||
account_id = payload.get("account_id", "")
|
||||
_require_mail(account_id)
|
||||
await _asyncio.to_thread(mail.delete_message, account_id, payload["folder"], payload["uid"])
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Network status module.
|
||||
|
||||
Cross-platform connection info (via psutil), a WireGuard VPN status/toggle
|
||||
that only works where NetworkManager + nmcli exist (Linux — this mirrors the
|
||||
XFCE panel's network-popup.py, minus the GTK UI), and a small user-defined
|
||||
list of ping targets so "is my router/VPN endpoint reachable" isn't tied to
|
||||
any one hardcoded host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from synapse.nexus_config import RUNTIME_DIR
|
||||
|
||||
_TARGETS_FILE = RUNTIME_DIR / "network_targets.json"
|
||||
|
||||
_PING_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)
|
||||
|
||||
|
||||
# --- connection info ---------------------------------------------------------------
|
||||
def hostname() -> str:
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def primary_connection() -> Dict[str, Any]:
|
||||
import psutil
|
||||
|
||||
stats = psutil.net_if_stats()
|
||||
addrs = psutil.net_if_addrs()
|
||||
for name, addr_list in addrs.items():
|
||||
st = stats.get(name)
|
||||
if not st or not st.isup:
|
||||
continue
|
||||
lname = name.lower()
|
||||
if lname.startswith(("lo", "loopback")):
|
||||
continue
|
||||
for a in addr_list:
|
||||
if a.family == socket.AF_INET and not a.address.startswith("169.254"):
|
||||
iface_type = "wifi" if any(k in lname for k in ("wlan", "wi-fi", "wireless", "wl")) else "ethernet"
|
||||
return {"interface": name, "ip": a.address, "type": iface_type}
|
||||
return {"interface": None, "ip": None, "type": "offline"}
|
||||
|
||||
|
||||
# --- WireGuard / VPN (Linux + NetworkManager only) ----------------------------------
|
||||
def _nmcli(*args: str) -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["nmcli", "-t", "--escape", "no", *args],
|
||||
text=True, stderr=subprocess.DEVNULL, timeout=5,
|
||||
).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _nmcli_available() -> bool:
|
||||
if os.name != "posix":
|
||||
return False
|
||||
try:
|
||||
subprocess.check_output(["nmcli", "--version"], stderr=subprocess.DEVNULL, timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def vpn_status() -> Dict[str, Any]:
|
||||
if not _nmcli_available():
|
||||
return {"available": False}
|
||||
wgs = [p for p in (line.split(":") for line
|
||||
in _nmcli("-f", "NAME,TYPE,STATE", "connection", "show").splitlines())
|
||||
if len(p) >= 3 and p[1] == "wireguard"]
|
||||
for name, _t, state in wgs:
|
||||
if state == "activated":
|
||||
return {"available": True, "configured": True, "name": name, "connected": True}
|
||||
if wgs:
|
||||
return {"available": True, "configured": True, "name": wgs[0][0], "connected": False}
|
||||
return {"available": True, "configured": False, "name": None, "connected": False}
|
||||
|
||||
|
||||
def vpn_toggle(enable: bool) -> Dict[str, Any]:
|
||||
status = vpn_status()
|
||||
if not status.get("configured"):
|
||||
raise RuntimeError("no WireGuard tunnel configured")
|
||||
action = "up" if enable else "down"
|
||||
subprocess.check_output(
|
||||
["nmcli", "connection", action, status["name"]],
|
||||
stderr=subprocess.STDOUT, text=True, timeout=15,
|
||||
)
|
||||
return vpn_status()
|
||||
|
||||
|
||||
# --- ping targets --------------------------------------------------------------------
|
||||
def _load_targets() -> List[Dict[str, Any]]:
|
||||
try:
|
||||
return json.loads(_TARGETS_FILE.read_text()).get("targets", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _write_targets(targets: List[Dict[str, Any]]) -> None:
|
||||
_TARGETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_TARGETS_FILE.write_text(json.dumps({"targets": targets}, indent=2))
|
||||
|
||||
|
||||
def list_targets() -> List[Dict[str, Any]]:
|
||||
return _load_targets()
|
||||
|
||||
|
||||
def add_target(label: str, host: str) -> Dict[str, Any]:
|
||||
targets = _load_targets()
|
||||
t = {"id": uuid.uuid4().hex[:12], "label": label, "host": host}
|
||||
targets.append(t)
|
||||
_write_targets(targets)
|
||||
return t
|
||||
|
||||
|
||||
def delete_target(target_id: str) -> None:
|
||||
_write_targets([t for t in _load_targets() if t["id"] != target_id])
|
||||
|
||||
|
||||
def ping(host: str) -> Dict[str, Any]:
|
||||
is_windows = platform.system() == "Windows"
|
||||
cmd = ["ping", "-n", "1", "-w", "1000", host] if is_windows else ["ping", "-c", "1", "-W", "1", host]
|
||||
try:
|
||||
out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, timeout=3)
|
||||
m = _PING_LATENCY_RE.search(out)
|
||||
return {"ok": True, "latency_ms": float(m.group(1)) if m else None}
|
||||
except subprocess.CalledProcessError:
|
||||
return {"ok": False, "latency_ms": None}
|
||||
except Exception as e:
|
||||
return {"ok": False, "latency_ms": None, "error": str(e)}
|
||||
|
||||
|
||||
def ping_targets() -> List[Dict[str, Any]]:
|
||||
return [{**t, **ping(t["host"])} for t in _load_targets()]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""HTTP routes for the network module — mounted onto the main app via modules/registry.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio as _asyncio
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from . import backend as net
|
||||
|
||||
MANIFEST = {"key": "network", "label": "Network", "icon": "📡"}
|
||||
|
||||
router = APIRouter(prefix="/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def network_status():
|
||||
return {"hostname": net.hostname(), "connection": net.primary_connection(), "vpn": net.vpn_status()}
|
||||
|
||||
|
||||
@router.post("/vpn/toggle")
|
||||
async def network_vpn_toggle(payload: Dict[str, Any] = Body(...)):
|
||||
try:
|
||||
return await _asyncio.to_thread(net.vpn_toggle, bool(payload.get("enable")))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"nmcli error: {e}")
|
||||
|
||||
|
||||
@router.get("/targets")
|
||||
async def network_targets():
|
||||
return {"targets": net.list_targets()}
|
||||
|
||||
|
||||
@router.post("/targets")
|
||||
async def network_target_create(payload: Dict[str, Any] = Body(...)):
|
||||
host = (payload.get("host") or "").strip()
|
||||
if not host:
|
||||
raise HTTPException(status_code=400, detail="'host' is required")
|
||||
return net.add_target((payload.get("label") or "").strip() or host, host)
|
||||
|
||||
|
||||
@router.delete("/targets/{target_id}")
|
||||
async def network_target_delete(target_id: str):
|
||||
net.delete_target(target_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def network_ping_all():
|
||||
return {"targets": await _asyncio.to_thread(net.ping_targets)}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Auto-discovers backend modules under modules/ — any subpackage with a
|
||||
router.py exposing `router` (an APIRouter) is mounted automatically. Drop a
|
||||
new module folder in and it's picked up on next start; nothing here needs
|
||||
editing.
|
||||
|
||||
A missing router.py just means the folder isn't a module (skipped quietly).
|
||||
An import error *inside* an existing router.py is a real bug and is left to
|
||||
raise — silently swallowing it would hide broken modules instead of
|
||||
surfacing them at startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
_MODULES_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _discover() -> List[APIRouter]:
|
||||
routers = []
|
||||
for info in sorted(pkgutil.iter_modules([str(_MODULES_DIR)]), key=lambda m: m.name):
|
||||
if not info.ispkg:
|
||||
continue
|
||||
if not (_MODULES_DIR / info.name / "router.py").exists():
|
||||
continue
|
||||
mod = importlib.import_module(f"modules.{info.name}.router")
|
||||
router = getattr(mod, "router", None)
|
||||
if isinstance(router, APIRouter):
|
||||
routers.append(router)
|
||||
return routers
|
||||
|
||||
|
||||
ROUTERS = _discover()
|
||||
Reference in New Issue
Block a user