pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / hub.py

51,758 bytes · last changed 2026-10-28 in Merge main (agents beside the swarm, 0.18.0) into identity without a shell; this is release 0.19.0

1"""The hub: where a swarm's services find each other.
2
3A hub holds no trees. It keeps a list of peers, polls each one's
4``/capabilities`` and ``/health``, and answers the same ``/intent`` and
5``/run`` a service does by forwarding to the peer that holds the matching
6tree. Each peer carries a ``link`` of ``up``, ``stale`` or ``down``: the one
7field an operator reads to know whether the swarm is whole.
8
9Endpoints:
10
11``POST /register`` ``{"url": "http://host:port", "name": "..."}``
12``GET /peers`` every peer with its link and trees
13``GET /capabilities`` the union of every peer's trees
14``GET /health`` ``{"status": "ok", "peers": n, "up": n, "trees": n}``
15``POST /intent`` forwarded to the peer whose tree matches best
16``POST /run`` forwarded to the peer holding ``tree``
17``POST /resume`` forwarded to the peer named in the snapshot
18``GET /gaps`` every peer's gaps, with the peer that saw them
19``GET /unanswered`` sentences no peer answered, each with a proposed tree
20``GET /suggestions`` what agents beside the swarm sent this hub (``POST`` to send)
21``GET /`` the console; ``GET /api`` is the JSON index
22
23With ``--chat``, a hub also keeps a lobby at ``/chat``, where people meet a
24stranger here or at a hub it follows, or a bot made of the trees it routes
25to; see :mod:`pyrtan.chat`. ``--chat-public`` opens the lobby to callers
26without the hub's token, who meet people only: a bot runs trees, and the
27token is what guards them.
28
29Federated (``--federate``), a hub also answers ``GET /actor``, ``POST
30/inbox``, ``POST /follow`` and ``/unfollow`` (operator), ``GET /following``,
31``/followers`` and ``/outbox``, ``GET`` and ``POST /networks``, ``POST
32/join`` and ``/leave`` (operator: the networks it is in), and routes to
33the hubs it follows; see :mod:`pyrtan.federation`.
34
35A service that holds several trees and a hub that routes between services
36answer the same requests, so a client need not know which it is talking to,
37and a swarm can start as one process and split later without the callers
38changing. A hub given a token requires it of callers (``/register``
39included, so a stranger cannot join the swarm) and presents it to peers.
40
41**Identities.** A request signed with a key (``X-Pyrtan-Key`` and its
42signature, ``_meta/identity.md`` §7) is checked against the hub's
43*audience*, its own id, which ``GET /api`` names. On a hub with a token,
44it may do what the hub's grants say its root may (``ask``, ``chat``,
45``run``, ``operate``; see :mod:`pyrtan.grants`) and nothing else; on a hub
46without one it only says who is asking. A member that follows with a key
47whose root holds ``run`` is trusted as one that joined with the token,
48and is dropped when its grant, its note or its root's document says it
49may no longer be. ``GET``/``POST /allow`` and ``POST /disallow`` (operator)
50keep the grants; ``POST /identity`` takes a granted root's newer identity
51document from anyone, since it is signed; ``GET
52/.well-known/pyrtan/id/<56>.json`` serves the documents the hub holds.
53With ``--names DIR`` a hub is also its network's name provider: it serves
54the claims in DIR (``pyrtan names init`` makes a repository of them) at
55``/.well-known/pyrtan/name/<name>.json``, ``names.json``, and their
56identity documents, for its own domain.
57"""
58
59from __future__ import annotations
60
61import datetime as _dt
62import json
63import threading
64from pathlib import Path
65import time
66from dataclasses import dataclass, field
67from typing import Any, Iterable, Iterator
68
69from . import __version__
70from .chat import Lobby, is_chat_path
71from .lan import Lan
72from .federation import MAX_HOPS, Federation, Remote, default_network, first_live_hub, parse_invite, rendezvous_hubs, sign, snapshot_hubs
73from . import identity
74from .grants import Grants, ability_for, parse_may
75from .identity import IdentityError
76from .grow import Unanswered, no_match_outcome
77from .suggestions import Suggestions
78from .intent import Trigger, read
79from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER
80from .registry import Registry
81from .runtime import Outcome
82from .tree import Tree
83from .service import Server, Service, bearer_ok, http_json, outcome_from_dict, signed_path
84from .store import StoreError, check_key
85
86STALE_AFTER = 30.0
87DOWN_AFTER = 90.0
88
89
90@dataclass
91class Peer:
92 url: str
93 name: str = ""
94 trees: list[dict[str, Any]] = field(default_factory=list)
95 last_ok: float = 0.0
96 last_error: str | None = None
97 registered: float = field(default_factory=time.time)
98 kind: str = "service"
99
100 @property
101 def link(self) -> str:
102 if self.last_ok == 0.0:
103 return "down" if self.last_error else "pending"
104 age = time.time() - self.last_ok
105 if age < STALE_AFTER:
106 return "up"
107 if age < DOWN_AFTER:
108 return "stale"
109 return "down"
110
111 def to_dict(self) -> dict[str, Any]:
112 return {"url": self.url, "name": self.name, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error}
113
114
115class Local:
116 """Trees the hub holds itself (``pyrtan hub --trees DIR``): a service in
117 the same process, reached without HTTP. A swarm that is one process."""
118
119 url = "local"
120 kind = "local"
121 link = "up"
122 last_ok = 0.0
123 last_error: str | None = None
124
125 def __init__(self, service: Service) -> None:
126 self.service = service
127 self.name = service.name
128
129 @property
130 def trees(self) -> list[dict[str, Any]]:
131 return self.service.capabilities()["trees"]
132
133 def to_dict(self) -> dict[str, Any]:
134 return {"url": self.url, "name": self.name, "kind": "local", "link": "up", "trees": [t["name"] for t in self.trees], "last_ok": time.time(), "last_error": None}
135
136
137class _HubResolver:
138 """What a local tree's ``delegate`` reaches: the hub itself, in-process."""
139
140 def __init__(self, hub: "Hub") -> None:
141 self.hub = hub
142
143 def run_tree(self, name: str, inputs: dict[str, Any], depth: int) -> Outcome:
144 _, body = self.hub.run({"tree": name, "inputs": inputs, "depth": depth})
145 return outcome_from_dict(body, name)
146
147 def run_intent(self, text: str, depth: int) -> Outcome:
148 _, body = self.hub.intent({"text": text, "depth": depth})
149 return outcome_from_dict(body, "")
150
151
152class Hub:
153 def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, chat_reach: int = 0, suggestions: str | Path | None = None, names: str | Path | None = None, names_domain: str | None = None) -> None:
154 self.name = name
155 self.names_dir = Path(names) if names else None
156 self._names_domain = names_domain
157 self._names_cache: tuple[tuple, Any] | None = None
158 self.grants = grants if grants is not None else Grants()
159 self.grants.on_change = self._grant_changed
160 self._audience = audience
161 self.lobby = Lobby(self, typing=chat_typing, reach=chat_reach) if chat or chat_public else None
162 self.chat_public = chat_public # callers without the token may chat, with people only
163 self.lan: Lan | None = None # hubs heard on the local network (--lan)
164 self.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them
165 self.poll_every = poll_every
166 self.token = token or None
167 self.peers: dict[str, Peer] = {}
168 self.local: Local | None = None
169 if local is not None:
170 self.hold(local)
171 self.federation: Federation | None = None
172 self.unanswered = Unanswered(path=unanswered)
173 self.suggestions = Suggestions(path=suggestions) # what agents sent this hub; a hub keeps its own, it does not collect its peers'
174 self._reports: list[dict[str, Any]] = []
175 self._reports_at = 0.0
176 self.started = time.time()
177 self._lock = threading.RLock()
178 self._stop = threading.Event()
179 self._thread: threading.Thread | None = None
180
181 # ------------------------------------------------------------ federation
182
183 def federate(self, advertise: str, *, secret: str | None = None, state: str | None = None, open_follows: bool = True, listed: bool = False, store: str | None = None, replicas: int = 2, report_gaps: bool = False) -> Federation:
184 """Give this hub an identity other hubs can follow. ``advertise`` is
185 the URL they reach it at. ``store`` is where it keeps blobs, its own
186 and others'; ``replicas`` how many keepers each of its own should have."""
187 auto = {
188 "directory": lambda: self.federation.directory_backup() if self.federation else [],
189 "gaps": lambda: self.gaps()["gaps"],
190 "unanswered": lambda: self.unanswered_all()["unanswered"],
191 }
192 self.federation = Federation(advertise, name=self.name, secret=secret, state=state, open_follows=open_follows, capabilities=self.announced_capabilities, listed=listed, store=store, replicas=replicas, auto_keys=auto, report_gaps=report_gaps, own_reports=self.gap_reports)
193 self.federation.handout = self.handout_trees
194 self.federation.chat = self.lobby
195 return self.federation
196
197 # ------------------------------------------------------------ identities
198
199 @property
200 def audience(self) -> str | None:
201 """The id a key-signed request to this hub must be signed for."""
202 return self.federation.id if self.federation is not None else self._audience
203
204 @property
205 def names_domain(self) -> str | None:
206 """The domain this hub's names are for: --names-domain, else its own host."""
207 if self._names_domain:
208 return self._names_domain
209 from urllib.parse import urlsplit
210 return urlsplit(self.audience).hostname if self.audience else None
211
212 def names(self) -> "Any":
213 """The directory of names this hub serves, read again whenever a file in it changes."""
214 from .names import load_directory
215 if self.names_dir is None or not self.names_domain:
216 return None
217 try:
218 stamp = tuple(sorted((e.name, e.stat().st_mtime_ns, e.stat().st_size) for e in self.names_dir.iterdir()))
219 except OSError:
220 return None
221 if self._names_cache is None or self._names_cache[0] != stamp:
222 self._names_cache = (stamp, load_directory(self.names_dir, self.names_domain))
223 return self._names_cache[1]
224
225 def _signer(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str]) -> tuple[identity.Signer | None, set[str], tuple[int, Any] | None]:
226 """For a key-signed request: who signed it, what they may do here, or the refusal."""
227 if not identity.has_key_signature(headers):
228 return None, set(), None
229 if self.audience is None:
230 return None, set(), (401, {"status": "error", "reason": "unsigned", "say": "This hub has no id to check a key signature against; start it with --advertise."})
231 try:
232 signer = identity.verify_request(self.audience, method, path, body if method == "POST" else None, headers)
233 except IdentityError as e:
234 return None, set(), (401, {"status": "error", "reason": "unsigned", "say": f"The key signature does not hold: {e}."})
235 may = self.grants.may(signer)
236 needed = ability_for(method, path)
237 if needed == "chat" and self.chat_public:
238 needed = None # a public lobby takes a key it grants nothing as a guest, as it takes a caller with no key
239 if self.token and needed is not None and needed not in may:
240 granted = f"it may {', '.join(sorted(may))}" if may else "this hub grants it nothing"
241 return signer, may, (403, {"status": "error", "reason": "not-granted", "say": f"{identity.short(signer.root)} may not {needed} here: {granted}."})
242 return signer, may, None
243
244 def _grant_changed(self, root: str) -> None:
245 """Drop the members that followed as ``root`` and may no longer be what they are."""
246 fed = self.federation
247 if fed is None:
248 return
249 for remote in [r for r in list(fed.followers.values()) if r.kind == "member" and r.identity == root]:
250 note_ok = remote.note is None or identity.check_device(remote.note, root=root) is None
251 revoked = remote.note is not None and (doc := self.grants.document(root)) is not None and identity.device_status(doc, remote.device or "") == "revoked"
252 lost_run = remote.trusted_by == "key" and not self.grants.still(root, remote.device or "", remote.note, "run")
253 if not note_ok or revoked or lost_run:
254 fed.drop_follower(remote.url)
255
256 def handout_trees(self) -> dict[str, Tree]:
257 """The trees a trusted member may run: the ones this hub holds itself,
258 and the ones given it to hand out (``--handout``), by name. A member
259 is a browser: a tree that reads a file, runs a program, opens a
260 serial line, talks to a broker or drives a browser stays here."""
261 out: dict[str, Tree] = {}
262 for reg in (self.local.service.registry if self.local is not None else None, self.handout_registry):
263 if reg is None:
264 continue
265 reg.reload()
266 for t in reg.trees():
267 if set(t.sources()) <= {"http"}:
268 out.setdefault(t.name, t)
269 return out
270
271 def member_capabilities(self) -> list[dict[str, Any]]:
272 """The trees this hub's trusted members have announced they run."""
273 if self.federation is None:
274 return []
275 return [{**t, "peer": r.url, "link": r.link, "via": "member"} for r in list(self.federation.followers.values()) if r.kind == "member" and r.trusted for t in r.trees]
276
277 def announced_capabilities(self) -> list[dict[str, Any]]:
278 """What this hub answers for: its own trees, its services', and its members'."""
279 return self.local_capabilities() + self.member_capabilities()
280
281 def hold(self, service: Service) -> Local:
282 """Hold trees in this process. The service's delegations reach the
283 hub directly, so a local tree can call anything the hub routes to."""
284 service.runner.resolver = _HubResolver(self)
285 self.local = Local(service)
286 return self.local
287
288 def local_capabilities(self) -> list[dict[str, Any]]:
289 """What this hub holds itself and through its own services: what it announces."""
290 trees: list[dict[str, Any]] = []
291 if self.local is not None:
292 for t in self.local.trees:
293 trees.append({**t, "peer": "local", "link": "up"})
294 with self._lock:
295 for peer in self.peers.values():
296 for t in peer.trees:
297 trees.append({**t, "peer": peer.url, "link": peer.link})
298 return trees
299
300 # ------------------------------------------------------------ peers
301
302 def add_peer(self, url: str, name: str = "") -> Peer:
303 url = url.rstrip("/")
304 with self._lock:
305 peer = self.peers.get(url)
306 if peer is None:
307 peer = Peer(url, name)
308 self.peers[url] = peer
309 elif name:
310 peer.name = name
311 self.refresh(peer)
312 return peer
313
314 def refresh(self, peer: Peer) -> None:
315 try:
316 status, caps = http_json("GET", f"{peer.url}/capabilities", timeout=5.0, token=self.token)
317 except Exception as e: # urllib raises URLError subclasses of OSError
318 status, caps = 0, None
319 peer.last_error = str(e)
320 if status == 200 and isinstance(caps, dict):
321 with self._lock:
322 peer.trees = list(caps.get("trees", []))
323 peer.name = caps.get("name") or peer.name
324 peer.last_ok = time.time()
325 peer.last_error = None
326 elif status:
327 peer.last_error = f"capabilities answered {status}"
328
329 def refresh_all(self) -> None:
330 for peer in list(self.peers.values()):
331 self.refresh(peer)
332 self.grants.refresh()
333 if self.federation is not None:
334 self.federation.refresh_all()
335 self.federation.announce() # only when what we hold changed
336 if self.lobby is not None:
337 self.lobby.reach_out() # chat links, for chat only (--chat-reach)
338
339 def start_polling(self) -> None:
340 def loop() -> None:
341 while not self._stop.wait(self.poll_every):
342 self.refresh_all()
343
344 self._thread = threading.Thread(target=loop, daemon=True)
345 self._thread.start()
346
347 def on_lan(self) -> Lan:
348 """Announce this hub on the LAN and listen for others (``--lan``)."""
349 fed = self.federation
350 assert fed is not None, "a hub is federated before it goes on the LAN"
351 self.lan = Lan(lambda: {"id": fed.id, "name": self.name, "networks": sorted(n.id for n in fed.networks.values() if n.public)}).start()
352 fed.lan = self.lan
353 return self.lan
354
355 def stop(self) -> None:
356 self._stop.set()
357 if self.lan is not None:
358 self.lan.stop()
359 if self.local is not None:
360 self.local.service.stop()
361
362 def watches(self) -> dict[str, Any]:
363 """This hub's own trees' watches, then each service's."""
364 out: list[dict[str, Any]] = []
365 if self.local is not None and self.local.service.watcher is not None:
366 out.extend({**w, "peer": "local"} for w in self.local.service.watcher.status()["watches"])
367 for peer in list(self.peers.values()):
368 if peer.link == "down":
369 continue
370 try:
371 status, body = http_json("GET", f"{peer.url}/watches", timeout=5.0, token=self.token)
372 except Exception:
373 continue
374 if status == 200 and isinstance(body, dict):
375 out.extend({**w, "peer": peer.url} for w in body.get("watches", []))
376 return {"count": len(out), "watches": out}
377
378 # ------------------------------------------------------------ lookup
379
380 def holders(self) -> list[Local | Peer | Remote]:
381 """Everyone this hub may route to: its own trees, its services, then the hubs it follows."""
382 with self._lock:
383 out: list[Local | Peer | Remote] = [self.local] if self.local is not None else []
384 out.extend(self.peers.values())
385 if self.federation is not None:
386 # Trusted members run our own trees; a hub we follow answers for its.
387 out.extend(r for r in self.federation.followers.values() if r.kind == "member" and r.trusted and r.trees)
388 out.extend(self.federation.following.values())
389 return out
390
391 def candidates(self, only: str | None = None) -> list[tuple[tuple[str, str], Trigger]]:
392 """Every trigger this hub may route by, as ((holder url, tree), trigger);
393 ``only`` keeps one holder's."""
394 out: list[tuple[tuple[str, str], Trigger]] = []
395 for holder in self.holders():
396 if holder.link == "down" or (only is not None and holder.url != only):
397 continue
398 for tree in holder.trees:
399 fixed = tree.get("trigger_inputs") if isinstance(tree.get("trigger_inputs"), dict) else {}
400 for pattern in tree.get("triggers", []):
401 try:
402 out.append(((holder.url, tree["name"]), Trigger(pattern, dict(fixed.get(pattern) or {}), language=tree.get("language") or {})))
403 except Exception:
404 continue
405 return out
406
407 def holder(self, tree_name: str) -> Local | Peer | Remote | None:
408 """Who to send a tree to: our own trees, then a service of ours, then
409 a hub we follow, and the healthiest of each."""
410 best: Local | Peer | Remote | None = None
411 for h in self.holders():
412 if any(t.get("name") == tree_name for t in h.trees):
413 if best is None or _rank(h) < _rank(best):
414 best = h
415 return best
416
417 # ------------------------------------------------------------ handlers
418
419 def health(self) -> dict[str, Any]:
420 with self._lock:
421 peers = list(self.peers.values())
422 return {
423 "status": "ok",
424 "name": self.name,
425 "peers": len(peers),
426 "up": sum(1 for p in peers if p.link == "up"),
427 "stale": sum(1 for p in peers if p.link == "stale"),
428 "down": sum(1 for p in peers if p.link == "down"),
429 "trees": sum(len(p.trees) for p in peers) + (len(self.local.trees) if self.local else 0),
430 "local": len(self.local.trees) if self.local else 0,
431 "unanswered": len(self.unanswered),
432 "suggestions": len(self.suggestions),
433 "uptime_s": int(time.time() - self.started),
434 }
435
436 def capabilities(self) -> dict[str, Any]:
437 trees = self.announced_capabilities()
438 if self.federation is not None:
439 for remote in list(self.federation.following.values()):
440 for t in remote.trees:
441 trees.append({**t, "peer": remote.url, "link": remote.link, "via": "federation"})
442 return {"name": self.name, "trees": trees}
443
444 def intent(self, body: dict[str, Any], only: str | None = None) -> tuple[int, dict[str, Any]]:
445 """Read a sentence and route it. ``only`` (a holder's url) reads it
446 against that holder's trees alone, as a chat bot does; a sentence
447 they do not answer is then not recorded, since another holder may."""
448 text = body.get("text")
449 if not isinstance(text, str) or not text.strip():
450 return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}
451 reading = read(text, self.candidates(only), _dt.datetime.now(_dt.timezone.utc))
452 matches = reading.matches
453 if not matches and only is not None:
454 return 200, {"status": "error", "reason": "no-match", "say": f"{only} holds no tree that answers {text!r}.", "output": {"reading": {"cleaned": reading.cleaned, "steps": reading.steps}}}
455 if not matches:
456 entry = self.unanswered.record(text)
457 answer = no_match_outcome(text, "No service in the swarm")
458 answer["output"]["seen"] = entry["count"]
459 answer["output"]["reading"] = {"cleaned": reading.cleaned, "steps": reading.steps}
460 return 200, {"status": "error", "reason": "no-match", **answer}
461 tree_names = {m.tree[1] for m in matches} # type: ignore[index]
462 if len(tree_names) > 1:
463 return 200, {"status": "error", "reason": "ambiguous", "say": f"{text!r} matches more than one tree: {sorted(tree_names)}", "output": {"candidates": sorted(tree_names), "reading": {"cleaned": reading.cleaned, "steps": reading.steps}}}
464 peer_url, tree_name = matches[0].tree # type: ignore[misc]
465 for m in matches:
466 m.tree = tree_name # the step names the tree, not the (peer, tree) pair
467 peer = next((h for h in self.holders() if h.url == only), None) if only is not None else self.holder(tree_name) or self.peers.get(peer_url)
468 status, answer = self.forward(peer, "/run", {"tree": tree_name, "inputs": matches[0].inputs, "depth": body.get("depth", 0), "trace": body.get("trace", True), "hops": body.get("hops", [])})
469 if isinstance(answer.get("trace"), dict) and isinstance(answer["trace"].get("steps"), list):
470 # The hub read the sentence; the peer only ran the tree. Put the
471 # reading where a caller expects it, at the top of the trace.
472 answer["trace"]["steps"].insert(0, {**reading.step(), "next": answer["trace"]["steps"][0]["node"] if answer["trace"]["steps"] else None, "elapsed_ms": 0, "depth": 0})
473 return status, answer
474
475 def run(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
476 tree = body.get("tree")
477 if not isinstance(tree, str):
478 return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"tree\": \"...\", \"inputs\": {...}}."}
479 peer = self.holder(tree)
480 if peer is None:
481 return 200, {"status": "error", "reason": "no-such-tree", "tree": tree, "say": f"No service in the swarm holds {tree!r}."}
482 return self.forward(peer, "/run", body)
483
484 def resume(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
485 snapshot = body.get("snapshot") or {}
486 peer = self.holder(str(snapshot.get("tree", "")))
487 if peer is None:
488 return 200, {"status": "error", "reason": "no-such-tree", "say": "No service holds the tree this snapshot came from."}
489 return self.forward(peer, "/resume", body)
490
491 def forward(self, peer: Local | Peer | Remote | None, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
492 if peer is None:
493 return 200, {"status": "error", "reason": "no-peer"}
494 if isinstance(peer, Local):
495 handler = {"/run": peer.service.run, "/intent": peer.service.intent, "/resume": peer.service.resume}[path]
496 answer = handler({k: v for k, v in body.items() if k != "hops"})
497 answer.setdefault("peer", "local")
498 return 200, answer
499 hops = list(body.get("hops") or [])
500 if isinstance(peer, Remote) and peer.kind == "member":
501 # One of our own devices runs it: an Ask in its mailbox, its Answer back.
502 if path != "/run":
503 return 200, {"status": "error", "reason": "not-on-member", "say": "A member runs trees; it cannot be asked a sentence or resumed."}
504 assert self.federation is not None
505 answer = self.federation.ask(peer, str(body.get("tree")), dict(body.get("inputs") or {}), int(body.get("depth", 0)))
506 answer.setdefault("peer", peer.url)
507 answer["via"] = ["member"]
508 if not body.get("trace", True):
509 answer.pop("trace", None)
510 return 200, answer
511 if isinstance(peer, Remote):
512 # Over federation: one hop, signed, and the request says where it has been.
513 if len(hops) >= MAX_HOPS or (self.federation and self.federation.id in hops):
514 return 200, {"status": "error", "reason": "hop-limit", "say": f"{peer.name or peer.url} holds this tree, but the request has already crossed a hub ({hops}); federation is {MAX_HOPS} hop.", "output": {"hops": hops, "peer": peer.url}}
515 assert self.federation is not None
516 body = {**body, "hops": hops + [self.federation.id]}
517 headers = sign(peer.secret, self.federation.id, "POST", path, body)
518 else:
519 body = {k: v for k, v in body.items() if k != "hops"}
520 headers = None
521 try:
522 status, answer = http_json("POST", f"{peer.url}{path}", body, timeout=120.0, token=None if headers else self.token, headers=headers)
523 except Exception as e:
524 peer.last_error = str(e)
525 return 200, {"status": "error", "reason": "peer-unreachable", "say": f"{peer.name or peer.url} did not answer: {e}", "output": {"peer": peer.url}}
526 if status != 200 or not isinstance(answer, dict):
527 return 200, {"status": "error", "reason": "peer-error", "say": f"{peer.name or peer.url} answered {status}", "output": {"peer": peer.url}}
528 peer.last_ok = time.time()
529 answer.setdefault("peer", peer.url)
530 if isinstance(peer, Remote):
531 answer["via"] = [*hops, peer.url]
532 return 200, answer
533
534 def gaps(self) -> dict[str, Any]:
535 out: list[dict[str, Any]] = []
536 if self.local is not None:
537 out.extend({**g, "peer": "local"} for g in self.local.service.gaps.all())
538 for peer in list(self.peers.values()):
539 try:
540 status, body = http_json("GET", f"{peer.url}/gaps", timeout=5.0, token=self.token)
541 except Exception:
542 continue
543 if status == 200 and isinstance(body, dict):
544 out.extend({**g, "peer": peer.url} for g in body.get("gaps", []))
545 return {"gaps": out}
546
547 def gap_reports(self) -> list[dict[str, Any]]:
548 """This hub's gaps for catalog trees (the ones with a namespace),
549 as reports: id, node, on, observed, count, last. Cached a minute;
550 the announce heartbeat reads it."""
551 now = time.time()
552 if now - self._reports_at < 60:
553 return self._reports
554 ids = {t["name"]: f"{t['namespace']}/{t['name']}@{t.get('version', 1)}" for t in self.announced_capabilities() if t.get("namespace") and t.get("name")}
555 rows: dict[str, dict[str, Any]] = {}
556 for g in self.gaps()["gaps"]:
557 tree_id = ids.get(str(g.get("tree")))
558 if tree_id is None:
559 continue
560 key = json.dumps([tree_id, g.get("node"), g.get("on"), g.get("observed")], sort_keys=True, ensure_ascii=False, default=str)
561 row = rows.get(key)
562 if row is None:
563 rows[key] = {"id": tree_id, "node": g.get("node"), "on": g.get("on"), "observed": g.get("observed"), "count": int(g.get("count", 1)), "last": float(g.get("last_seen", now))}
564 else:
565 row["count"] += int(g.get("count", 1))
566 row["last"] = max(row["last"], float(g.get("last_seen", now)))
567 self._reports, self._reports_at = list(rows.values()), now
568 return self._reports
569
570 def unanswered_all(self) -> dict[str, Any]:
571 """This hub's unanswered sentences, then each peer's own (a peer
572 without a hub keeps its own record; one with a hub leaves it here)."""
573 out = self.unanswered.to_list()
574 if self.local is not None:
575 out.extend({**u, "peer": "local"} for u in self.local.service.unanswered.to_list())
576 for peer in list(self.peers.values()):
577 if peer.link == "down":
578 continue
579 try:
580 status, body = http_json("GET", f"{peer.url}/unanswered", timeout=5.0, token=self.token)
581 except Exception:
582 continue
583 if status == 200 and isinstance(body, dict):
584 out.extend({**u, "peer": peer.url} for u in body.get("unanswered", []))
585 return {"unanswered": out}
586
587 def _trusted(self, headers: dict[str, str], may: set[str] | frozenset[str] = frozenset()) -> bool:
588 """Whether the caller holds this hub's token (always, when it has none), or a key it grants chat: who meets bots in the lobby."""
589 return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token) or "chat" in may
590
591 def route(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str] | None = None) -> tuple[int, Any]:
592 body = body or {}
593 headers = headers or {}
594 fed = self.federation
595 signer, may, refused = self._signer(method, path, body, headers)
596 if refused:
597 return refused
598 # The operator: the token, or a key whose root this hub grants operate.
599 operator = bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token or "") or "operate" in may
600 signed = any(k.lower() == "x-pyrtan-signature" for k in headers)
601 if signed and not operator:
602 # A hub we federate with is asking. It may ask the routed paths
603 # and the inbox; anything else needs the operator's token.
604 if fed is None or not signed_path(path):
605 return 401, {"status": "error", "reason": "unauthorised", "say": "A signed request may only reach the inbox, the mailbox, the kept blobs and the routed paths."}
606 actor = fed.verify_request(method, path, body if method == "POST" else None, headers) if path != "/inbox" else None
607 if path != "/inbox" and actor is None:
608 return 401, {"status": "error", "reason": "unsigned", "say": "The signature does not hold, or this hub does not know the actor."}
609 if method == "GET" and path == "/mailbox":
610 return 200, {"activities": fed.mailbox(actor or "")}
611 if method == "GET" and path.startswith("/kept/"):
612 blob = fed.kept_for(actor or "", path[len("/kept/"):])
613 return (200, blob) if blob else (404, {"status": "error", "reason": "not-kept", "say": f"Nothing kept here for you under {path[len('/kept/'):]!r}."})
614 if method == "GET" and path == "/health":
615 return 200, self.health()
616 if method == "GET" and path == "/peers":
617 with self._lock:
618 out: dict[str, Any] = {"peers": ([self.local.to_dict()] if self.local else []) + [p.to_dict() for p in self.peers.values()]}
619 if fed is not None:
620 out.update(fed.to_dict())
621 if self.lobby is not None and self.lobby.reach:
622 out["chat_links"] = self.lobby.links_view()
623 return 200, out
624 if fed is not None:
625 if method == "GET" and path == "/actor":
626 return 200, fed.actor()
627 if method == "GET" and path == "/lan":
628 return (200, self.lan.view()) if self.lan is not None else (404, {"status": "error", "reason": "no-lan", "say": "This hub is not on the LAN; start it with --lan."})
629 if method == "GET" and path == "/directory":
630 # A private network's part only to the operator (or to anyone, on a hub with no token).
631 return 200, fed.directory(private=not self.token or operator)
632 if method == "GET" and path == "/reports":
633 return 200, fed.reports_view()
634 if method == "POST" and path == "/inbox":
635 by_token = bool(self.token) and bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)
636 by_key = signer is not None and "run" in may
637 return fed.handle_inbox(body, headers, trusted=by_token or by_key, trusted_by="token" if by_token else "key" if by_key else None, signer=signer)
638 if method == "GET" and path in ("/following", "/followers"):
639 return 200, {path[1:]: fed.to_dict()[path[1:]]}
640 if method == "GET" and path == "/outbox":
641 return 200, {"outbox": list(fed.outbox), "inbox": list(fed.inbox_log)}
642 if method == "POST" and path == "/follow":
643 url = body.get("url")
644 if not isinstance(url, str) or not url.startswith(("http://", "https://")):
645 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"https://hub.example\"}."}
646 network = body.get("network")
647 if network is not None and not isinstance(network, str):
648 return 400, {"status": "error", "reason": "bad-request", "say": "A network is named by a string."}
649 if body.get("listed") is True and not fed.listed:
650 fed.set_listed(True)
651 try:
652 remote = fed.follow(url, network=network)
653 except ValueError as e:
654 return 400, {"status": "error", "reason": "cannot-follow", "say": str(e)}
655 return 200, {"status": "ok", "following": remote.to_dict(), "listed": fed.listed}
656 if method == "GET" and path == "/networks":
657 return 200, {"networks": fed.networks_view()}
658 if method == "POST" and path == "/networks":
659 try:
660 net = fed.create_network(body.get("name"), public=body.get("public") is True)
661 except ValueError as e:
662 return 400, {"status": "error", "reason": "bad-network", "say": str(e)}
663 return 200, {"status": "ok", "network": net.name, "id": net.id, "invite": net.invite(fed.id)}
664 if method == "POST" and path == "/join":
665 if body.get("listed") is True and not fed.listed:
666 fed.set_listed(True)
667 return join_network(fed, body.get("invite"))
668 if method == "POST" and path == "/leave":
669 name = body.get("network")
670 if not isinstance(name, str):
671 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"network\": \"name\"}."}
672 return 200, {"status": "ok", "left": fed.leave_network(name)}
673 if method == "POST" and path == "/listed":
674 fed.set_listed(body.get("listed") is not False)
675 if fed.listed:
676 fed.announce_up(force=True)
677 fed.announce(force=True)
678 return 200, {"status": "ok", "listed": fed.listed}
679 if method == "GET" and path == "/keep":
680 return 200, {"keeping": fed.keeping_view(), "kept": fed.store.list_kept(), "store": fed.store.usage(), "replicas": fed.replicas}
681 if method == "GET" and path.startswith("/keep/"):
682 blob = fed.store.get_own(path[len("/keep/"):])
683 return (200, {**blob, "holders": fed.holders(blob["key"])}) if blob else (404, {"status": "error", "reason": "no-blob", "say": f"No blob of ours under {path[len('/keep/'):]!r}; POST /fetch asks the holders."})
684 if method == "POST" and path == "/keep":
685 try:
686 return 200, {"status": "ok", "keeping": fed.keep(check_key(body.get("key")), body.get("data"))}
687 except StoreError as e:
688 return 400, {"status": "error", "reason": "bad-blob", "say": str(e)}
689 if method == "POST" and path == "/fetch":
690 key = body.get("key")
691 if not isinstance(key, str):
692 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"key\": \"...\"}."}
693 return 200, fed.fetch(key)
694 if method == "POST" and path == "/unfollow":
695 url = body.get("url")
696 if not isinstance(url, str):
697 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"https://hub.example\"}."}
698 return 200, {"status": "ok", "removed": fed.unfollow(url)}
699 if method == "GET" and path == "/allow":
700 return 200, {"grants": self.grants.view(), "audience": self.audience}
701 if method == "POST" and path in ("/allow", "/disallow"):
702 if not operator:
703 # Even on a hub with no token: a grant of run makes a trusted member, which an open hub never had.
704 return 403, {"status": "error", "reason": "not-granted", "say": "Grants change only with the operator's token or a key granted operate; a hub with no token takes them from --allow at start."}
705 who = body.get("who")
706 if not isinstance(who, str):
707 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"who\": \"pyr:…\" or \"name@domain\"" + (", \"may\": [\"ask\", ...]}." if path == "/allow" else "}.")}
708 try:
709 if path == "/disallow":
710 return 200, {"status": "ok", "removed": self.grants.disallow(who)}
711 grant = self.grants.allow(who, parse_may(body.get("may")))
712 except IdentityError as e:
713 return 400, {"status": "error", "reason": "bad-grant", "say": str(e)}
714 return 200, {"status": "ok", "grant": grant.view()}
715 if method == "POST" and path == "/identity":
716 problem = self.grants.take_document(body.get("document"))
717 if problem:
718 return 400, {"status": "error", "reason": "bad-identity", "say": problem}
719 return 200, {"status": "ok", "id": body["document"]["id"]}
720 if method == "GET" and path.startswith("/.well-known/pyrtan/id/") and path.endswith(".json"):
721 ident = identity.PREFIX + path[len("/.well-known/pyrtan/id/"):-len(".json")]
722 doc = self.grants.document(ident) if len(ident) == len(identity.PREFIX) + identity.ID_LENGTH else None
723 named = self.names()
724 if named is not None:
725 held = [d for d in named.docs.values() if d["id"] == ident and (doc is None or d["updated"] > doc["updated"])]
726 doc = max(held, key=lambda d: d["updated"]) if held else doc
727 return (200, doc) if doc else (404, {"status": "error", "reason": "no-identity", "say": "This hub holds no document for that identity."})
728 if method == "GET" and (path == "/.well-known/pyrtan/names.json" or (path.startswith("/.well-known/pyrtan/name/") and path.endswith(".json"))):
729 named = self.names()
730 if named is None:
731 return 404, {"status": "error", "reason": "no-names", "say": "This hub serves no names; start it with --names DIR."}
732 if path.endswith("/names.json"):
733 return 200, {"pyrtan-names": 1, "domain": self.names_domain, "names": named.claims}
734 claim = named.claims.get(path[len("/.well-known/pyrtan/name/"):-len(".json")])
735 return (200, claim) if claim else (404, {"status": "error", "reason": "no-name", "say": f"No such name at {self.names_domain}."})
736 if method == "GET" and path == "/capabilities":
737 return 200, self.capabilities()
738 if method == "GET" and path == "/gaps":
739 return 200, self.gaps()
740 if method == "GET" and path == "/unanswered":
741 return 200, self.unanswered_all()
742 if method in ("GET", "POST") and path == "/suggestions":
743 return self.suggestions.route(method, body)
744 if method == "GET" and path == "/watches":
745 return 200, self.watches()
746 if is_chat_path(path):
747 if self.lobby is None:
748 return 404, {"status": "error", "reason": "no-chat", "say": "This hub keeps no lobby; start it with --chat."}
749 return self.lobby.route(method, path, body, guest=not self._trusted(headers, may))
750 if method == "GET" and path in ("/", "/index.html", "/console"):
751 return 200, PAGE
752 if method == "GET" and path == "/manifest.webmanifest":
753 return 200, ("application/manifest+json", MANIFEST)
754 if method == "GET" and path == "/sw.js":
755 return 200, ("text/javascript; charset=utf-8", SERVICE_WORKER)
756 if method == "GET" and path == "/pyrtan.js":
757 return 200, ("text/javascript; charset=utf-8", PYRTAN_JS)
758 if method == "GET" and path == "/api":
759 endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/suggestions", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json", "/.well-known/pyrtan/name/{name}.json", "/.well-known/pyrtan/names.json"]
760 if self.lobby is not None:
761 endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"]
762 if fed is not None:
763 endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/lan", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"]
764 return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "audience": self.audience, "grants": len(self.grants), "chat_bots": self.lobby is not None and self._trusted(headers, may), "endpoints": endpoints}
765 if method == "POST" and path == "/register":
766 url = body.get("url")
767 if not isinstance(url, str) or not url.startswith(("http://", "https://")):
768 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"http://host:port\"}."}
769 peer = self.add_peer(url, str(body.get("name", "")))
770 return 200, {"status": "ok", "peer": peer.to_dict()}
771 if method == "POST" and path == "/intent":
772 return self.intent(body)
773 if method == "POST" and path == "/run":
774 return self.run(body)
775 if method == "POST" and path == "/resume":
776 return self.resume(body)
777 return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}
778
779
780def _rank(peer: Local | Peer | Remote) -> int:
781 # Our own trees, then a service of ours, then a hub we follow; the healthier of each.
782 # Our own trees, then a service of ours, then our trusted members, then a hub we follow; the healthier of each.
783 return {"up": 0, "pending": 1, "stale": 2, "down": 3}[peer.link] + (5 if isinstance(peer, Remote) and peer.kind == "member" else 10 if isinstance(peer, Remote) else 0) - (1 if isinstance(peer, Local) else 0)
784
785
786def join_network(fed: Federation, invite: Any) -> tuple[int, dict[str, Any]]:
787 """Join a network from an invite (``name:secret[@hub]``), or the default
788 network (built in) when ``invite`` is empty or ``default``, and follow a
789 hub in it: for the default network, the hubs this one already knows in
790 it first, then the hub list, then the snapshot, each fetched only when
791 the ones before it did not do. When ``$PYRTAN_NETWORK`` names a default
792 network that cannot be read, the first listed hub that answers is
793 followed outside any network, as before networks."""
794 tried: list[str] = []
795 if invite in (None, "", "default"):
796 net = default_network()
797 if net is not None:
798 known = net.id
799
800 def candidates() -> Iterator[str]:
801 for source in (lambda: fed.known_hubs(known), rendezvous_hubs, snapshot_hubs):
802 for url in source():
803 if url not in tried and url != fed.id:
804 tried.append(url)
805 yield url
806
807 hubs: Iterable[str] = candidates()
808 if net is None:
809 url = first_live_hub()
810 if url is None:
811 return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no listed or snapshot hub answers; give an invite or a hub URL."}
812 try:
813 remote = fed.follow(url)
814 except ValueError as e:
815 return 200, {"status": "error", "reason": "cannot-follow", "say": str(e)}
816 return 200, {"status": "ok", "network": None, "following": remote.to_dict(), "say": f"now follows {url}, outside any network (the default network could not be read)"}
817 else:
818 try:
819 net, url = parse_invite(str(invite))
820 except ValueError as e:
821 return 400, {"status": "error", "reason": "bad-invite", "say": str(e)}
822 hubs = [url] if url else []
823 tried.extend(h for h in hubs if h.rstrip("/") != fed.id)
824 try:
825 remote = fed.join(net, hubs)
826 except ValueError as e:
827 return 400, {"status": "error", "reason": "bad-network", "say": str(e)}
828 net = fed.networks[net.name]
829 hubs = tried
830 if remote is not None:
831 say = f"in {net.name}, following {remote.url}"
832 elif hubs:
833 say = f"in {net.name}; no hub in it accepted a follow yet (tried {', '.join(hubs)}), so this hub is a network of one until one does or another hub joins through it"
834 else:
835 say = f"in {net.name}, a network of one until another hub joins through this one"
836 return 200, {"status": "ok", "network": net.name, "id": net.id, "public": net.public, "following": remote.to_dict() if remote else None, "invite": net.invite(fed.id), "say": say}
837
838
839def serve_hub(
840 host: str = "127.0.0.1",
841 port: int = 8700,
842 *,
843 name: str = "hub",
844 peers: list[str] | None = None,
845 poll_every: float = 10.0,
846 quiet: bool = False,
847 token: str | None = None,
848 cors: list[str] | None = None,
849 federate: bool = False,
850 advertise: str | None = None,
851 federation_secret: str | None = None,
852 state: str | None = None,
853 open_follows: bool = True,
854 follow: list[str] | None = None,
855 join: list[str] | None = None,
856 listed: bool = False,
857 trees: list[str | Path] | None = None,
858 handout: list[str | Path] | None = None,
859 env: dict[str, Any] | None = None,
860 gaps: str | None = None,
861 egress: str = "any",
862 store: str | None = None,
863 replicas: int = 2,
864 report_gaps: bool = False,
865 unanswered: str | Path | None = None,
866 watch: bool = False,
867 lan: bool = False,
868 chat_reach: int = 0,
869 chat: bool = False,
870 suggestions: str | Path | None = None,
871 allow: list[str] | None = None,
872 grants: str | None = None,
873 names: str | Path | None = None,
874 names_domain: str | None = None,
875 chat_typing: bool = True,
876 chat_public: bool = False,
877) -> tuple[Hub, Server]:
878 """Start a hub. ``allow`` holds ``WHO=ABILITIES`` grants, made before it answers
879 anything (a name is resolved now; IdentityError if it cannot be); ``grants`` is
880 where the list is kept (default: beside ``state``, else in memory)."""
881 from .grants import parse_spec
882 specs = [parse_spec(a) for a in allow or []]
883 if specs:
884 identity.require_signatures() # a grant nobody could prove is refused at start, not at the first request
885 keep = grants or (str(Path(state).with_name("grants.json")) if state else None)
886 granted = Grants(keep)
887 for who, may in specs:
888 granted.allow(who, may)
889 local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None
890 hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, chat_reach=chat_reach, suggestions=suggestions, names=names, names_domain=names_domain)
891 server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), public=is_chat_path if chat_public else None, keyed=True).start()
892 hub._audience = (advertise or server.url).rstrip("/")
893 if federate or advertise or join or lan or chat_reach:
894 hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps)
895 for url in peers or []:
896 hub.add_peer(url)
897 for url in follow or []:
898 try:
899 hub.federation.follow(url) # type: ignore[union-attr]
900 except ValueError as e:
901 print(f"pyrtan hub: cannot follow {url}: {e}", flush=True)
902 if lan:
903 hub.on_lan()
904 if join and hub.lan is not None and hub.lan.error is None:
905 hub.lan.wait(1.5) # the hubs nearby answer the first question in that time: join one of them
906 for invite in join or []:
907 _, answer = join_network(hub.federation, invite) # type: ignore[arg-type]
908 print(f"pyrtan hub: join {answer.get('network') or invite.split(':', 1)[0]}: {answer.get('say')}", flush=True)
909 if join and hub.lan is not None:
910 hub.lan.send() # now in its networks: say so at once, not at the next beacon
911 hub.start_polling()
912 return hub, server