Newer
Older
navi-1 / hive / app.py
"""hive — the swarm address book.

A tiny standalone service for the main server: Navi instances across the
network announce themselves here (name, uuid, address, machine info), and
agents ask this book who is currently alive and where. Later this grows into
a mini knowledge base about the fleet (the free-form ``meta`` field is the seed).

Not part of the Navi server — it never starts by default. Run it manually on
the main server (see hive/README.md):

    .venv/bin/uvicorn hive.app:app --host 0.0.0.0 --port 8087

Auth: the shared swarm key (PSK). Every announce/peers request must carry the
X-Swarm-Key header matching ``.swarm-key`` (or ``.swarm-key.previous`` — the
rotation window). /health is open.
"""

from __future__ import annotations

import hmac
import os
from pathlib import Path

from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel, Field

from hive.db import HiveDB

_KEY_FILE = Path(os.environ.get("HIVE_KEY_FILE", ".swarm-key"))
# Rotation window: the previous key keeps working until it is deleted.
_PREVIOUS_KEY_FILE = _KEY_FILE.with_name(_KEY_FILE.name + ".previous")
_DB_PATH = os.environ.get("HIVE_DB", "hive.db")
_TTL_SEC = int(os.environ.get("HIVE_TTL_SEC", "300"))

app = FastAPI(title="navi hive", description="Swarm address book for Navi instances.")
_db = HiveDB(_DB_PATH)


def _load_keys() -> list[str]:
    keys: list[str] = []
    for p in (_KEY_FILE, _PREVIOUS_KEY_FILE):
        try:
            value = p.read_text(encoding="utf-8").strip()
        except OSError:
            continue
        if value:
            keys.append(value)
    return keys


def _check_key(x_swarm_key: str | None) -> None:
    keys = _load_keys()
    if not keys:
        raise HTTPException(status_code=503, detail="hive has no swarm key configured")
    if not x_swarm_key:
        raise HTTPException(status_code=401, detail="X-Swarm-Key header required")
    if not any(hmac.compare_digest(x_swarm_key, k) for k in keys):
        raise HTTPException(status_code=403, detail="invalid swarm key")


class AnnouncePayload(BaseModel):
    name: str = Field(min_length=1, max_length=64)
    instance_id: str = Field(min_length=8, max_length=64)
    version: str = Field(default="", max_length=32)
    port: int = Field(ge=1, le=65535)
    meta: dict = Field(default_factory=dict)


@app.get("/health")
async def health() -> dict:
    return {"status": "ok", "hive": True, "machines": len(_db.list_peers(_TTL_SEC))}


@app.post("/announce")
async def announce(
    payload: AnnouncePayload,
    request: Request,
    x_swarm_key: str | None = Header(default=None),
) -> dict:
    _check_key(x_swarm_key)
    # The source IP is the reliable address for a floating-IP network; the
    # announced payload only supplies the port (and could not be trusted for
    # the host anyway — anyone with the key may announce from anywhere).
    host = request.client.host if request.client else None
    _db.upsert(
        instance_id=payload.instance_id,
        name=payload.name,
        host=host,
        port=payload.port,
        version=payload.version,
        meta=payload.meta,
    )
    return {"ok": True, "host": host}


@app.get("/peers")
async def peers(x_swarm_key: str | None = Header(default=None)) -> dict:
    _check_key(x_swarm_key)
    return {"peers": _db.list_peers(_TTL_SEC), "ttl_sec": _TTL_SEC}