From 3343e955fddda25d2f6922868e1a507c1906a3b7 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 23 Feb 2026 02:30:12 -0800 Subject: [PATCH] feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination --- CLAUDE.md | 44 ++++ TODO.md | 21 +- app/src/components/GatewayPanel.tsx | 136 ++++++++++ app/src/components/MeshPanel.tsx | 67 +++++ app/src/components/NodeBar.tsx | 41 +++ app/src/pages/Dashboard.tsx | 31 ++- app/src/pages/NodeDetail.tsx | 106 ++++++++ app/src/router/routes.tsx | 5 + app/src/services/api/hooks.ts | 43 ++++ app/src/types/index.ts | 35 +++ castle-api/pyproject.toml | 2 + castle-api/src/castle_api/config.py | 6 + castle-api/src/castle_api/main.py | 50 +++- castle-api/src/castle_api/mdns.py | 130 ++++++++++ castle-api/src/castle_api/mesh.py | 76 ++++++ castle-api/src/castle_api/models.py | 43 ++++ castle-api/src/castle_api/mqtt_client.py | 255 +++++++++++++++++++ castle-api/src/castle_api/nodes.py | 115 +++++++++ castle-api/src/castle_api/routes.py | 106 +++++++- castle-api/tests/test_health.py | 20 ++ castle-api/tests/test_mesh.py | 104 ++++++++ castle-api/tests/test_mqtt.py | 96 +++++++ castle-api/tests/test_nodes.py | 91 +++++++ castle.yaml | 17 ++ cli/src/castle_cli/commands/deploy.py | 2 +- core/src/castle_core/generators/caddyfile.py | 28 +- core/tests/test_caddyfile.py | 61 +++++ docs/design.md | 118 ++++++++- uv.lock | 64 +++++ 29 files changed, 1878 insertions(+), 35 deletions(-) create mode 100644 app/src/components/GatewayPanel.tsx create mode 100644 app/src/components/MeshPanel.tsx create mode 100644 app/src/components/NodeBar.tsx create mode 100644 app/src/pages/NodeDetail.tsx create mode 100644 castle-api/src/castle_api/mdns.py create mode 100644 castle-api/src/castle_api/mesh.py create mode 100644 castle-api/src/castle_api/mqtt_client.py create mode 100644 castle-api/src/castle_api/nodes.py create mode 100644 castle-api/tests/test_mesh.py create mode 100644 castle-api/tests/test_mqtt.py create mode 100644 castle-api/tests/test_nodes.py diff --git a/CLAUDE.md b/CLAUDE.md index 428b655..6aff873 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,50 @@ castle services start|stop # Start/stop everything - **Data**: Service data lives in `/data/castle//`, passed via env var. - **Secrets**: `~/.castle/secrets/` — never in project directories. +## API Endpoints (castle-api, port 9020) + +Core: +- `GET /health` — Health check +- `GET /stream` — SSE stream (health, service-action, mesh events) + +Components: +- `GET /components` — List all (add `?include_remote=true` for cross-node) +- `GET /components/{name}` — Component detail +- `GET /status` — Live health for all services + +Gateway: +- `GET /gateway` — Gateway info with route table and hostname +- `GET /gateway/caddyfile` — Generated Caddyfile content +- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy + +Nodes (mesh): +- `GET /nodes` — All known nodes (local + discovered remote) +- `GET /nodes/{hostname}` — Node detail with deployed components + +Services: +- `POST /services/{name}/{action}` — start/stop/restart +- `GET /services/{name}/unit` — Systemd unit content + +Tools: +- `GET /tools` — List all tools +- `GET /tools/{name}` — Tool detail +- `POST /tools/{name}/install` — Install tool to PATH +- `POST /tools/{name}/uninstall` — Uninstall tool + +## Mesh Coordination (opt-in) + +Multi-node discovery is disabled by default. Enable via env vars: + +```bash +CASTLE_API_MQTT_ENABLED=true # Connect to MQTT broker +CASTLE_API_MQTT_HOST=localhost # Broker address +CASTLE_API_MQTT_PORT=1883 # Broker port +CASTLE_API_MDNS_ENABLED=true # Advertise/discover via mDNS +``` + +Key modules: `castle_api.mesh` (MeshStateManager), `castle_api.mqtt_client` +(paho-mqtt wrapper), `castle_api.mdns` (python-zeroconf wrapper). + ## Per-Project Commands All projects use **uv**. Commands run from each project's directory: diff --git a/TODO.md b/TODO.md index eb3218b..0233265 100644 --- a/TODO.md +++ b/TODO.md @@ -3,8 +3,19 @@ - Remove devbox-connect. Instead, make it easy to copy an ssh tunnel command to expose all ports of all services to a remote box: `ssh -L 9000:localhost:9000 payne@dev.payne.io, etc. - Add a scripts dir? -- tools: use std-in/std-out unix philosophy -- daemons or web-services: expose ports -- workers: just keep doing work in a background--usually watching the filesystem or a queue -- jobs: scheduled tools or daemon requests -- frontend: files served up by caddy +- Component (software units) types: + - python/golang/rust/bash tool: use std-in/std-out unix philosophy + - python/golang/rust-daemon: Whatever + - python web-service: RESTful, fastapi, SSE, etc. + - react-frontend + - python API + +- Dependencies + - docker/podman (mqtt) + - all component system dependencies + - uv + - golang/rust/java/.net/whatever + +- Add amplifier to make a component builder + +- What's this about? "I need to add the MQTT env vars to the systemd unit. Let me add them via a drop-in override so the deploy command doesn't overwrite them." diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx new file mode 100644 index 0000000..6dd8c4f --- /dev/null +++ b/app/src/components/GatewayPanel.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState } from "react" +import { Link } from "react-router-dom" +import { Globe, RefreshCw, FileText } from "lucide-react" +import type { GatewayInfo, HealthStatus } from "@/types" +import { useGatewayReload, useCaddyfile } from "@/services/api/hooks" +import { HealthBadge } from "./HealthBadge" + +interface GatewayPanelProps { + gateway: GatewayInfo + statuses: HealthStatus[] +} + +export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { + const statusMap = new Map(statuses.map((s) => [s.id, s])) + const { mutate: reload, isPending: reloading } = useGatewayReload() + const [showCaddyfile, setShowCaddyfile] = useState(false) + const { data: caddyfileData } = useCaddyfile(showCaddyfile) + + const multiNode = useMemo(() => { + const nodes = new Set(gateway.routes.map((r) => r.node)) + return nodes.size > 1 + }, [gateway.routes]) + + return ( +
+ {/* Header */} +
+
+ +

Gateway

+ + {gateway.hostname} · port {gateway.port} · {gateway.routes.length} route{gateway.routes.length !== 1 ? "s" : ""} + +
+
+ + +
+
+ + {/* Route table */} + {gateway.routes.length > 0 && ( + + + + + + + {multiNode && ( + + )} + + + + + {gateway.routes.map((route) => { + const health = statusMap.get(route.component) + return ( + + + + + {multiNode && ( + + )} + + + ) + })} + +
PathComponentPortNodeHealth
+ {route.path} + + + {route.component} + + + {route.target_port} + + + {route.node} + + + {health ? ( + + ) : ( + + )} +
+ )} + + {gateway.routes.length === 0 && ( +

+ No proxy routes configured. +

+ )} + + {/* Caddyfile viewer */} + {showCaddyfile && caddyfileData && ( +
+
+            {caddyfileData.content}
+          
+
+ )} +
+ ) +} diff --git a/app/src/components/MeshPanel.tsx b/app/src/components/MeshPanel.tsx new file mode 100644 index 0000000..516d09e --- /dev/null +++ b/app/src/components/MeshPanel.tsx @@ -0,0 +1,67 @@ +import { Link } from "react-router-dom" +import { Network, Wifi, WifiOff } from "lucide-react" +import { cn } from "@/lib/utils" +import type { MeshStatus } from "@/types" + +interface MeshPanelProps { + mesh: MeshStatus +} + +export function MeshPanel({ mesh }: MeshPanelProps) { + if (!mesh.enabled) return null + + return ( +
+
+
+ +

Mesh

+ + {mesh.mqtt_connected ? ( + + ) : ( + + )} + {mesh.mqtt_connected ? "connected" : "disconnected"} + +
+ +
+ {mesh.mqtt_broker_host && ( + + mqtt://{mesh.mqtt_broker_host}:{mesh.mqtt_broker_port} + + )} + {mesh.mdns_enabled && ( + mDNS active + )} + + {mesh.peer_count} peer{mesh.peer_count !== 1 ? "s" : ""} + +
+
+ + {mesh.peers.length > 0 && ( +
+ Peers: + {mesh.peers.map((peer) => ( + + {peer} + + ))} +
+ )} +
+ ) +} diff --git a/app/src/components/NodeBar.tsx b/app/src/components/NodeBar.tsx new file mode 100644 index 0000000..1ab979c --- /dev/null +++ b/app/src/components/NodeBar.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom" +import { cn } from "@/lib/utils" +import type { NodeSummary } from "@/types" + +interface NodeBarProps { + nodes: NodeSummary[] +} + +export function NodeBar({ nodes }: NodeBarProps) { + // Only show when there are remote nodes + if (nodes.length <= 1) return null + + return ( +
+ {nodes.map((node) => ( + + + {node.hostname} + {!node.is_local && node.deployed_count > 0 && ( + ({node.deployed_count}) + )} + + ))} +
+ ) +} diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index d3a7dcc..002f17d 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react" import { Link } from "react-router-dom" -import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks" +import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks" +import { GatewayPanel } from "@/components/GatewayPanel" +import { MeshPanel } from "@/components/MeshPanel" +import { NodeBar } from "@/components/NodeBar" import { ServiceSection } from "@/components/ServiceSection" import { JobSection } from "@/components/JobSection" import { ToolSection } from "@/components/ToolSection" @@ -11,6 +14,8 @@ export function Dashboard() { const { data: components, isLoading } = useComponents() const { data: statusResp } = useStatus() const { data: gateway } = useGateway() + const { data: nodes } = useNodes() + const { data: mesh } = useMeshStatus() const { services, jobs, tools, frontends, other } = useMemo(() => { const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components } @@ -30,17 +35,23 @@ export function Dashboard() {

Castle

-

- Personal software platform - {gateway && ( - - · {services.length} services · {jobs.length} jobs - · {tools.length} tools · port {gateway.port} - - )} -

+

Personal software platform

+ {nodes && } + + {gateway && ( +
+ +
+ )} + + {mesh && ( +
+ +
+ )} + {isLoading ? (

Loading...

) : ( diff --git a/app/src/pages/NodeDetail.tsx b/app/src/pages/NodeDetail.tsx new file mode 100644 index 0000000..a4d2de4 --- /dev/null +++ b/app/src/pages/NodeDetail.tsx @@ -0,0 +1,106 @@ +import { useParams, Link } from "react-router-dom" +import { ArrowLeft, Server } from "lucide-react" +import { useNode } from "@/services/api/hooks" +import { RoleBadge } from "@/components/RoleBadge" +import { cn } from "@/lib/utils" + +export function NodeDetailPage() { + const { hostname } = useParams<{ hostname: string }>() + const { data: node, isLoading, error } = useNode(hostname ?? "") + + if (isLoading) { + return ( +
+

Loading...

+
+ ) + } + + if (error || !node) { + return ( +
+ + Back + +

Node "{hostname}" not found.

+
+ ) + } + + return ( +
+ + Back + + +
+
+ +

{node.hostname}

+ + + {node.online ? "online" : "offline"} + + {node.is_local && ( + local + )} +
+

+ Gateway port {node.gateway_port} · {node.deployed_count} deployed · {node.service_count} services +

+
+ + {/* Deployed components table */} + {node.deployed.length > 0 ? ( +
+ + + + + + + + + + + {node.deployed.map((comp) => ( + + + + + + + ))} + +
ComponentCategoryRunnerPort
+ + {comp.id} + + {comp.description && ( +

{comp.description}

+ )} +
+ + + {comp.runner ?? "—"} + + {comp.port ?? "—"} +
+
+ ) : ( +

No deployed components on this node.

+ )} +
+ ) +} diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx index 6e8dc0f..6e110ac 100644 --- a/app/src/router/routes.tsx +++ b/app/src/router/routes.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter } from "react-router-dom" import { Dashboard } from "@/pages/Dashboard" import { ComponentDetailPage } from "@/pages/ComponentDetail" +import { NodeDetailPage } from "@/pages/NodeDetail" export const router = createBrowserRouter([ { @@ -11,4 +12,8 @@ export const router = createBrowserRouter([ path: "/component/:name", element: , }, + { + path: "/node/:hostname", + element: , + }, ]) diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 3fba341..1eab71a 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -8,6 +8,9 @@ import type { GatewayInfo, ServiceActionResponse, SSEHealthEvent, + MeshStatus, + NodeSummary, + NodeDetail, ToolSummary, ToolDetail, } from "@/types" @@ -107,6 +110,39 @@ export function useToolAction() { }) } +export function useGatewayReload() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => apiClient.post<{ status: string }>("/gateway/reload"), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["gateway"] }) + }, + }) +} + +export function useNodes() { + return useQuery({ + queryKey: ["nodes"], + queryFn: () => apiClient.get("/nodes"), + }) +} + +export function useNode(hostname: string) { + return useQuery({ + queryKey: ["nodes", hostname], + queryFn: () => apiClient.get(`/nodes/${hostname}`), + enabled: !!hostname, + }) +} + +export function useMeshStatus() { + return useQuery({ + queryKey: ["mesh"], + queryFn: () => apiClient.get("/mesh/status"), + refetchInterval: 30_000, + }) +} + export function useTools() { return useQuery({ queryKey: ["tools"], @@ -140,6 +176,13 @@ export function useEventStream() { qc.invalidateQueries({ queryKey: ["components"] }) }) + es.addEventListener("mesh", () => { + // A remote node updated or went offline — refresh mesh, nodes, and gateway + qc.invalidateQueries({ queryKey: ["mesh"] }) + qc.invalidateQueries({ queryKey: ["nodes"] }) + qc.invalidateQueries({ queryKey: ["gateway"] }) + }) + es.onerror = () => { // EventSource auto-reconnects; nothing to do } diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 2a6e132..c4c4270 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -19,6 +19,7 @@ export interface ComponentSummary { system_dependencies: string[] schedule: string | null installed: boolean | null + node: string | null } export interface ComponentDetail extends ComponentSummary { @@ -35,11 +36,20 @@ export interface StatusResponse { statuses: HealthStatus[] } +export interface GatewayRoute { + path: string + target_port: number + component: string + node: string +} + export interface GatewayInfo { port: number + hostname: string component_count: number service_count: number managed_count: number + routes: GatewayRoute[] } export interface ServiceActionResponse { @@ -59,6 +69,31 @@ export interface SSEServiceActionEvent { status: string } +export interface NodeSummary { + hostname: string + gateway_port: number + deployed_count: number + service_count: number + is_local: boolean + online: boolean + is_stale: boolean + last_seen: number | null +} + +export interface NodeDetail extends NodeSummary { + deployed: ComponentSummary[] +} + +export interface MeshStatus { + enabled: boolean + mqtt_connected: boolean + mqtt_broker_host: string | null + mqtt_broker_port: number | null + mdns_enabled: boolean + peer_count: number + peers: string[] +} + export interface ToolSummary { id: string description: string | null diff --git a/castle-api/pyproject.toml b/castle-api/pyproject.toml index 95db147..85c5f1d 100644 --- a/castle-api/pyproject.toml +++ b/castle-api/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "pydantic-settings>=2.0.0", "httpx>=0.27.0", "castle-core", + "paho-mqtt>=2.0.0", + "zeroconf>=0.131.0", ] [tool.uv.sources] diff --git a/castle-api/src/castle_api/config.py b/castle-api/src/castle_api/config.py index 43d0dc9..d641ad2 100644 --- a/castle-api/src/castle_api/config.py +++ b/castle-api/src/castle_api/config.py @@ -14,6 +14,12 @@ class Settings(BaseSettings): host: str = "0.0.0.0" port: int = 9020 + # Mesh coordination (all off by default — single-node works without them) + mqtt_enabled: bool = False + mqtt_host: str = "localhost" + mqtt_port: int = 1883 + mdns_enabled: bool = False + model_config = { "env_prefix": "CASTLE_API_", "env_file": ".env", diff --git a/castle-api/src/castle_api/main.py b/castle-api/src/castle_api/main.py index 5c8bbde..69a6c22 100644 --- a/castle-api/src/castle_api/main.py +++ b/castle-api/src/castle_api/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -11,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.responses import StreamingResponse -from castle_api.config import settings +from castle_api.config import get_registry, settings from castle_api.config_editor import router as config_router from castle_api.logs import router as logs_router from castle_api.routes import router as dashboard_router @@ -23,8 +24,11 @@ from castle_api.stream import ( subscribe, unsubscribe, ) +from castle_api.nodes import router as nodes_router from castle_api.tools import router as tools_router +logger = logging.getLogger(__name__) + # Set by _watch_shutdown when uvicorn begins its shutdown sequence. _shutting_down = False @@ -46,10 +50,53 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: poll_task = asyncio.create_task(health_poll_loop()) + # --- Mesh coordination (opt-in) --- + mqtt_client = None + mdns_service = None + + if settings.mqtt_enabled: + try: + from castle_api.mqtt_client import CastleMQTTClient + + registry = get_registry() + mqtt_client = CastleMQTTClient( + local_hostname=registry.node.hostname, + local_registry=registry, + broker_host=settings.mqtt_host, + broker_port=settings.mqtt_port, + ) + await mqtt_client.start() + app.state.mqtt_client = mqtt_client + except Exception: + logger.exception("Failed to start MQTT client") + mqtt_client = None + + if settings.mdns_enabled: + try: + from castle_api.mdns import CastleMDNS + + registry = get_registry() + mdns_service = CastleMDNS( + hostname=registry.node.hostname, + gateway_port=registry.node.gateway_port, + api_port=settings.port, + ) + mdns_service.start() + app.state.mdns = mdns_service + except Exception: + logger.exception("Failed to start mDNS") + mdns_service = None + yield _shutting_down = True poll_task.cancel() + + if mqtt_client: + await mqtt_client.stop() + if mdns_service: + mdns_service.stop() + close_all_subscribers() @@ -70,6 +117,7 @@ app.add_middleware( app.include_router(config_router) app.include_router(dashboard_router) app.include_router(logs_router) +app.include_router(nodes_router) app.include_router(secrets_router) app.include_router(services_router) app.include_router(tools_router) diff --git a/castle-api/src/castle_api/mdns.py b/castle-api/src/castle_api/mdns.py new file mode 100644 index 0000000..6e569bc --- /dev/null +++ b/castle-api/src/castle_api/mdns.py @@ -0,0 +1,130 @@ +"""mDNS discovery for castle mesh — zero-config LAN peer and broker discovery. + +Advertises this node as _castle._tcp and browses for: + - _castle._tcp — peer castle nodes + - _mqtt._tcp — MQTT broker address +""" + +from __future__ import annotations + +import logging +import socket + +from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf + +logger = logging.getLogger(__name__) + +CASTLE_SERVICE_TYPE = "_castle._tcp.local." +MQTT_SERVICE_TYPE = "_mqtt._tcp.local." + + +class CastleMDNS: + """Advertise this castle node and discover peers + MQTT broker via mDNS.""" + + def __init__( + self, + hostname: str, + gateway_port: int, + api_port: int, + ) -> None: + self._hostname = hostname + self._gateway_port = gateway_port + self._api_port = api_port + self._zeroconf: Zeroconf | None = None + self._browsers: list[ServiceBrowser] = [] + self._service_info: ServiceInfo | None = None + + # Discovered state + self.peers: dict[str, dict] = {} # hostname -> {gateway_port, api_port, addresses} + self.mqtt_broker: dict | None = None # {host, port} or None + + def _on_service_state_change( + self, + zeroconf: Zeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + """Handle discovered/removed services.""" + if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): + info = zeroconf.get_service_info(service_type, name) + if info is None: + return + + if service_type == CASTLE_SERVICE_TYPE: + self._handle_castle_peer(info) + elif service_type == MQTT_SERVICE_TYPE: + self._handle_mqtt_broker(info) + + elif state_change == ServiceStateChange.Removed: + if service_type == CASTLE_SERVICE_TYPE: + # Extract hostname from service name (format: "hostname._castle._tcp.local.") + peer_hostname = name.replace(f".{CASTLE_SERVICE_TYPE}", "") + if peer_hostname != self._hostname and peer_hostname in self.peers: + del self.peers[peer_hostname] + logger.info("mDNS: peer %s removed", peer_hostname) + + def _handle_castle_peer(self, info: ServiceInfo) -> None: + """Process a discovered castle peer.""" + props = { + k.decode() if isinstance(k, bytes) else k: v.decode() if isinstance(v, bytes) else v + for k, v in info.properties.items() + } + peer_hostname = props.get("hostname", "") + if not peer_hostname or peer_hostname == self._hostname: + return + + addresses = [socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4] + + self.peers[peer_hostname] = { + "gateway_port": int(props.get("gateway_port", 9000)), + "api_port": int(props.get("api_port", 9020)), + "addresses": addresses, + } + logger.info("mDNS: discovered peer %s at %s", peer_hostname, addresses) + + def _handle_mqtt_broker(self, info: ServiceInfo) -> None: + """Process a discovered MQTT broker.""" + addresses = [socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4] + if addresses: + self.mqtt_broker = { + "host": addresses[0], + "port": info.port, + } + logger.info("mDNS: discovered MQTT broker at %s:%d", addresses[0], info.port) + + def start(self) -> None: + """Start advertising and browsing.""" + self._zeroconf = Zeroconf() + + # Advertise ourselves + self._service_info = ServiceInfo( + CASTLE_SERVICE_TYPE, + f"{self._hostname}.{CASTLE_SERVICE_TYPE}", + port=self._gateway_port, + properties={ + "hostname": self._hostname, + "gateway_port": str(self._gateway_port), + "api_port": str(self._api_port), + }, + ) + self._zeroconf.register_service(self._service_info) + logger.info("mDNS: advertising %s on port %d", self._hostname, self._gateway_port) + + # Browse for peers and MQTT broker + self._browsers.append( + ServiceBrowser(self._zeroconf, CASTLE_SERVICE_TYPE, handlers=[self._on_service_state_change]) + ) + self._browsers.append( + ServiceBrowser(self._zeroconf, MQTT_SERVICE_TYPE, handlers=[self._on_service_state_change]) + ) + + def stop(self) -> None: + """Stop advertising and close.""" + if self._zeroconf: + if self._service_info: + self._zeroconf.unregister_service(self._service_info) + self._zeroconf.close() + self._zeroconf = None + self._browsers.clear() + logger.info("mDNS: stopped") diff --git a/castle-api/src/castle_api/mesh.py b/castle-api/src/castle_api/mesh.py new file mode 100644 index 0000000..e66bf86 --- /dev/null +++ b/castle-api/src/castle_api/mesh.py @@ -0,0 +1,76 @@ +"""Mesh state manager — aggregates remote node registries.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field + +from castle_core.registry import NodeRegistry + +logger = logging.getLogger(__name__) + +# Remote registries older than this are considered stale. +STALE_TTL_SECONDS = 300 # 5 minutes + + +@dataclass +class RemoteNode: + """A remote node's registry and metadata.""" + + registry: NodeRegistry + last_seen: float = field(default_factory=time.time) + online: bool = True + + @property + def is_stale(self) -> bool: + return (time.time() - self.last_seen) > STALE_TTL_SECONDS + + +class MeshStateManager: + """Singleton holding remote node state discovered via MQTT. + + Thread-safe for reads from the FastAPI request handlers. + Mutations happen only from the MQTT callback task. + """ + + def __init__(self) -> None: + self._nodes: dict[str, RemoteNode] = {} + + def update_node(self, hostname: str, registry: NodeRegistry) -> None: + """Add or update a remote node's registry.""" + self._nodes[hostname] = RemoteNode(registry=registry) + logger.info("Mesh: updated node %s (%d deployed)", hostname, len(registry.deployed)) + + def set_offline(self, hostname: str) -> None: + """Mark a node as offline (LWT received).""" + if hostname in self._nodes: + self._nodes[hostname].online = False + logger.info("Mesh: node %s went offline", hostname) + + def remove_node(self, hostname: str) -> None: + """Remove a node entirely.""" + if self._nodes.pop(hostname, None): + logger.info("Mesh: removed node %s", hostname) + + def get_node(self, hostname: str) -> RemoteNode | None: + """Get a specific remote node.""" + return self._nodes.get(hostname) + + def all_nodes(self, *, include_stale: bool = False) -> dict[str, RemoteNode]: + """Return all remote nodes, optionally filtering out stale ones.""" + if include_stale: + return dict(self._nodes) + return {h: n for h, n in self._nodes.items() if not n.is_stale} + + def prune_stale(self) -> list[str]: + """Remove nodes that have gone stale. Returns list of pruned hostnames.""" + pruned = [h for h, n in self._nodes.items() if n.is_stale] + for h in pruned: + del self._nodes[h] + logger.info("Mesh: pruned stale node %s", h) + return pruned + + +# Module-level singleton — imported by MQTT client and API routes. +mesh_state = MeshStateManager() diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 66fbd9a..dae67e0 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -28,6 +28,7 @@ class ComponentSummary(BaseModel): system_dependencies: list[str] = [] schedule: str | None = None installed: bool | None = None + node: str | None = None class ComponentDetail(ComponentSummary): @@ -50,13 +51,55 @@ class StatusResponse(BaseModel): statuses: list[HealthStatus] +class GatewayRoute(BaseModel): + """A single route in the gateway's reverse proxy table.""" + + path: str + target_port: int + component: str + node: str + + class GatewayInfo(BaseModel): """Gateway configuration summary.""" port: int + hostname: str component_count: int service_count: int managed_count: int + routes: list[GatewayRoute] = [] + + +class NodeSummary(BaseModel): + """Summary of a discovered node in the mesh.""" + + hostname: str + gateway_port: int + deployed_count: int + service_count: int + is_local: bool = False + online: bool = True + is_stale: bool = False + last_seen: float | None = None + + +class NodeDetail(NodeSummary): + """Full detail for a node, including its deployed components.""" + + deployed: list[ComponentSummary] = [] + + +class MeshStatus(BaseModel): + """Current state of the mesh coordination layer.""" + + enabled: bool = False + mqtt_connected: bool = False + mqtt_broker_host: str | None = None + mqtt_broker_port: int | None = None + mdns_enabled: bool = False + peer_count: int = 0 + peers: list[str] = [] class ServiceActionResponse(BaseModel): diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py new file mode 100644 index 0000000..7191b24 --- /dev/null +++ b/castle-api/src/castle_api/mqtt_client.py @@ -0,0 +1,255 @@ +"""MQTT client for inter-node mesh coordination. + +Topics: + castle/{hostname}/registry — retained JSON NodeRegistry, published on connect + castle/{hostname}/status — "online" (retained) / "offline" (LWT) + +On incoming messages from other hostnames, updates MeshStateManager. +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +import paho.mqtt.client as mqtt + +from castle_core.registry import ( + DeployedComponent, + NodeConfig, + NodeRegistry, +) + +from castle_api.mesh import mesh_state +from castle_api.stream import broadcast + +logger = logging.getLogger(__name__) + + +def _registry_to_json(registry: NodeRegistry) -> str: + """Serialize a NodeRegistry to JSON for MQTT publishing. + + Only includes fields needed for mesh routing — env vars, run_cmd, + and castle_root are excluded to avoid leaking secrets. + """ + data: dict = { + "node": { + "hostname": registry.node.hostname, + "gateway_port": registry.node.gateway_port, + }, + "deployed": {}, + } + + for name, comp in registry.deployed.items(): + entry: dict = { + "runner": comp.runner, + "category": comp.category, + } + if comp.description: + entry["description"] = comp.description + if comp.port is not None: + entry["port"] = comp.port + if comp.health_path: + entry["health_path"] = comp.health_path + if comp.proxy_path: + entry["proxy_path"] = comp.proxy_path + if comp.schedule: + entry["schedule"] = comp.schedule + if comp.managed: + entry["managed"] = comp.managed + data["deployed"][name] = entry + + return json.dumps(data) + + +def _json_to_registry(payload: str) -> NodeRegistry: + """Deserialize a NodeRegistry from MQTT JSON payload.""" + data = json.loads(payload) + node_data = data.get("node", {}) + node = NodeConfig( + hostname=node_data.get("hostname", ""), + castle_root=node_data.get("castle_root"), + gateway_port=node_data.get("gateway_port", 9000), + ) + deployed: dict[str, DeployedComponent] = {} + for name, comp_data in data.get("deployed", {}).items(): + deployed[name] = DeployedComponent( + runner=comp_data.get("runner", "command"), + run_cmd=comp_data.get("run_cmd", []), + env=comp_data.get("env", {}), + description=comp_data.get("description"), + category=comp_data.get("category", "service"), + port=comp_data.get("port"), + health_path=comp_data.get("health_path"), + proxy_path=comp_data.get("proxy_path"), + schedule=comp_data.get("schedule"), + managed=comp_data.get("managed", False), + ) + return NodeRegistry(node=node, deployed=deployed) + + +class CastleMQTTClient: + """Async wrapper around paho-mqtt for castle mesh coordination.""" + + def __init__( + self, + local_hostname: str, + local_registry: NodeRegistry, + broker_host: str = "localhost", + broker_port: int = 1883, + loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + self._local_hostname = local_hostname + self._local_registry = local_registry + self._broker_host = broker_host + self._broker_port = broker_port + self._loop = loop or asyncio.get_event_loop() + + self._client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"castle-{local_hostname}", + clean_session=True, + ) + + # LWT: if we disconnect unexpectedly, broker publishes "offline" + self._client.will_set( + f"castle/{local_hostname}/status", + payload="offline", + qos=1, + retain=True, + ) + + self._connected = False + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + @property + def connected(self) -> bool: + return self._connected + + @property + def broker_host(self) -> str: + return self._broker_host + + @property + def broker_port(self) -> int: + return self._broker_port + + def _on_disconnect( + self, + client: mqtt.Client, + userdata: object, + flags: mqtt.DisconnectFlags, + rc: mqtt.ReasonCode, + properties: mqtt.Properties | None = None, + ) -> None: + self._connected = False + logger.info("Disconnected from MQTT broker (rc=%s)", rc) + + def _on_connect( + self, + client: mqtt.Client, + userdata: object, + flags: mqtt.ConnectFlags, + rc: mqtt.ReasonCode, + properties: mqtt.Properties | None = None, + ) -> None: + """Called when connected to broker — publish our state and subscribe.""" + if rc.is_failure: + logger.error("MQTT connect failed: %s", rc) + self._connected = False + return + + self._connected = True + logger.info("Connected to MQTT broker at %s:%d", self._broker_host, self._broker_port) + + # Publish our status as online (retained) + client.publish( + f"castle/{self._local_hostname}/status", + payload="online", + qos=1, + retain=True, + ) + + # Publish our registry (retained) + self.publish_registry(self._local_registry) + + # Subscribe to all castle nodes + client.subscribe("castle/+/registry", qos=1) + client.subscribe("castle/+/status", qos=1) + + def _on_message( + self, + client: mqtt.Client, + userdata: object, + msg: mqtt.MQTTMessage, + ) -> None: + """Process incoming MQTT messages from other nodes.""" + try: + parts = msg.topic.split("/") + if len(parts) != 3 or parts[0] != "castle": + return + + hostname = parts[1] + msg_type = parts[2] + + # Ignore our own messages + if hostname == self._local_hostname: + return + + payload = msg.payload.decode() + + if msg_type == "registry": + registry = _json_to_registry(payload) + mesh_state.update_node(hostname, registry) + # Notify SSE clients about mesh change + asyncio.run_coroutine_threadsafe( + broadcast("mesh", {"event": "node_updated", "hostname": hostname}), + self._loop, + ) + + elif msg_type == "status": + if payload == "offline": + mesh_state.set_offline(hostname) + asyncio.run_coroutine_threadsafe( + broadcast("mesh", {"event": "node_offline", "hostname": hostname}), + self._loop, + ) + + except Exception: + logger.exception("Error processing MQTT message on %s", msg.topic) + + def publish_registry(self, registry: NodeRegistry) -> None: + """Publish (or re-publish) our local registry.""" + self._local_registry = registry + self._client.publish( + f"castle/{self._local_hostname}/registry", + payload=_registry_to_json(registry), + qos=1, + retain=True, + ) + + async def start(self) -> None: + """Connect to the broker and start the network loop.""" + self._client.connect_async(self._broker_host, self._broker_port) + self._client.loop_start() + logger.info( + "MQTT client starting (broker=%s:%d)", + self._broker_host, + self._broker_port, + ) + + async def stop(self) -> None: + """Disconnect and stop the network loop.""" + # Publish offline status before disconnecting + self._client.publish( + f"castle/{self._local_hostname}/status", + payload="offline", + qos=1, + retain=True, + ) + self._client.loop_stop() + self._client.disconnect() + logger.info("MQTT client stopped") diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py new file mode 100644 index 0000000..cea89a4 --- /dev/null +++ b/castle-api/src/castle_api/nodes.py @@ -0,0 +1,115 @@ +"""Nodes router — discover and inspect mesh nodes.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, status + +from castle_api.config import get_registry, settings +from castle_api.mesh import mesh_state +from castle_api.models import ComponentSummary, MeshStatus, NodeDetail, NodeSummary + +router = APIRouter(tags=["nodes"]) + + +def _local_node_summary(registry: object) -> NodeSummary: + """Build a NodeSummary for the local node from the registry.""" + return NodeSummary( + hostname=registry.node.hostname, + gateway_port=registry.node.gateway_port, + deployed_count=len(registry.deployed), + service_count=sum(1 for d in registry.deployed.values() if d.port is not None), + is_local=True, + online=True, + is_stale=False, + ) + + +def _remote_node_summary(hostname: str, remote: object) -> NodeSummary: + """Build a NodeSummary from a RemoteNode.""" + reg = remote.registry + return NodeSummary( + hostname=hostname, + gateway_port=reg.node.gateway_port, + deployed_count=len(reg.deployed), + service_count=sum(1 for d in reg.deployed.values() if d.port is not None), + is_local=False, + online=remote.online, + is_stale=remote.is_stale, + last_seen=remote.last_seen, + ) + + +def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSummary]: + """Convert deployed components from a registry into ComponentSummary list.""" + summaries = [] + for name, d in registry.deployed.items(): + summaries.append( + ComponentSummary( + id=name, + description=d.description, + category=d.category, + runner=d.runner, + port=d.port, + health_path=d.health_path, + proxy_path=d.proxy_path, + managed=d.managed, + schedule=d.schedule, + node=hostname, + ) + ) + return summaries + + +@router.get("/mesh/status", response_model=MeshStatus) +def get_mesh_status(request: Request) -> MeshStatus: + """Get the current state of the mesh coordination layer.""" + mqtt_client = getattr(request.app.state, "mqtt_client", None) + mdns = getattr(request.app.state, "mdns", None) + + peers = list(mesh_state.all_nodes(include_stale=True).keys()) + + return MeshStatus( + enabled=settings.mqtt_enabled, + mqtt_connected=mqtt_client.connected if mqtt_client else False, + mqtt_broker_host=mqtt_client.broker_host if mqtt_client else None, + mqtt_broker_port=mqtt_client.broker_port if mqtt_client else None, + mdns_enabled=settings.mdns_enabled, + peer_count=len(peers), + peers=peers, + ) + + +@router.get("/nodes", response_model=list[NodeSummary]) +def list_nodes() -> list[NodeSummary]: + """List all known nodes (local + discovered remote).""" + registry = get_registry() + nodes = [_local_node_summary(registry)] + + for hostname, remote in mesh_state.all_nodes(include_stale=True).items(): + nodes.append(_remote_node_summary(hostname, remote)) + + return nodes + + +@router.get("/nodes/{hostname}", response_model=NodeDetail) +def get_node(hostname: str) -> NodeDetail: + """Get detailed info for a specific node.""" + registry = get_registry() + + # Local node + if hostname == registry.node.hostname: + summary = _local_node_summary(registry) + deployed = _deployed_to_summaries(registry, hostname) + return NodeDetail(**summary.model_dump(), deployed=deployed) + + # Remote node + remote = mesh_state.get_node(hostname) + if remote is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Node '{hostname}' not found", + ) + + summary = _remote_node_summary(hostname, remote) + deployed = _deployed_to_summaries(remote.registry, hostname) + return NodeDetail(**summary.model_dump(), deployed=deployed) diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index e10e7da..5a57633 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -2,20 +2,24 @@ from __future__ import annotations +import asyncio import shutil from pathlib import Path from fastapi import APIRouter, HTTPException, status +from castle_core.config import GENERATED_DIR from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec from castle_api.config import get_castle_root, get_registry +from castle_api.mesh import mesh_state from castle_api.health import check_all_health from castle_api.models import ( ComponentDetail, ComponentSummary, GatewayInfo, + GatewayRoute, StatusResponse, SystemdInfo, ) @@ -173,15 +177,21 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo @router.get("/components", response_model=list[ComponentSummary]) -def list_components() -> list[ComponentSummary]: - """List all components — deployed from registry, non-deployed from castle.yaml.""" +def list_components(include_remote: bool = False) -> list[ComponentSummary]: + """List all components — deployed from registry, non-deployed from castle.yaml. + + Pass ?include_remote=true to include components from remote mesh nodes. + """ registry = get_registry() + local_hostname = registry.node.hostname summaries: list[ComponentSummary] = [] seen: set[str] = set() # Deployed components from registry for name, deployed in registry.deployed.items(): - summaries.append(_summary_from_deployed(name, deployed)) + s = _summary_from_deployed(name, deployed) + s.node = local_hostname + summaries.append(s) seen.add(name) # Non-deployed from castle.yaml (if repo available) @@ -195,13 +205,17 @@ def list_components() -> list[ComponentSummary]: # Services not in registry for name, svc in config.services.items(): if name not in seen: - summaries.append(_summary_from_service(name, svc, config)) + s = _summary_from_service(name, svc, config) + s.node = local_hostname + summaries.append(s) seen.add(name) # Jobs not in registry for name, job in config.jobs.items(): if name not in seen: - summaries.append(_summary_from_job(name, job, config)) + s = _summary_from_job(name, job, config) + s.node = local_hostname + summaries.append(s) seen.add(name) # Backfill source from component refs for deployed items @@ -223,6 +237,7 @@ def list_components() -> list[ComponentSummary]: # "what software exists", services/jobs are "how it runs". for name, comp in config.components.items(): summary = _summary_from_component(name, comp, root) + summary.node = local_hostname # Skip if this exact category is already represented # (e.g. a deployed tool already in the list) if not any(s.id == name and s.category == summary.category for s in summaries): @@ -230,6 +245,27 @@ def list_components() -> list[ComponentSummary]: except FileNotFoundError: pass + # Remote components from mesh (local wins on name conflicts) + if include_remote: + for hostname, remote in mesh_state.all_nodes().items(): + for name, d in remote.registry.deployed.items(): + if name not in seen: + summaries.append( + ComponentSummary( + id=name, + description=d.description, + category=d.category, + runner=d.runner, + port=d.port, + health_path=d.health_path, + proxy_path=d.proxy_path, + managed=d.managed, + schedule=d.schedule, + node=hostname, + ) + ) + seen.add(name) + return summaries @@ -320,11 +356,42 @@ def get_gateway() -> GatewayInfo: deployed_count = len(registry.deployed) service_count = sum(1 for d in registry.deployed.values() if d.port is not None) managed_count = sum(1 for d in registry.deployed.values() if d.managed) + + # Local routes + routes: list[GatewayRoute] = [ + GatewayRoute( + path=d.proxy_path, + target_port=d.port, + component=name, + node=registry.node.hostname, + ) + for name, d in registry.deployed.items() + if d.proxy_path and d.port + ] + + # Remote routes from mesh (local paths take precedence) + local_paths = {r.path for r in routes} + for hostname, remote in mesh_state.all_nodes().items(): + for name, d in remote.registry.deployed.items(): + if d.proxy_path and d.port and d.proxy_path not in local_paths: + routes.append( + GatewayRoute( + path=d.proxy_path, + target_port=d.port, + component=name, + node=hostname, + ) + ) + + routes.sort(key=lambda r: r.path) + return GatewayInfo( port=registry.node.gateway_port, + hostname=registry.node.hostname, component_count=deployed_count, service_count=service_count, managed_count=managed_count, + routes=routes, ) @@ -333,3 +400,32 @@ def get_caddyfile() -> dict[str, str]: """Return the generated Caddyfile content.""" registry = get_registry() return {"content": generate_caddyfile_from_registry(registry)} + + +@router.post("/gateway/reload") +async def reload_gateway() -> dict[str, str]: + """Regenerate Caddyfile and reload Caddy.""" + registry = get_registry() + GENERATED_DIR.mkdir(parents=True, exist_ok=True) + caddyfile_path = GENERATED_DIR / "Caddyfile" + + # Include remote registries for cross-node routing + remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()} + caddyfile_path.write_text( + generate_caddyfile_from_registry(registry, remote_registries=remote_regs or None) + ) + + proc = await asyncio.create_subprocess_exec( + "systemctl", "--user", "reload", "castle-castle-gateway.service", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + + if proc.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Reload failed: {(stderr or b'').decode().strip()}", + ) + + return {"status": "ok"} diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 02fb303..4876090 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -80,7 +80,27 @@ class TestGateway: assert response.status_code == 200 data = response.json() assert data["port"] == 9000 + assert data["hostname"] == "test-node" # Registry has 1 deployed component (test-svc) assert data["component_count"] == 1 assert data["service_count"] == 1 assert data["managed_count"] == 1 + + def test_gateway_routes(self, client: TestClient) -> None: + """Returns proxy routes from deployed components.""" + response = client.get("/gateway") + data = response.json() + routes = data["routes"] + assert len(routes) == 1 + route = routes[0] + assert route["path"] == "/test-svc" + assert route["target_port"] == 19000 + assert route["component"] == "test-svc" + assert route["node"] == "test-node" + + def test_gateway_routes_sorted(self, client: TestClient) -> None: + """Routes are sorted by path.""" + response = client.get("/gateway") + data = response.json() + paths = [r["path"] for r in data["routes"]] + assert paths == sorted(paths) diff --git a/castle-api/tests/test_mesh.py b/castle-api/tests/test_mesh.py new file mode 100644 index 0000000..3240be9 --- /dev/null +++ b/castle-api/tests/test_mesh.py @@ -0,0 +1,104 @@ +"""Tests for MeshStateManager.""" + +import time + +from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry + +from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode + + +def _make_registry(hostname: str, deployed: dict | None = None) -> NodeRegistry: + return NodeRegistry( + node=NodeConfig(hostname=hostname, gateway_port=9000), + deployed=deployed or {}, + ) + + +class TestRemoteNode: + """RemoteNode staleness tracking.""" + + def test_fresh_node_not_stale(self) -> None: + node = RemoteNode(registry=_make_registry("a")) + assert not node.is_stale + + def test_old_node_is_stale(self) -> None: + node = RemoteNode( + registry=_make_registry("a"), + last_seen=time.time() - STALE_TTL_SECONDS - 1, + ) + assert node.is_stale + + +class TestMeshStateManager: + """MeshStateManager add/remove/stale operations.""" + + def test_update_and_get(self) -> None: + mgr = MeshStateManager() + reg = _make_registry("devbox") + mgr.update_node("devbox", reg) + node = mgr.get_node("devbox") + assert node is not None + assert node.registry.node.hostname == "devbox" + assert node.online is True + + def test_set_offline(self) -> None: + mgr = MeshStateManager() + mgr.update_node("devbox", _make_registry("devbox")) + mgr.set_offline("devbox") + node = mgr.get_node("devbox") + assert node is not None + assert node.online is False + + def test_remove_node(self) -> None: + mgr = MeshStateManager() + mgr.update_node("devbox", _make_registry("devbox")) + mgr.remove_node("devbox") + assert mgr.get_node("devbox") is None + + def test_remove_nonexistent_is_safe(self) -> None: + mgr = MeshStateManager() + mgr.remove_node("nope") # should not raise + + def test_all_nodes_excludes_stale(self) -> None: + mgr = MeshStateManager() + mgr.update_node("fresh", _make_registry("fresh")) + mgr._nodes["stale"] = RemoteNode( + registry=_make_registry("stale"), + last_seen=time.time() - STALE_TTL_SECONDS - 1, + ) + result = mgr.all_nodes() + assert "fresh" in result + assert "stale" not in result + + def test_all_nodes_includes_stale_when_requested(self) -> None: + mgr = MeshStateManager() + mgr._nodes["stale"] = RemoteNode( + registry=_make_registry("stale"), + last_seen=time.time() - STALE_TTL_SECONDS - 1, + ) + result = mgr.all_nodes(include_stale=True) + assert "stale" in result + + def test_prune_stale(self) -> None: + mgr = MeshStateManager() + mgr.update_node("fresh", _make_registry("fresh")) + mgr._nodes["stale"] = RemoteNode( + registry=_make_registry("stale"), + last_seen=time.time() - STALE_TTL_SECONDS - 1, + ) + pruned = mgr.prune_stale() + assert pruned == ["stale"] + assert mgr.get_node("stale") is None + assert mgr.get_node("fresh") is not None + + def test_update_replaces_existing(self) -> None: + mgr = MeshStateManager() + mgr.update_node("devbox", _make_registry("devbox")) + new_reg = _make_registry( + "devbox", + {"svc": DeployedComponent(runner="python", run_cmd=["svc"])}, + ) + mgr.update_node("devbox", new_reg) + node = mgr.get_node("devbox") + assert node is not None + assert "svc" in node.registry.deployed diff --git a/castle-api/tests/test_mqtt.py b/castle-api/tests/test_mqtt.py new file mode 100644 index 0000000..398d492 --- /dev/null +++ b/castle-api/tests/test_mqtt.py @@ -0,0 +1,96 @@ +"""Tests for MQTT client serialization logic.""" + +import json + +from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry + +from castle_api.mqtt_client import _json_to_registry, _registry_to_json + + +def _make_registry() -> NodeRegistry: + return NodeRegistry( + node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000), + deployed={ + "my-svc": DeployedComponent( + runner="python", + run_cmd=["uv", "run", "my-svc"], + env={"PORT": "9001", "SECRET_KEY": "super-secret"}, + description="My service", + category="service", + port=9001, + health_path="/health", + proxy_path="/my-svc", + managed=True, + ), + "my-job": DeployedComponent( + runner="command", + run_cmd=["my-job"], + category="job", + schedule="0 2 * * *", + ), + }, + ) + + +class TestRegistrySerialization: + """Round-trip serialization of NodeRegistry to/from JSON.""" + + def test_round_trip(self) -> None: + original = _make_registry() + json_str = _registry_to_json(original) + restored = _json_to_registry(json_str) + + assert restored.node.hostname == "tower" + assert restored.node.gateway_port == 9000 + + def test_deployed_components_preserved(self) -> None: + original = _make_registry() + restored = _json_to_registry(_registry_to_json(original)) + + assert "my-svc" in restored.deployed + svc = restored.deployed["my-svc"] + assert svc.runner == "python" + assert svc.port == 9001 + assert svc.health_path == "/health" + assert svc.proxy_path == "/my-svc" + assert svc.managed is True + + def test_job_fields_preserved(self) -> None: + original = _make_registry() + restored = _json_to_registry(_registry_to_json(original)) + + assert "my-job" in restored.deployed + job = restored.deployed["my-job"] + assert job.runner == "command" + assert job.schedule == "0 2 * * *" + assert job.category == "job" + + def test_optional_fields_omitted(self) -> None: + """Fields like port, health_path are None when not set.""" + reg = NodeRegistry( + node=NodeConfig(hostname="minimal"), + deployed={ + "bare": DeployedComponent(runner="command", run_cmd=["bare"]), + }, + ) + restored = _json_to_registry(_registry_to_json(reg)) + bare = restored.deployed["bare"] + assert bare.port is None + assert bare.health_path is None + assert bare.proxy_path is None + assert bare.schedule is None + assert bare.managed is False + + def test_no_secrets_in_payload(self) -> None: + """env vars, run_cmd, and castle_root must not appear in MQTT payload.""" + original = _make_registry() + json_str = _registry_to_json(original) + data = json.loads(json_str) + + # No castle_root in node + assert "castle_root" not in data["node"] + + # No env or run_cmd in any component + for name, comp in data["deployed"].items(): + assert "env" not in comp, f"{name} has env in MQTT payload" + assert "run_cmd" not in comp, f"{name} has run_cmd in MQTT payload" diff --git a/castle-api/tests/test_nodes.py b/castle-api/tests/test_nodes.py new file mode 100644 index 0000000..98f3201 --- /dev/null +++ b/castle-api/tests/test_nodes.py @@ -0,0 +1,91 @@ +"""Tests for nodes endpoints.""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry + +from castle_api.mesh import MeshStateManager + + +class TestNodesList: + """GET /nodes endpoint tests.""" + + def test_returns_local_node(self, client: TestClient) -> None: + """Always returns the local node.""" + response = client.get("/nodes") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + local = data[0] + assert local["hostname"] == "test-node" + assert local["is_local"] is True + assert local["online"] is True + + def test_local_node_counts(self, client: TestClient) -> None: + """Local node has correct deployment counts.""" + response = client.get("/nodes") + data = response.json() + local = data[0] + assert local["deployed_count"] == 1 # test-svc + assert local["service_count"] == 1 + + def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None: + """Remote nodes from mesh state are included.""" + import castle_api.mesh as mesh_mod + + original = mesh_mod.mesh_state + try: + mgr = MeshStateManager() + remote_reg = NodeRegistry( + node=NodeConfig(hostname="devbox", gateway_port=9000), + deployed={ + "remote-svc": DeployedComponent( + runner="python", + run_cmd=["svc"], + port=9050, + category="service", + ), + }, + ) + mgr.update_node("devbox", remote_reg) + mesh_mod.mesh_state = mgr + + # Also patch the reference in the nodes module + import castle_api.nodes as nodes_mod + + nodes_mod.mesh_state = mgr + + response = client.get("/nodes") + data = response.json() + hostnames = [n["hostname"] for n in data] + assert "devbox" in hostnames + devbox = next(n for n in data if n["hostname"] == "devbox") + assert devbox["is_local"] is False + assert devbox["deployed_count"] == 1 + finally: + mesh_mod.mesh_state = original + import castle_api.nodes as nodes_mod2 + + nodes_mod2.mesh_state = original + + +class TestNodeDetail: + """GET /nodes/{hostname} endpoint tests.""" + + def test_local_node_detail(self, client: TestClient) -> None: + """Returns local node detail with deployed components.""" + response = client.get("/nodes/test-node") + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == "test-node" + assert data["is_local"] is True + assert len(data["deployed"]) == 1 + assert data["deployed"][0]["id"] == "test-svc" + assert data["deployed"][0]["node"] == "test-node" + + def test_unknown_node_returns_404(self, client: TestClient) -> None: + """Returns 404 for unknown hostname.""" + response = client.get("/nodes/nonexistent") + assert response.status_code == 404 diff --git a/castle.yaml b/castle.yaml index fe6c4c0..724b8db 100644 --- a/castle.yaml +++ b/castle.yaml @@ -215,6 +215,23 @@ services: caddy: path_prefix: /api + castle-mqtt: + description: MQTT broker for inter-node mesh coordination + run: + runner: container + image: eclipse-mosquitto:2 + ports: + 1883: 1883 + volumes: + - /data/castle/castle-mqtt/config:/mosquitto/config + - /data/castle/castle-mqtt/data:/mosquitto/data + manage: + systemd: {} + expose: + http: + internal: + port: 1883 + jobs: protonmail-sync: component: protonmail diff --git a/cli/src/castle_cli/commands/deploy.py b/cli/src/castle_cli/commands/deploy.py index 65baf56..b69a2d5 100644 --- a/cli/src/castle_cli/commands/deploy.py +++ b/cli/src/castle_cli/commands/deploy.py @@ -221,7 +221,7 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]: cmd[0] = resolved return cmd case "container": - runtime = shutil.which("podman") or shutil.which("docker") or "podman" + runtime = shutil.which("docker") or shutil.which("podman") or "docker" image_name = run.image.split("/")[-1].split(":")[0] cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"] for container_port, host_port in run.ports.items(): diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 4777187..1518f94 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -6,23 +6,49 @@ from castle_core.config import GENERATED_DIR, STATIC_DIR from castle_core.registry import NodeRegistry -def generate_caddyfile_from_registry(registry: NodeRegistry) -> str: +def generate_caddyfile_from_registry( + registry: NodeRegistry, + remote_registries: dict[str, NodeRegistry] | None = None, +) -> str: """Generate Caddyfile from the node registry. Static files served from ~/.castle/static/castle-app/. No repo-relative paths. + + If remote_registries is provided, cross-node routes are added for + remote services whose proxy_path doesn't conflict with local ones. """ lines = [f":{registry.node.gateway_port} {{"] + # Track local proxy paths for precedence + local_paths: set[str] = set() + for name, deployed in registry.deployed.items(): if not deployed.proxy_path or not deployed.port: continue + local_paths.add(deployed.proxy_path) lines.append(f" handle_path {deployed.proxy_path}/* {{") lines.append(f" reverse_proxy localhost:{deployed.port}") lines.append(" }") lines.append("") + # Remote routes (cross-node) — local paths take precedence + if remote_registries: + for hostname, remote_reg in remote_registries.items(): + for name, deployed in remote_reg.deployed.items(): + if not deployed.proxy_path or not deployed.port: + continue + if deployed.proxy_path in local_paths: + continue + + local_paths.add(deployed.proxy_path) + lines.append(f" # {name} on {hostname}") + lines.append(f" handle_path {deployed.proxy_path}/* {{") + lines.append(f" reverse_proxy {hostname}:{deployed.port}") + lines.append(" }") + lines.append("") + # SPA from static dir static_app = STATIC_DIR / "castle-app" if (static_app / "index.html").exists(): diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index f912b11..51cc186 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -113,3 +113,64 @@ class TestCaddyfileFromRegistry: assert "reverse_proxy localhost:9001" in caddyfile assert "handle_path /svc-b/*" in caddyfile assert "reverse_proxy localhost:9002" in caddyfile + + +class TestCaddyfileRemoteRegistries: + """Tests for cross-node routing in Caddyfile.""" + + def test_remote_routes_included(self) -> None: + """Remote services get reverse_proxy entries to their hostname.""" + local = _make_registry( + deployed={ + "local-svc": DeployedComponent( + runner="python", run_cmd=["svc"], port=9001, proxy_path="/local" + ), + } + ) + remote = _make_registry( + deployed={ + "remote-svc": DeployedComponent( + runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote" + ), + } + ) + remote.node.hostname = "devbox" + caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote}) + assert "reverse_proxy localhost:9001" in caddyfile + assert "reverse_proxy devbox:9050" in caddyfile + assert "handle_path /remote/*" in caddyfile + + def test_local_takes_precedence(self) -> None: + """If local and remote use the same path, local wins.""" + local = _make_registry( + deployed={ + "svc": DeployedComponent( + runner="python", run_cmd=["svc"], port=9001, proxy_path="/api" + ), + } + ) + remote = _make_registry( + deployed={ + "svc": DeployedComponent( + runner="python", run_cmd=["svc"], port=9001, proxy_path="/api" + ), + } + ) + remote.node.hostname = "devbox" + caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote}) + assert "reverse_proxy localhost:9001" in caddyfile + assert "devbox" not in caddyfile + + def test_no_remote_when_none(self) -> None: + """No remote routes when remote_registries is None.""" + local = _make_registry( + deployed={ + "svc": DeployedComponent( + runner="python", run_cmd=["svc"], port=9001, proxy_path="/api" + ), + } + ) + caddyfile = generate_caddyfile_from_registry(local, remote_registries=None) + assert "reverse_proxy localhost:9001" in caddyfile + # Only one reverse_proxy line + assert caddyfile.count("reverse_proxy") == 1 diff --git a/docs/design.md b/docs/design.md index 2d17808..851665b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -243,26 +243,45 @@ Three interfaces expose the registry: ### Coordination Layer -*Partially built. This section describes the target architecture.* - Coordination handles discovery and communication — both between components on a single node and across multiple Castle nodes. -**Intra-node coordination** (current): +**Intra-node coordination:** - Components find each other through the gateway (path-based routing) or direct port access via env vars. - The registry (CLI/API) provides discoverability. - No service mesh or message broker required for basic operation. -**Inter-node coordination** (future): +**Inter-node coordination:** - Each Castle node runs the API, which exposes its component registry. -- Nodes discover each other via MQTT retained messages or mDNS/DNS-SD - (Avahi) for LAN environments. +- Nodes discover each other via MQTT retained messages and mDNS/DNS-SD + (python-zeroconf) for LAN environments. - The gateway on each node can proxy to services on other nodes, preserving path-based routing. Components don't know which node they're talking to. - MQTT provides pub/sub messaging for events, status, and coordination across nodes. +- All mesh features are opt-in: `CASTLE_API_MQTT_ENABLED=true` and + `CASTLE_API_MDNS_ENABLED=true`. Single-node works without them. + +**MQTT topics:** +- `castle/{hostname}/registry` — retained JSON, full NodeRegistry. + Published on connect and after `castle deploy`. +- `castle/{hostname}/status` — `"online"` (retained) / `"offline"` (LWT). + LWT ensures nodes are marked offline if they disconnect unexpectedly. + +**MeshStateManager** (`castle_api.mesh`) holds remote NodeRegistry +instances in memory, indexed by hostname. 5-minute staleness TTL. +Updated by the MQTT client on incoming messages. Read by API endpoints +to serve cross-node data. + +**mDNS** (`castle_api.mdns`) advertises `_castle._tcp` and browses +for peers and `_mqtt._tcp` broker. Uses python-zeroconf. Properties +include hostname, gateway_port, api_port. + +**Caddyfile generation** supports `remote_registries` — cross-node +routes are added with `reverse_proxy {hostname}:{port}` entries. +Local paths always take precedence. **Why MQTT over custom gossip:** - Standard protocol, every language has a client library. @@ -272,12 +291,74 @@ components on a single node and across multiple Castle nodes. - Mosquitto is a single binary, simple to run as a Castle component. **Why mDNS/DNS-SD as a complement:** -- Zero-config LAN discovery via Avahi (already on most Linux systems). +- Zero-config LAN discovery via python-zeroconf. - Each node advertises `_castle._tcp` — standard tooling works - (`avahi-browse`). + (`avahi-browse`, `dns-sd`). - Good for bootstrapping: find the MQTT broker without hardcoding its address. +### Dashboard + +The web dashboard (`castle-app`) is a React SPA served by Caddy from +`~/.castle/static/castle-app/`. It talks to `castle-api` via the +gateway proxy at `/api`. + +**Layout:** + +``` +Castle +Personal software platform + +[tower] [devbox (3)] ← NodeBar (hidden in single-node) + +┌─────────────────────────────────────────────────────────┐ +│ Gateway · tower · port 9000 · 4 routes [Reload] [Caddyfile] │ +│ │ +│ Path Component Port Node Health│ +│ /api castle-api 9020 tower ● up │ +│ /central-context central-context 9001 tower ● up │ +│ /notifications notification-bridge 9002 tower ● up │ +│ /devbox-api devbox-api 9020 devbox ● up │ +└─────────────────────────────────────────────────────────┘ + +Services · Long-running daemons managed by systemd +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ castle-api │ │ central-ctx │ │ notif-bridge │ +│ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │ +│ :9020 │ │ :9001 │ │ :9002 │ +└──────────────┘ └──────────────┘ └──────────────┘ + +Jobs · Scheduled tasks with cron timers + Name Schedule Timer + protonmail-sync */5 * * * * active + backup-collect 0 2 * * * active + +Tools · CLI utilities installed to PATH + Name Status + pdf2md ● installed + gpt ● installed +``` + +**Key components:** +- **GatewayPanel** — Route table with live health badges, reload button, + collapsible Caddyfile viewer. Node column appears when multi-node. +- **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node + mode. Each node links to `/node/{hostname}`. +- **ServiceSection** — Service cards in a responsive grid. +- **JobSection** — Sortable table of scheduled tasks. +- **ToolSection** — Sortable table of CLI tools with install/uninstall. + +**Real-time updates:** +- SSE stream at `/stream` pushes `health`, `service-action`, and `mesh` + events. React Query caches are updated or invalidated on each event. +- Health polling runs every 10s server-side; SSE delivers updates to all + connected dashboard clients. + +**Multi-node behavior:** +- NodeBar appears when `GET /nodes` returns >1 node. +- GatewayPanel shows a "Node" column when routes span multiple nodes. +- `/node/{hostname}` page shows a specific node's deployed components. + ## Component Contract Every Castle component, regardless of language, must satisfy a minimal @@ -439,14 +520,27 @@ What exists today: - **Tools** — ~15 CLI utilities (pdf2md, docx2md, search, gpt, etc.) - **Manifest** — `castle.yaml` with typed Pydantic models +- **Mesh infrastructure** — MQTT client (paho-mqtt), mDNS discovery + (python-zeroconf), MeshStateManager, all wired into API lifespan. + Opt-in via `CASTLE_API_MQTT_ENABLED` / `CASTLE_API_MDNS_ENABLED`. +- **Node API** — `GET /nodes`, `GET /nodes/{hostname}` endpoints. + `GET /components?include_remote=true` for cross-node component listing. +- **Gateway panel** — Dedicated UI showing route table, health per route, + reload button, Caddyfile viewer. Cross-node routes shown when multi-node. +- **Node-aware UI** — NodeBar (hidden single-node), node detail page, + mesh SSE events for live node discovery updates. +- **Cross-node routing** — Caddyfile generator accepts remote registries, + generates `reverse_proxy {hostname}:{port}` entries. + What doesn't exist yet: - **Multi-language support** — Rust and Go components (the abstractions support them via `command` runner, but no examples exist yet) -- **Inter-node coordination** — MQTT broker, node discovery, cross-node - routing - **Build automation** — Castle records build specs but doesn't orchestrate builds (each project builds independently) +- **Mosquitto deployment** — Registered in castle.yaml as `castle-mqtt` + container service, but not yet deployed (needs `castle deploy` + + container runtime) ## Technology Map @@ -466,7 +560,7 @@ What doesn't exist yet: | Testing | pytest (Python), Vitest (TS) | Active | | Secrets | `~/.castle/secrets/` file-based | Active | | Data storage | Filesystem (`/data/castle/`) | Active | -| Messaging | MQTT (Mosquitto) | Planned | -| Node discovery | mDNS/Avahi + MQTT | Planned | +| Messaging | MQTT (paho-mqtt client, Mosquitto broker) | Active (opt-in) | +| Node discovery | mDNS (python-zeroconf) + MQTT | Active (opt-in) | | Rust packaging | cargo | Planned | | Go packaging | go build | Planned | diff --git a/uv.lock b/uv.lock index 4d1c964..7c913eb 100644 --- a/uv.lock +++ b/uv.lock @@ -48,8 +48,10 @@ dependencies = [ { name = "castle-core" }, { name = "fastapi" }, { name = "httpx" }, + { name = "paho-mqtt" }, { name = "pydantic-settings" }, { name = "uvicorn" }, + { name = "zeroconf" }, ] [package.dev-dependencies] @@ -65,8 +67,10 @@ requires-dist = [ { name = "castle-core", editable = "core" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "paho-mqtt", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, + { name = "zeroconf", specifier = ">=0.131.0" }, ] [package.metadata.requires-dev] @@ -234,6 +238,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -325,6 +338,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -581,3 +603,45 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e wheels = [ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] + +[[package]] +name = "zeroconf" +version = "0.148.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/09/394a24a633645063557c5144c9abb694699df76155dcab5e1e3078dd1323/zeroconf-0.148.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ad889929bdc3953530546a4a2486d8c07f5a18d4ef494a98446bf17414897a7", size = 1714465, upload-time = "2025-10-05T01:08:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/3d/db/f57c4bfcceb67fe474705cbadba3f8f7a88bdc95892e74ba6d85e24d28c3/zeroconf-0.148.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:29fb10be743650eb40863f1a1ee868df1869357a0c2ab75140ee3d7079540c1e", size = 1683877, upload-time = "2025-10-05T01:08:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/b3e2d39c40802a8cc9415357acdb76ff01bc29e25ffaa811771b6fffc428/zeroconf-0.148.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2995e74969c577461060539164c47e1ba674470585cb0f954ebeb77f032f3c2", size = 2122874, upload-time = "2025-10-05T01:08:32.11Z" }, + { url = "https://files.pythonhosted.org/packages/66/eb/0ac2bf51d58d47cfa854628036a7ad95544a1802bc890f3d69649dc35e46/zeroconf-0.148.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5be50346efdc20823f9d68d8757612767d11ceb8da7637d46080977b87912551", size = 1922164, upload-time = "2025-10-05T01:08:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/59/ff/c7372507c7e25ad3499fe08d4678deb1ed41c57f78ff5df43bd2d4d98cfc/zeroconf-0.148.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc88fd01b5552ffb4d5bc551d027ac28a1852c03ceab754d02bd0d5f04c54e85", size = 2214119, upload-time = "2025-10-05T01:08:35.478Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/57f0889f47923b4fa4364b62b7b3ffc347f6bad09a25ce4e578b8991a86d/zeroconf-0.148.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:5af260c74187751c0df6a40f38d6fd17cb8658a734b0e1148a86084b71c1977c", size = 2137609, upload-time = "2025-10-05T00:21:15.953Z" }, + { url = "https://files.pythonhosted.org/packages/3b/33/9cb5558695c1377941dbb10a5591f88a787f9e1fba130642693d5c80663b/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6078c73a76d49ba969ca2bb7067e4d58ebd2b79a5f956e45c4c989b11d36e03", size = 2154314, upload-time = "2025-10-05T01:08:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/38/06/cf4e17a86922b4561d85d36f50f1adada1328723e882d95aa42baefa5479/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3e686bf741158f4253d5e0aa6a8f9d34b3140bf5826c0aca9b906273b9c77a5f", size = 2004973, upload-time = "2025-10-05T01:08:39.825Z" }, + { url = "https://files.pythonhosted.org/packages/a4/61/937a405783317639cd11e7bfab3879669896297b6ca2edfb0d2d9c8dbb30/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:52d6ac06efe05a1e46089cfde066985782824f64b64c6982e8678e70b4b49453", size = 2237775, upload-time = "2025-10-05T01:08:41.535Z" }, + { url = "https://files.pythonhosted.org/packages/03/43/a1751c4b63e108a2318c2266e5afdd9d62292250aa8b1a8ed1674090885c/zeroconf-0.148.0-cp313-cp313-win32.whl", hash = "sha256:b9ba58e2bbb0cff020b54330916eaeb8ee8f4b0dde852e84f670f4ca3a0dd059", size = 1291073, upload-time = "2025-10-05T01:08:43.757Z" }, + { url = "https://files.pythonhosted.org/packages/5e/69/5f4f9eb14506e2afd2d423472e566d5455334d0c8740b933914d642bdbb5/zeroconf-0.148.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee3fcc2edcc04635cf673c400abac2f0c22c9786490fbfb971e0a860a872bf26", size = 1528568, upload-time = "2025-10-05T01:08:45.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" }, + { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" }, + { url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" }, + { url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" }, + { url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" }, + { url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" }, + { url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" }, + { url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" }, +]