pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / federation.py

77,529 bytes · last changed 2026-10-28 in Chat links: open lobbies share their strangers without following each other; release 0.17.0

1"""Federation: hubs that follow each other, and a directory they all keep.
2
3A hub on its own routes sentences to the services that registered with
4it. A federated hub also *follows* other hubs, the way one account follows
5another on the fediverse, and answers for their trees too. The shape is
6ActivityPub's (an actor document, an inbox, ``Follow`` / ``Accept`` /
7``Undo`` / ``Announce`` activities) without being an implementation of it:
8the activities are Pyrtan's own, the signatures are HMAC on a secret the
9two hubs share, and nothing here federates with Mastodon.
10
11The one rule everything rests on: **following is trust, in one direction.**
12
13- A hub routes sentences only to hubs it follows. Its operator chose them.
14- A hub that follows another may ask it to run trees; the followed hub
15 serves its followers. Nobody else can ask over federation.
16- Being followed grants nothing: a follower receives announcements and may
17 ask, but the followed hub never routes anything to it.
18- A hub announces what its own services hold, never what it learned from
19 the hubs it follows, and does not relay: federation is one hop. A
20 request that has already crossed a hub is refused rather than forwarded
21 again, so there are no loops to detect.
22
23How it works. Each hub has an *actor* document at ``GET /actor`` (its id,
24its inbox, its name). To follow, hub A fetches B's actor, makes a secret
25for the pair, records B as pending, and posts a ``Follow`` carrying the
26secret to B's inbox. B checks the policy (open, or a shared federation
27secret the Follow must be signed with), fetches A's actor to confirm the
28inbox is A's, records A as a follower, and posts back an ``Accept`` and an
29``Announce`` of its capabilities, both signed with the pair secret. From
30then on B announces whenever its capabilities change and A polls B's
31``/capabilities`` (signed) as a fallback, so B's ``link`` is up, stale or
32down like any peer's. ``Undo`` of the Follow ends it.
33
34Every message between two hubs is signed: ``X-Pyrtan-Actor`` names the
35sender, ``X-Pyrtan-Date`` carries a unix timestamp within five minutes,
36and ``X-Pyrtan-Signature`` is HMAC-SHA256 over the method, path, date and
37the SHA-256 of the canonical JSON body. The body sent *is* that canonical
38JSON, so the receiver can verify from the body it parsed.
39
40**The directory** is how hubs find each other without a server. A hub
41started ``--listed`` puts an entry for itself (id, name, the trees it
42holds, when) in every ``Announce`` it sends; every hub keeps the entries
43it hears and passes them on in its own announces, in both directions of
44every follow. Nothing central holds the directory: every federated hub
45does, and pyrtan.com publishes only static files on GitHub Pages: the
46default network, the *hub list* (hubs operators added by pull request,
47which pyrtan.com asks every hour), and an hourly snapshot of what they
48know. No hub is run for it, and none has to be up for it. An
49entry is a hint (a hub two follows away cannot verify who said it), kept
50until its hub has not been heard of for a week, and routing never uses
51it: finding a hub in the directory and following it is still the one act
52of trust.
53
54**Networks** are names and secrets. A hub can be in any number and start
55one (``create_network``, a network of one); another joins with its
56invite, ``name:secret@hub`` (``join``). A Follow made in a network names
57it by ``network_id`` (from the name and the secret, so it gives neither
58away) and is signed with its secret; the followed hub takes it only if
59it is in that network. The directory heard over a link is kept with the
60link's network and passed on only over links in the same one, so a
61private network's hubs never reach the public directory, and ``GET
62/directory`` shows them only to the operator. The default network
63(``default_network``) has a public secret: it names the network, it does
64not guard it, and nothing here needs it to. Its name and secret are
65built in, so joining it asks nobody for them; pyrtan.com publishes the
66same two values as a mirror, not as the authority.
67
68**Joining** the default network tries what the hub already knows before
69anything else: the hubs it follows in it, then the hubs its directory
70has heard in it, most recent first. Only when none of those accepts does
71it read the hub list and then the snapshot, and only as far as it needs.
72So a hub that has been in the network once never needs pyrtan.com again,
73and any hub's public ``/directory`` or any mirror of the list will do as
74a starting point (``$PYRTAN_HUB_LIST``). A hub started ``--lan`` also
75hears the hubs on its own network say they are there (:mod:`pyrtan.lan`)
76and tries those right after the ones it follows: no internet needed.
77A follow outside any network (and every member's) works as before.
78
79**Chat.** A hub with a lobby (``--chat``) asks the hubs it follows, and
80the ones it keeps a chat link with (``ChatLink``, ``--chat-reach``: for
81chat only, never a follow), for a stranger with ``Meet``, and the two seats talk in ``Say`` and end with
82``Part``, with ``Typing`` while someone types, both ways between any two
83hubs that share a secret; a ``Say`` is logged without its text and a
84``Typing`` not at all. See :mod:`pyrtan.chat`.
85
86What this module refuses: open relaying (no hop beyond one), asymmetric
87secrets (there is no key infrastructure to lean on in the standard
88library, and a pair secret is enough for hubs whose operators chose each
89other), and any routing decision that was not an operator's follow.
90"""
91
92from __future__ import annotations
93
94import hashlib
95import hmac
96import json
97import os
98import re
99import secrets as _secrets
100import threading
101import time
102import uuid
103from collections import deque
104from dataclasses import dataclass, field
105from pathlib import Path
106from typing import Any, Callable, Iterable
107
108from . import __version__
109from .service import http_json
110from .store import BlobStore, StoreError, check_key, digest as blob_digest
111
112DEFAULT_HUB_LIST = "https://pyrtan.com/rendezvous/hubs.json"
113DEFAULT_SNAPSHOT = "https://pyrtan.com/rendezvous/directory.json"
114# The default network, built in: public on purpose, so it names the network
115# and guards nothing. site/rendezvous/network.json publishes the same values.
116DEFAULT_NETWORK_NAME = "pyrtan"
117DEFAULT_NETWORK_SECRET = "public-default-network-of-pyrtan-com"
118NETWORK_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
119INVITE = re.compile(r"^([a-z0-9][a-z0-9-]{0,39}):([A-Za-z0-9_-]{16,128})(?:@(https?://\S+))?$")
120SIGNATURE_WINDOW = 300.0 # seconds a signed message stays valid
121MAX_HOPS = 1 # federation is one hop; raise with care
122STALE_AFTER = 30.0
123DOWN_AFTER = 90.0
124ANNOUNCE_EVERY = 60.0 # seconds between announces when nothing changed: the directory's heartbeat
125DIRECTORY_TTL = 7 * 86400 # an entry unheard of for this long is dropped
126DIRECTORY_MAX = 500
127HEARD_UP = 600.0 # a directory entry seen this recently reads "up"
128HEARD_STALE = 86400.0
129ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Typing", "Part", "ChatLink")
130CHAT_TYPES = ("Meet", "Say", "Typing", "Part") # a lobby's: two strangers on two hubs
131UNLOGGED = ("Typing",) # said every couple of seconds while someone types: not worth a log line
132ASK_TIMEOUT = 30.0 # seconds a hub waits for a member to answer an Ask
133MEMBER_PREFIX = "urn:pyrtan:member:" # an actor with no inbox: a browser or a phone, which polls a mailbox
134KEY_PREFIX = "pyr:" # a member may be its device key instead (_meta/identity.md §7)
135MAILBOX_MAX = 200 # activities a hub holds for a member that has not polled
136REPORT_TTL = 30 * 86400 # a gap report unheard of for this long is dropped
137REPORT_MAX = 2000
138REPORT_ID = re.compile(r"^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@\d+$") # namespace/name@version: a catalog tree
139HOLDER_FRESH = 86400.0 # a holder confirmed within this counts toward the replicas
140ASK_WINDOW = 600.0 # a member asked to keep within this counts as covered until it confirms
141SIGNED_PATHS = ("/inbox", "/run", "/intent", "/resume", "/capabilities")
142
143
144# ---------------------------------------------------------------- the hub list
145
146
147def _read_json(source: str) -> Any:
148 """A URL or a file, as JSON; None when it cannot be had."""
149 try:
150 if source.startswith(("http://", "https://")):
151 status, body = http_json("GET", source, timeout=10.0)
152 return body if status == 200 else None
153 return json.loads(Path(source).read_text(encoding="utf-8"))
154 except Exception:
155 return None
156
157
158def hub_urls(body: Any) -> list[str]:
159 """The hubs a list names: ``{"hubs": ["https://…", …]}`` (a hub list)
160 or ``{"hubs": [{"id": "https://…", "seen": …}, …]}`` (a hub's
161 ``/directory``, or a snapshot), the latter most recently heard first."""
162 if not isinstance(body, dict) or not isinstance(body.get("hubs"), list):
163 return []
164 rows = body["hubs"]
165 if all(isinstance(h, dict) for h in rows):
166 rows = sorted(rows, key=lambda h: -float(h.get("seen", 0) or 0))
167 urls = [h if isinstance(h, str) else h.get("id") if isinstance(h, dict) else None for h in rows]
168 return list(dict.fromkeys(u.rstrip("/") for u in urls if isinstance(u, str) and u.startswith(("http://", "https://"))))
169
170
171def rendezvous_hubs() -> list[str]:
172 """Hubs to try first, from ``$PYRTAN_RENDEZVOUS`` (one hub),
173 ``$PYRTAN_HUB_LIST`` (any number of URLs or files, separated by spaces
174 or commas: hub lists, a hub's ``/directory``, a snapshot or a mirror of
175 one, in order), or the hub list at pyrtan.com. Never raises: nothing
176 readable is an empty list."""
177 single = os.environ.get("PYRTAN_RENDEZVOUS")
178 if single:
179 return [single.rstrip("/")]
180 sources = os.environ.get("PYRTAN_HUB_LIST", "").replace(",", " ").split() or [DEFAULT_HUB_LIST]
181 out: list[str] = []
182 for source in sources:
183 out.extend(u for u in hub_urls(_read_json(source)) if u not in out)
184 return out
185
186
187def snapshot_hubs() -> list[str]:
188 """Hubs from the directory snapshot pyrtan.com bakes hourly
189 (``$PYRTAN_SNAPSHOT`` overrides the URL or names a file), most recently
190 heard first. What a new hub can try when no listed hub answers."""
191 return hub_urls(_read_json(os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT))
192
193
194# ---------------------------------------------------------------- networks
195
196
197def network_id(name: str, secret: str) -> str:
198 """What a Follow names a network by: derived from its name and secret,
199 so two networks that share a name never meet, and the id gives the
200 secret away to nobody."""
201 return hashlib.sha256(f"pyrtan-network\n{name}\n{secret}".encode("utf-8")).hexdigest()[:16]
202
203
204@dataclass
205class Network:
206 """A set of hubs that sign their follows with one secret. ``public``
207 marks one whose secret is published (the default network at
208 pyrtan.com): its directory is shown to anyone, a private one's only
209 to the operator."""
210
211 name: str
212 secret: str
213 public: bool = False
214
215 @property
216 def id(self) -> str:
217 return network_id(self.name, self.secret)
218
219 def invite(self, hub: str | None = None) -> str:
220 return f"{self.name}:{self.secret}" + (f"@{hub.rstrip('/')}" if hub else "")
221
222 def to_state(self) -> dict[str, Any]:
223 return {"name": self.name, "secret": self.secret, "public": self.public}
224
225 @classmethod
226 def from_state(cls, d: Any) -> "Network | None":
227 if not isinstance(d, dict) or not isinstance(d.get("name"), str) or not isinstance(d.get("secret"), str):
228 return None
229 if not NETWORK_NAME.match(d["name"]) or len(d["secret"]) < 16:
230 return None
231 return cls(d["name"], d["secret"], bool(d.get("public", False)))
232
233
234def parse_invite(text: str) -> tuple[Network, str | None]:
235 """``name:secret`` or ``name:secret@https://hub``: a network, and the
236 hub to follow into it. Raises ValueError on anything else."""
237 m = INVITE.match(text.strip()) if isinstance(text, str) else None
238 if not m:
239 raise ValueError("an invite is NAME:SECRET or NAME:SECRET@https://hub (a name of lowercase letters, digits and dashes; a secret of at least 16 letters, digits, dashes or underscores)")
240 return Network(m.group(1), m.group(2)), (m.group(3).rstrip("/") if m.group(3) else None)
241
242
243def default_network() -> Network | None:
244 """The default network: built in, so finding it asks nobody. Its secret
245 is public on purpose: it names the network rather than guarding it.
246 ``$PYRTAN_NETWORK`` (a URL or a file shaped like pyrtan.com's
247 ``network.json``) names another; None when that cannot be read."""
248 source = os.environ.get("PYRTAN_NETWORK")
249 if not source:
250 return Network(DEFAULT_NETWORK_NAME, DEFAULT_NETWORK_SECRET, public=True)
251 net = Network.from_state(_read_json(source))
252 if net is not None:
253 net.public = True
254 return net
255
256
257def bootstrap_candidates() -> list[str]:
258 """Where to look for a first hub: the hub list, then the snapshot's hubs."""
259 listed = rendezvous_hubs()
260 return listed + [h for h in snapshot_hubs() if h not in listed]
261
262
263def first_live_hub(hubs: list[str] | None = None) -> str | None:
264 """The first listed hub whose ``/actor`` answers as a hub; when none
265 does, the first hub from the snapshot that answers; else None."""
266 tried = hubs if hubs is not None else rendezvous_hubs()
267 for batch in ([tried] if hubs is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):
268 for url in (batch() if callable(batch) else batch):
269 try:
270 status, actor = http_json("GET", f"{url}/actor", timeout=5.0)
271 except Exception:
272 continue
273 if status == 200 and isinstance(actor, dict) and actor.get("type") == "Hub":
274 return url
275 return None
276
277
278# ---------------------------------------------------------------- signing
279
280
281def canonical(body: Any) -> bytes:
282 return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
283
284
285def sign(secret: str, actor: str, method: str, path: str, body: Any, date: float | None = None) -> dict[str, str]:
286 date_text = str(int(date if date is not None else time.time()))
287 digest = hashlib.sha256(canonical(body) if body is not None else b"").hexdigest()
288 message = "\n".join([method.upper(), path, date_text, digest]).encode("utf-8")
289 mac = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
290 return {"X-Pyrtan-Actor": actor, "X-Pyrtan-Date": date_text, "X-Pyrtan-Signature": mac}
291
292
293def verify(secret: str, method: str, path: str, body: Any, headers: dict[str, str], now: float | None = None) -> str | None:
294 """None if the signature holds; otherwise what is wrong with it."""
295 date_text = headers.get("X-Pyrtan-Date", "")
296 mac = headers.get("X-Pyrtan-Signature", "")
297 if not date_text.isdigit() or not mac:
298 return "no signature"
299 if abs((now if now is not None else time.time()) - int(date_text)) > SIGNATURE_WINDOW:
300 return "signature too old"
301 expected = sign(secret, headers.get("X-Pyrtan-Actor", ""), method, path, body, int(date_text))["X-Pyrtan-Signature"]
302 return None if hmac.compare_digest(expected, mac) else "bad signature"
303
304
305def _lower(headers: dict[str, str] | None) -> dict[str, str]:
306 """Header lookup is case-insensitive; keep the X-Pyrtan-* names as written."""
307 out: dict[str, str] = {}
308 for k, v in (headers or {}).items():
309 key = "-".join(p.capitalize() if p.lower() != "pyrtan" else "Pyrtan" for p in k.split("-"))
310 out[key] = v
311 return out
312
313
314# ---------------------------------------------------------------- remotes
315
316
317@dataclass
318class Remote:
319 """A hub we follow, or one that follows us."""
320
321 url: str
322 inbox: str
323 secret: str
324 name: str = ""
325 state: str = "pending" # pending | accepted | rejected
326 trees: list[dict[str, Any]] = field(default_factory=list)
327 last_ok: float = 0.0
328 last_error: str | None = None
329 since: float = field(default_factory=time.time)
330 kind: str = "hub" # hub | member
331 trusted: bool = False # a member that joined with the operator's token, or as an identity granted run: it may run the hub's trees
332 network: str | None = None # the id of the network the follow was made in; None for a follow outside any
333 trusted_by: str | None = None # "token" or "key": how a trusted member came to be
334 identity: str | None = None # the root a member followed as, if it signed the follow with a key
335 device: str | None = None # the key that signed it
336 note: dict[str, Any] | None = None # its device note, when the key is a device
337
338 @property
339 def link(self) -> str:
340 if self.state != "accepted":
341 return "down" if self.state == "rejected" else "pending"
342 if self.last_ok == 0.0:
343 return "down" if self.last_error else "pending"
344 age = time.time() - self.last_ok
345 if age < STALE_AFTER:
346 return "up"
347 if age < DOWN_AFTER:
348 return "stale"
349 return "down"
350
351 def to_dict(self) -> dict[str, Any]:
352 return {"url": self.url, "name": self.name, "kind": self.kind, "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error, "trusted": self.trusted, "network": self.network, "identity": self.identity}
353
354 def to_state(self) -> dict[str, Any]:
355 return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since, "kind": self.kind, "trusted": self.trusted, "network": self.network,
356 "trusted_by": self.trusted_by, "identity": self.identity, "device": self.device, "note": self.note}
357
358 @classmethod
359 def from_state(cls, d: dict[str, Any]) -> "Remote":
360 text = lambda k: d.get(k) if isinstance(d.get(k), str) else None # noqa: E731
361 return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())), kind=d.get("kind", "hub"), trusted=bool(d.get("trusted", False)), network=text("network"),
362 trusted_by=text("trusted_by"), identity=text("identity"), device=text("device"), note=d.get("note") if isinstance(d.get("note"), dict) else None)
363
364
365def _shape_report(e: dict[str, Any], reporter: str, now: float) -> dict[str, Any] | None:
366 """A report as carried: valid or nothing. ``reporter`` is who said it
367 first (the entry's own, else who we heard it from)."""
368 tree_id, node, on = e.get("id"), e.get("node"), e.get("on")
369 if not (isinstance(tree_id, str) and REPORT_ID.match(tree_id) and isinstance(node, str) and isinstance(on, str)):
370 return None
371 who = e.get("reporter") if isinstance(e.get("reporter"), str) and e.get("reporter", "").startswith(("http://", "https://")) else reporter
372 try:
373 count = max(1, int(e.get("count", 1)))
374 last = min(float(e.get("last", now)), now)
375 except (TypeError, ValueError):
376 return None
377 if now - last >= REPORT_TTL:
378 return None
379 observed = e.get("observed")
380 if isinstance(observed, str) and len(observed) > 80:
381 observed = "<str>"
382 elif not isinstance(observed, (str, int, float, bool)) and observed is not None:
383 observed = f"<{type(observed).__name__}>"
384 return {"reporter": who.rstrip("/"), "id": tree_id, "node": node[:80], "on": on[:120], "observed": observed, "count": count, "last": last}
385
386
387def _logged(activity: dict[str, Any]) -> dict[str, Any]:
388 """An activity as the outbox and inbox logs keep it: a chat line without
389 its text, a chat link without its secret."""
390 obj = activity.get("object")
391 if activity.get("type") == "Say" and isinstance(obj, dict) and "text" in obj:
392 return {**activity, "object": {**obj, "text": f"<{len(str(obj['text']))} characters>"}}
393 if activity.get("type") == "ChatLink" and "secret" in activity:
394 return {**activity, "secret": "<secret>"}
395 return activity
396
397
398def heard(seen: float, now: float | None = None) -> str:
399 """How recently a directory entry's hub announced, as gossip carried it."""
400 age = (now if now is not None else time.time()) - seen
401 return "up" if age < HEARD_UP else "stale" if age < HEARD_STALE else "down"
402
403
404class Federation:
405 def __init__(self, hub_id: str, *, name: str = "hub", secret: str | None = None, state: str | Path | None = None, open_follows: bool = True, capabilities: Callable[[], list[dict[str, Any]]] | None = None, listed: bool = False, store: BlobStore | str | Path | None = None, replicas: int = 2, auto_keys: dict[str, Callable[[], Any]] | None = None, report_gaps: bool = False, own_reports: Callable[[], list[dict[str, Any]]] | None = None) -> None:
406 self.id = hub_id.rstrip("/")
407 self.report_gaps = report_gaps # opt in: our own catalog trees' gaps travel with our announces
408 self.own_reports = own_reports or (lambda: []) # the hub's gaps for catalog trees, shaped for a report
409 self.reports: dict[str, dict[str, Any]] = {} # heard gap reports: (reporter, id, node, on, observed) -> entry
410 self.name = name
411 self.secret = secret or None # a shared federation secret: Follows must be signed with it
412 self.open_follows = open_follows # without a secret, whether strangers may follow
413 self.state_path = Path(state) if state else None
414 self.capabilities = capabilities or (lambda: [])
415 self.listed = listed # put ourselves in the directory we gossip
416 self.store = store if isinstance(store, BlobStore) else BlobStore(store)
417 self.replicas = max(0, int(replicas)) # how many keepers each of our blobs should have
418 self.auto_keys = dict(auto_keys or {}) # blobs kept automatically: key -> what to keep
419 self._auto_digest: dict[str, str] = {}
420 self.keeping: dict[str, dict[str, Any]] = {} # our blobs: key -> {sha256, size, updated, holders: {actor: {at, sha256}}}
421 self.mailboxes: dict[str, deque[dict[str, Any]]] = {} # activities waiting for members
422 self.handout: Callable[[], dict[str, Any]] = lambda: {} # the trees trusted members run: name -> the tree as JSON
423 self._handed: dict[str, dict[str, dict[str, Any]]] = {} # member -> key -> {sha256, at, confirmed}
424 self._asks: dict[str, dict[str, Any]] = {} # run id -> {member, event, answer}
425 self.chat: Any = None # the hub's lobby, when it keeps one (pyrtan.chat.Lobby)
426 self.lan: Any = None # what the hub hears on the LAN, when started --lan (pyrtan.lan.Lan)
427 self.ask_timeout = ASK_TIMEOUT
428 self.following: dict[str, Remote] = {}
429 self.followers: dict[str, Remote] = {}
430 self.networks: dict[str, Network] = {} # name -> network this hub is in
431 self.directory_entries: dict[str, dict[str, Any]] = {} # (network id, hub id) -> entry; see _dkey
432 self.outbox: deque[dict[str, Any]] = deque(maxlen=50)
433 self.inbox_log: deque[dict[str, Any]] = deque(maxlen=50)
434 self._announced: tuple[str, float] = ("", 0.0) # digest and time of the last downward announce
435 self._announced_up: tuple[str, float] = ("", 0.0) # likewise upward
436 self._lock = threading.RLock()
437 if self.state_path and self.state_path.exists():
438 self._load()
439 if listed:
440 self.listed = True
441
442 # ------------------------------------------------------------ actor
443
444 @property
445 def inbox(self) -> str:
446 return f"{self.id}/inbox"
447
448 def actor(self) -> dict[str, Any]:
449 return {"type": "Hub", "id": self.id, "name": self.name, "inbox": self.inbox, "mailbox": f"{self.id}/mailbox", "capabilities": f"{self.id}/capabilities", "directory": f"{self.id}/directory", "pyrtan": __version__, "follows": "open" if (self.open_follows and not self.secret) else "secret", "listed": self.listed, "replicas": self.replicas}
450
451 # ------------------------------------------------------------ state
452
453 def _save(self) -> None:
454 if not self.state_path:
455 return
456 with self._lock:
457 data = {
458 "listed": self.listed,
459 "networks": [n.to_state() for n in self.networks.values()],
460 "following": [r.to_state() for r in self.following.values()],
461 "followers": [r.to_state() for r in self.followers.values()],
462 "directory": list(self.directory_entries.values()),
463 "keeping": self.keeping,
464 "reports": list(self.reports.values()),
465 }
466 self.state_path.parent.mkdir(parents=True, exist_ok=True)
467 self.state_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
468
469 def _load(self) -> None:
470 assert self.state_path
471 try:
472 data = json.loads(self.state_path.read_text(encoding="utf-8"))
473 except (OSError, json.JSONDecodeError):
474 return
475 self.listed = bool(data.get("listed", False))
476 for d in data.get("networks", []):
477 net = Network.from_state(d)
478 if net is not None:
479 self.networks[net.name] = net
480 for d in data.get("following", []):
481 self.following[d["url"]] = Remote.from_state(d)
482 for d in data.get("followers", []):
483 self.followers[d["url"]] = Remote.from_state(d)
484 self.merge_directory(data.get("directory", []), via="state", keep_tags=True)
485 self.merge_reports(data.get("reports", []), via="state")
486 keeping = data.get("keeping")
487 if isinstance(keeping, dict):
488 self.keeping = {k: v for k, v in keeping.items() if isinstance(v, dict) and isinstance(v.get("holders"), dict)}
489
490 def set_listed(self, listed: bool) -> None:
491 with self._lock:
492 self.listed = listed
493 if not listed:
494 self.directory_entries.pop(self.id, None)
495 self._save()
496
497 # ------------------------------------------------------------ networks
498 # A network is a name and a secret its hubs sign their follows with. A
499 # hub may be in any number, starts one with create_network, and joins
500 # one with an invite (join). Every follow made in a network says so,
501 # and the directory heard over it is kept apart and passed on only
502 # within it, so a private network's hubs never reach the public one.
503 # A follow outside any network (follow(url), a member) works as before.
504
505 def network(self, name: str) -> Network:
506 net = self.networks.get(name)
507 if net is None:
508 raise ValueError(f"this hub is in no network named {name!r}")
509 return net
510
511 def network_by_id(self, nid: Any) -> Network | None:
512 return next((n for n in self.networks.values() if n.id == nid), None) if isinstance(nid, str) else None
513
514 def add_network(self, net: Network) -> Network:
515 """Be in ``net``. The same network again is a no-op; another one
516 under a name already taken is refused."""
517 with self._lock:
518 current = self.networks.get(net.name)
519 if current is not None:
520 if current.secret != net.secret:
521 raise ValueError(f"this hub is already in a different network named {net.name!r}; leave it first")
522 current.public = current.public or net.public
523 return current
524 self.networks[net.name] = net
525 self._save()
526 return net
527
528 def create_network(self, name: str, public: bool = False) -> Network:
529 if not isinstance(name, str) or not NETWORK_NAME.match(name):
530 raise ValueError("a network's name is lowercase letters, digits and dashes, at most 40")
531 if name in self.networks:
532 raise ValueError(f"this hub is already in a network named {name!r}")
533 return self.add_network(Network(name, _secrets.token_urlsafe(24), public))
534
535 def known_hubs(self, network: str) -> list[str]:
536 """The hubs this one already knows in a network (by id), to try
537 before asking anyone: the ones it follows in it, then the ones it
538 heard on the LAN say they are in it (``--lan``), then the ones its
539 directory heard in it, most recently heard first."""
540 now = time.time()
541 with self._lock:
542 followed = [r.url for r in self.following.values() if r.kind == "hub" and r.network == network and r.state == "accepted"]
543 heard = sorted((e for e in self.directory_entries.values() if e.get("network") == network and e.get("kind", "hub") == "hub" and now - e["seen"] < DIRECTORY_TTL), key=lambda e: -e["seen"])
544 nearby = self.lan.hubs(network) if self.lan is not None else []
545 return [u for u in dict.fromkeys(followed + nearby + [e["id"].rstrip("/") for e in heard]) if u != self.id]
546
547 def join(self, net: Network, hubs: Iterable[str]) -> Remote | None:
548 """Be in ``net`` and follow the first of ``hubs`` that accepts a
549 follow in it (an iterable, so a caller can fetch more only when the
550 first ones fail). None when none does: the hub is then a network of
551 one, which others can join by following it."""
552 net = self.add_network(net)
553 for url in hubs:
554 url = url.rstrip("/")
555 if url == self.id:
556 continue
557 current = self.following.get(url)
558 if current is not None and current.network == net.id and current.state == "accepted":
559 return current
560 before = (current.network, current.state) if current is not None else None
561 try:
562 remote = self.follow(url, network=net.name)
563 except ValueError:
564 continue
565 if remote.state == "accepted":
566 return remote
567 with self._lock:
568 # Not accepted in this network: leave things as they were.
569 if before is None:
570 self.following.pop(url, None)
571 else:
572 remote.network, remote.state = before
573 self._save()
574 return None
575
576 def leave_network(self, name: str) -> bool:
577 """Leave a network: unfollow the hubs followed in it, drop the hubs
578 that followed us in it, and forget its directory."""
579 net = self.networks.get(name)
580 if net is None:
581 return False
582 for url in [u for u, r in self.following.items() if r.network == net.id]:
583 self.unfollow(url)
584 with self._lock:
585 for url in [u for u, r in self.followers.items() if r.network == net.id]:
586 del self.followers[url]
587 for key in [k for k, e in self.directory_entries.items() if e.get("network") == net.id]:
588 del self.directory_entries[key]
589 del self.networks[name]
590 self._save()
591 return True
592
593 def networks_view(self) -> list[dict[str, Any]]:
594 """The operator's view: each network with its invite."""
595 with self._lock:
596 return [{"name": n.name, "id": n.id, "public": n.public, "invite": n.invite(self.id), "following": sorted(u for u, r in self.following.items() if r.network == n.id), "followers": sorted(u for u, r in self.followers.items() if r.network == n.id)} for n in sorted(self.networks.values(), key=lambda n: n.name)]
597
598 # ------------------------------------------------------------ directory
599
600 @staticmethod
601 def _dkey(network: str | None, hub_id: str) -> str:
602 return hub_id if network is None else f"{network} {hub_id}"
603
604 def own_entry(self) -> dict[str, Any] | None:
605 if not self.listed:
606 return None
607 return {"id": self.id, "name": self.name, "kind": "hub", "trees": sorted({t.get("name") for t in self.capabilities() if isinstance(t, dict) and t.get("name")}), "seen": time.time()}
608
609 def gossip(self, network: str | None = None) -> list[dict[str, Any]]:
610 """What this hub tells a hub it is linked to in ``network`` (an id;
611 None for a link outside any) about the directory: itself, if
612 listed, and every live entry it heard in that same network."""
613 now = time.time()
614 with self._lock:
615 entries = [e for e in self.directory_entries.values() if e["id"] != self.id and e.get("network") == network and now - e["seen"] < DIRECTORY_TTL]
616 own = self.own_entry()
617 return ([own] if own else []) + sorted(({k: v for k, v in e.items() if k != "network"} for e in entries), key=lambda e: -e["seen"])
618
619 def _public_networks(self) -> set[str | None]:
620 return {None} | {n.id for n in self.networks.values() if n.public}
621
622 def directory_backup(self) -> list[dict[str, Any]]:
623 """The directory as a blob for keepers: the public part, tagged,
624 so a private network's hubs are never handed to a keeper."""
625 now = time.time()
626 public = self._public_networks()
627 with self._lock:
628 entries = [dict(e) for e in self.directory_entries.values() if e.get("network") in public and now - e["seen"] < DIRECTORY_TTL]
629 own = self.own_entry()
630 return ([own] if own else []) + sorted(entries, key=lambda e: -e["seen"])
631
632 def merge_directory(self, entries: Any, via: str, network: str | None = None, keep_tags: bool = False) -> int:
633 """Take entries heard from ``via`` over a link in ``network``: a
634 newer ``seen`` replaces an older one, our own entry is never
635 overwritten, nothing from the future is believed, and the table
636 stays bounded. ``keep_tags`` is for our own copies (the state file,
637 a blob handed back): each entry keeps the network it carries, if we
638 are still in it. Returns how many entries changed."""
639 if not isinstance(entries, list):
640 return 0
641 now = time.time()
642 changed = 0
643 with self._lock:
644 known = {n.id for n in self.networks.values()}
645 for e in entries:
646 if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not e["id"].startswith(("http://", "https://", MEMBER_PREFIX, KEY_PREFIX)):
647 continue
648 hub_id = e["id"].rstrip("/")
649 if hub_id == self.id:
650 continue
651 net = network
652 if keep_tags:
653 net = e.get("network") if isinstance(e.get("network"), str) else None
654 if net is not None and net not in known:
655 continue
656 try:
657 seen = min(float(e.get("seen", 0)), now)
658 except (TypeError, ValueError):
659 continue
660 if now - seen >= DIRECTORY_TTL:
661 continue
662 key = self._dkey(net, hub_id)
663 current = self.directory_entries.get(key)
664 if current is not None and current["seen"] >= seen:
665 continue
666 self.directory_entries[key] = {
667 "id": hub_id,
668 "name": str(e.get("name") or "")[:80],
669 "kind": "member" if hub_id.startswith((MEMBER_PREFIX, KEY_PREFIX)) else "hub",
670 "trees": [str(t)[:80] for t in e.get("trees", []) if isinstance(t, (str, int))][:200],
671 "seen": seen,
672 "via": via,
673 "network": net,
674 }
675 changed += 1
676 if len(self.directory_entries) > DIRECTORY_MAX:
677 for key in sorted(self.directory_entries, key=lambda k: self.directory_entries[k]["seen"])[: len(self.directory_entries) - DIRECTORY_MAX]:
678 del self.directory_entries[key]
679 if changed:
680 self._save()
681 return changed
682
683 def directory(self, private: bool = True) -> dict[str, Any]:
684 """Every hub this one has heard of that asked to be found, this one
685 included if listed: one row per hub, naming the networks it was
686 heard in. ``link`` is how recently each announced. Without
687 ``private`` (a caller without the operator's token), only what was
688 heard outside any network or in a public one."""
689 now = time.time()
690 visible = None if private else self._public_networks()
691 names = {n.id: n.name for n in self.networks.values()}
692 rows: dict[str, dict[str, Any]] = {}
693 own = self.own_entry()
694 if own:
695 rows[self.id] = {**own, "via": "self", "networks": sorted(n.id for n in self.networks.values() if private or n.public)}
696 with self._lock:
697 entries = [dict(e) for e in self.directory_entries.values() if now - e["seen"] < DIRECTORY_TTL and (visible is None or e.get("network") in visible)]
698 for e in sorted(entries, key=lambda e: -e["seen"]):
699 row = rows.get(e["id"])
700 if row is None:
701 rows[e["id"]] = row = {**{k: v for k, v in e.items() if k != "network"}, "networks": []}
702 if e.get("network") and e["network"] not in row["networks"]:
703 row["networks"].append(e["network"])
704 hubs = [{"id": r["id"], "name": r["name"], "kind": r.get("kind", "hub"), "trees": r["trees"], "seen": r["seen"], "link": heard(r["seen"], now), "via": r.get("via", ""), "networks": sorted(r["networks"])} for r in rows.values()]
705 hubs.sort(key=lambda h: (h["id"] != self.id, {"up": 0, "stale": 1, "down": 2}[h["link"]], h["name"], h["id"]))
706 shown = {nid for h in hubs for nid in h["networks"]}
707 return {"hub": self.id, "name": self.name, "listed": self.listed, "count": len(hubs), "hubs": hubs, "networks": {nid: names[nid] for nid in sorted(shown) if nid in names}}
708
709 # ------------------------------------------------------------ gap reports
710 # A hub that opted in (--report-gaps) puts the gaps its catalog trees met
711 # into its announces: the tree's id (namespace/name@version), the node,
712 # the path decided on and the value no branch covered, with a count;
713 # never the inputs or the rest of the state. Every federated hub keeps
714 # what it hears and passes it on, like the directory, so the reports
715 # reach the listed hubs and the snapshot pyrtan.com bakes, where an author
716 # reads them beside the tree. Nothing is reported for a tree without a
717 # namespace: there is nobody to report to.
718
719 def report_gossip(self) -> list[dict[str, Any]]:
720 now = time.time()
721 own = []
722 if self.report_gaps:
723 for r in self.own_reports():
724 shaped = _shape_report(r, self.id, now)
725 if shaped:
726 own.append(shaped)
727 with self._lock:
728 heard = [r for r in self.reports.values() if r["reporter"] != self.id and now - r["last"] < REPORT_TTL]
729 return own + sorted(heard, key=lambda r: -r["last"])
730
731 def merge_reports(self, entries: Any, via: str) -> int:
732 """Take reports heard from ``via``: one row per reporter and gap, a
733 newer ``last`` replaces an older one, nothing from the future is
734 believed, old news is dropped, the table stays bounded."""
735 if not isinstance(entries, list):
736 return 0
737 now = time.time()
738 changed = 0
739 with self._lock:
740 for e in entries:
741 shaped = _shape_report(e, via, now) if isinstance(e, dict) else None
742 if shaped is None or shaped["reporter"] == self.id:
743 continue
744 key = json.dumps([shaped["reporter"], shaped["id"], shaped["node"], shaped["on"], shaped["observed"]], sort_keys=True, ensure_ascii=False)
745 current = self.reports.get(key)
746 if current is not None and (current["last"], current["count"]) >= (shaped["last"], shaped["count"]):
747 continue
748 self.reports[key] = shaped
749 changed += 1
750 if len(self.reports) > REPORT_MAX:
751 for key in sorted(self.reports, key=lambda k: self.reports[k]["last"])[: len(self.reports) - REPORT_MAX]:
752 del self.reports[key]
753 if changed:
754 self._save()
755 return changed
756
757 def reports_view(self) -> dict[str, Any]:
758 """Every gap report this hub carries, one row per gap with the
759 counts and reporters summed, most seen first."""
760 rows: dict[str, dict[str, Any]] = {}
761 for r in self.report_gossip():
762 key = json.dumps([r["id"], r["node"], r["on"], r["observed"]], sort_keys=True, ensure_ascii=False)
763 row = rows.get(key)
764 if row is None:
765 rows[key] = {"id": r["id"], "node": r["node"], "on": r["on"], "observed": r["observed"], "count": r["count"], "reporters": 1, "last": r["last"]}
766 else:
767 row["count"] += r["count"]
768 row["reporters"] += 1
769 row["last"] = max(row["last"], r["last"])
770 out = sorted(rows.values(), key=lambda r: (-r["count"], -r["last"], r["id"]))
771 return {"hub": self.id, "reporting": self.report_gaps, "count": len(out), "reports": out}
772
773 # ------------------------------------------------------------ outgoing
774
775 def _activity(self, type_: str, object_: Any, **extra: Any) -> dict[str, Any]:
776 return {"type": type_, "id": f"{self.id}/activities/{uuid.uuid4()}", "actor": self.id, "published": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "object": object_, **extra}
777
778 def _announcement(self, for_remote: Remote | None = None) -> dict[str, Any]:
779 out = {"type": "Capabilities", "name": self.name, "trees": self.capabilities(), "directory": self.gossip(for_remote.network if for_remote is not None else None)}
780 reports = self.report_gossip()
781 if reports:
782 out["reports"] = reports
783 if for_remote is not None:
784 out["kept"] = [b["key"] for b in self.store.list_kept(for_remote.url)] # what we keep for them
785 return out
786
787 def deliver(self, remote: Remote, activity: dict[str, Any], secret: str | None = None) -> tuple[bool, str | None, Any]:
788 """Post an activity to a remote's inbox, signed with the pair secret
789 (or the one given); for a member, leave it in its mailbox. Returns
790 (delivered, error, the inbox's answer)."""
791 if remote.inbox == "mailbox":
792 box = self.mailboxes.setdefault(remote.url, deque(maxlen=MAILBOX_MAX))
793 box.append(activity)
794 self.outbox.append({**activity, "to": remote.url, "delivered": True, "error": None, "mailbox": True})
795 return True, None, None
796 path = "/inbox"
797 headers = sign(secret or remote.secret, self.id, "POST", path, activity)
798 try:
799 status, answer = http_json("POST", remote.inbox, activity, timeout=10.0, headers=headers)
800 except Exception as e:
801 if activity.get("type") not in UNLOGGED:
802 self.outbox.append({**_logged(activity), "to": remote.url, "delivered": False, "error": str(e)})
803 return False, str(e), None
804 ok = status in (200, 202)
805 if activity.get("type") not in UNLOGGED:
806 self.outbox.append({**_logged(activity), "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})
807 if ok and isinstance(answer, dict):
808 if activity.get("type") == "Announce":
809 # An inbox answers an Announce with its own gossip: one round trip, both directions.
810 self.merge_directory(answer.get("directory"), via=remote.url, network=remote.network)
811 self.merge_reports(answer.get("reports"), via=remote.url)
812 if activity.get("type") == "Keep" and answer.get("kept") == activity["object"].get("key"):
813 self.confirm(remote.url, activity["object"]["key"], activity["object"]["sha256"])
814 return ok, None if ok else f"inbox answered {status}: {answer}", answer
815
816 def mailbox(self, actor: str) -> list[dict[str, Any]]:
817 """Hand a member what has been waiting for it, and forget it."""
818 box = self.mailboxes.get(actor.rstrip("/"))
819 if not box:
820 return []
821 out = list(box)
822 box.clear()
823 remote = self.followers.get(actor.rstrip("/"))
824 if remote is not None:
825 remote.last_ok = time.time()
826 remote.last_error = None
827 return out
828
829 def follow(self, url: str, network: str | None = None) -> Remote:
830 """Follow the hub at ``url``: fetch its actor, make a pair secret,
831 send the Follow. The remote is pending until it Accepts. In a
832 ``network`` (a name this hub is in), the Follow names it and is
833 signed with its secret, and what the two hear of the directory over
834 this link stays in it."""
835 url = url.rstrip("/")
836 if url == self.id:
837 raise ValueError("a hub cannot follow itself")
838 net = self.network(network) if network is not None else None
839 try:
840 status, actor = http_json("GET", f"{url}/actor", timeout=10.0)
841 except Exception as e:
842 raise ValueError(f"{url} does not answer /actor: {e}") from e
843 if status != 200 or not isinstance(actor, dict) or actor.get("type") != "Hub" or not isinstance(actor.get("inbox"), str):
844 raise ValueError(f"{url} does not answer /actor as a Pyrtan hub ({status})")
845 with self._lock:
846 remote = self.following.get(url)
847 if remote is None:
848 remote = Remote(url, actor["inbox"], _secrets.token_hex(32), actor.get("name", ""))
849 self.following[url] = remote
850 else:
851 remote.inbox = actor["inbox"]
852 remote.name = actor.get("name", remote.name)
853 remote.state = "pending"
854 remote.network = net.id if net else None
855 self._save()
856 extra = {"network": net.id} if net else {}
857 activity = self._activity("Follow", url, secret=remote.secret, inbox=self.inbox, name=self.name, **extra)
858 # In a network the Follow is signed with its secret; under a shared
859 # federation secret with that; in the open case with the pair
860 # secret, which proves nothing yet but keeps every message signed.
861 ok, error, _ = self.deliver(remote, activity, net.secret if net else (self.secret or remote.secret))
862 if not ok:
863 remote.last_error = error
864 elif remote.state == "accepted":
865 self.announce_up(force=True) # tell it about us, and hear the directory, now
866 return remote
867
868 def unfollow(self, url: str) -> bool:
869 url = url.rstrip("/")
870 with self._lock:
871 remote = self.following.pop(url, None)
872 self._save()
873 if remote is None:
874 return False
875 self.deliver(remote, self._activity("Undo", {"type": "Follow", "actor": self.id, "object": url}))
876 return True
877
878 def _due(self, last: tuple[str, float], announcement: dict[str, Any], force: bool) -> tuple[bool, str]:
879 with self._lock:
880 # Every network's directory: a change in any is news for the links in it.
881 heard = sorted((e.get("network") or "", e["id"], e["trees"]) for e in self.directory_entries.values())
882 digest = hashlib.sha256(canonical({"trees": [{k: t.get(k) for k in ("name", "version", "triggers", "inputs", "language", "hosts")} for t in announcement["trees"]], "directory": [(e["id"], e["trees"]) for e in announcement["directory"]], "heard": heard})).hexdigest()
883 due = force or digest != last[0] or time.time() - last[1] >= ANNOUNCE_EVERY
884 return due, digest
885
886 def announce(self, force: bool = False) -> int:
887 """Tell every follower what this hub's own services hold and what
888 the directory says: when either changed, on a heartbeat, or
889 ``force``. Returns how many deliveries succeeded."""
890 announcement = self._announcement()
891 due, digest = self._due(self._announced, announcement, force)
892 if not due or not self.followers:
893 return 0
894 self._announced = (digest, time.time())
895 delivered = 0
896 for remote in list(self.followers.values()):
897 ok, error, _ = self.deliver(remote, self._activity("Announce", self._announcement(remote)))
898 if ok:
899 delivered += 1
900 remote.last_ok = time.time()
901 remote.last_error = None
902 else:
903 remote.last_error = error
904 return delivered
905
906 def announce_up(self, force: bool = False) -> int:
907 """Tell every hub this one follows what it holds and what the
908 directory says. The followed hub routes none of it (being followed
909 grants nothing); it keeps the directory and, if this hub is
910 listed, finds it there."""
911 announcement = self._announcement()
912 due, digest = self._due(self._announced_up, announcement, force)
913 accepted = [r for r in self.following.values() if r.state == "accepted"]
914 if not due or not accepted:
915 return 0
916 self._announced_up = (digest, time.time())
917 delivered = 0
918 for remote in accepted:
919 ok, _, _ = self.deliver(remote, self._activity("Announce", self._announcement(remote)))
920 delivered += ok
921 return delivered
922
923 def refresh(self, remote: Remote) -> None:
924 """Poll a hub we follow, signed: the fallback when its announces
925 do not arrive, and what keeps its link honest."""
926 if remote.state != "accepted":
927 return
928 path = "/capabilities"
929 try:
930 status, caps = http_json("GET", f"{remote.url}{path}", timeout=5.0, headers=sign(remote.secret, self.id, "GET", path, None))
931 except Exception as e:
932 remote.last_error = str(e)
933 return
934 if status == 200 and isinstance(caps, dict):
935 self._take_capabilities(remote, caps)
936 else:
937 remote.last_error = f"capabilities answered {status}"
938
939 def refresh_all(self) -> None:
940 for remote in list(self.following.values()):
941 self.refresh(remote)
942 self.auto()
943 self.announce_up()
944 self.announce()
945 self.replicate()
946 self.hand_out()
947
948 # ------------------------------------------------------------ keeping
949
950 def keep(self, key: str, data: Any) -> dict[str, Any]:
951 """Keep a blob of our own and see that enough others keep it too."""
952 meta = self.store.put_own(key, data)
953 with self._lock:
954 entry = self.keeping.setdefault(key, {"holders": {}})
955 if entry.get("sha256") != meta["sha256"]:
956 entry["asked"] = {}
957 entry.update({"sha256": meta["sha256"], "size": meta["size"], "updated": meta["updated"]})
958 self._save()
959 self.replicate(only=key)
960 return self.keeping_view(key)
961
962 def confirm(self, actor: str, key: str, sha256: str) -> None:
963 with self._lock:
964 entry = self.keeping.get(key)
965 if entry is None:
966 return
967 entry["holders"][actor.rstrip("/")] = {"at": time.time(), "sha256": sha256}
968 entry.get("asked", {}).pop(actor.rstrip("/"), None)
969 self._save()
970
971 def holders(self, key: str) -> list[str]:
972 """Who holds the current version of a blob, confirmed recently."""
973 entry = self.keeping.get(key)
974 if not entry:
975 return []
976 now = time.time()
977 return sorted(a for a, h in entry["holders"].items() if h.get("sha256") == entry.get("sha256") and now - float(h.get("at", 0)) < HOLDER_FRESH)
978
979 def keeping_view(self, key: str | None = None) -> Any:
980 def view(k: str, e: dict[str, Any]) -> dict[str, Any]:
981 return {"key": k, "sha256": e.get("sha256"), "size": e.get("size"), "updated": e.get("updated"), "holders": self.holders(k), "wanted": self.replicas, "known": sorted(e["holders"])}
982
983 with self._lock:
984 if key is not None:
985 e = self.keeping.get(key)
986 return view(key, e) if e else None
987 return [view(k, e) for k, e in sorted(self.keeping.items())]
988
989 def keepers(self) -> list[Remote]:
990 """Who we may ask to keep a blob: everyone we share a secret with, members first."""
991 remotes = [r for r in self.followers.values() if r.state == "accepted"] + [r for r in self.following.values() if r.state == "accepted"]
992 remotes.sort(key=lambda r: (r.kind != "member", r.link != "up", r.url))
993 return remotes
994
995 def replicate(self, only: str | None = None, limit: int = 20) -> int:
996 """Send ``Keep`` for every blob with fewer fresh holders than
997 ``replicas``, to keepers that do not hold it, at most ``limit`` at a
998 time. Returns how many were sent."""
999 sent = 0
1000 now = time.time()
1001 for key in ([only] if only else list(self.keeping)):
1002 entry = self.keeping.get(key)
1003 blob = self.store.get_own(key)
1004 if not entry or not blob:
1005 continue
1006 asked = entry.setdefault("asked", {})
1007 # A member confirms later, through its mailbox: count it as covered
1008 # for a while rather than asking every member at once.
1009 have = set(self.holders(key)) | {actor for actor, t in asked.items() if now - float(t) < ASK_WINDOW}
1010 # Someone new before a holder gone stale: the stale one may be gone for good.
1011 for remote in sorted(self.keepers(), key=lambda r: r.url in entry["holders"]):
1012 if len(have) >= self.replicas or sent >= limit:
1013 break
1014 if remote.url in have:
1015 continue
1016 ok, _, _ = self.deliver(remote, self._activity("Keep", {"key": key, "sha256": blob["sha256"], "size": blob["size"], "updated": blob["updated"], "data": blob["data"]}))
1017 if ok:
1018 sent += 1
1019 have.add(remote.url)
1020 if remote.inbox == "mailbox":
1021 asked[remote.url] = now
1022 return sent
1023
1024 def auto(self) -> None:
1025 """Keep the automatic blobs (directory, gaps, ...) when they changed."""
1026 for key, fn in self.auto_keys.items():
1027 try:
1028 data = fn()
1029 except Exception:
1030 continue
1031 sha, size = blob_digest(data)
1032 if size > 256 * 1024 or self._auto_digest.get(key) == sha:
1033 continue
1034 self._auto_digest[key] = sha
1035 try:
1036 self.keep(key, data)
1037 except StoreError:
1038 continue
1039
1040 def hand_out(self, limit: int = 20) -> int:
1041 """Give every trusted member the trees it should run (``trees/<name>``
1042 blobs), each once per version until the member confirms. Returns
1043 how many were sent."""
1044 trees = self.handout()
1045 if not trees:
1046 return 0
1047 now = time.time()
1048 sent = 0
1049 for remote in list(self.followers.values()):
1050 if remote.kind != "member" or not remote.trusted or remote.state != "accepted":
1051 continue
1052 handed = self._handed.setdefault(remote.url, {})
1053 for name, tree in trees.items():
1054 key = f"trees/{name}"
1055 raw = tree.raw
1056 sha, size = blob_digest(raw)
1057 h = handed.get(key)
1058 if h and h["sha256"] == sha and (h["confirmed"] or now - h["at"] < ASK_WINDOW):
1059 continue
1060 if sent >= limit:
1061 return sent
1062 ok, _, _ = self.deliver(remote, self._activity("Keep", {"key": key, "sha256": sha, "size": size, "updated": now, "data": raw}))
1063 if ok:
1064 handed[key] = {"sha256": sha, "at": now, "confirmed": False}
1065 sent += 1
1066 return sent
1067
1068 def ask(self, remote: Remote, tree: str, inputs: dict[str, Any], depth: int = 0, timeout: float | None = None) -> dict[str, Any]:
1069 """Have a trusted member run a tree: an Ask in its mailbox, then wait
1070 for its Answer (at most ``timeout`` seconds, default ``ask_timeout``;
1071 a member polls)."""
1072 timeout = self.ask_timeout if timeout is None else timeout
1073 run = uuid.uuid4().hex
1074 pending = {"member": remote.url, "event": threading.Event(), "answer": None}
1075 self._asks[run] = pending
1076 try:
1077 self.deliver(remote, self._activity("Ask", {"run": run, "tree": tree, "inputs": inputs, "depth": depth}))
1078 if not pending["event"].wait(timeout):
1079 return {"status": "error", "reason": "member-timeout", "tree": tree, "say": f"{remote.name or remote.url} did not answer within {int(timeout)} s; a member answers when it next polls."}
1080 answer = dict(pending["answer"] or {})
1081 answer.setdefault("tree", tree)
1082 return answer
1083 finally:
1084 self._asks.pop(run, None)
1085
1086 def kept_for(self, actor: str, key: str) -> dict[str, Any] | None:
1087 """A blob we keep for ``actor``, for it to take back."""
1088 return self.store.get_kept(actor, key)
1089
1090 def fetch(self, key: str) -> dict[str, Any]:
1091 """Our own copy of a blob, or the copy a holder keeps: a hub answers
1092 at once, a member is asked through its mailbox and answers later."""
1093 own = self.store.get_own(key)
1094 if own:
1095 return {"status": "ok", "source": "own", "blob": own}
1096 asked: list[str] = []
1097 entry = self.keeping.get(key, {})
1098 for actor in sorted(entry.get("holders", {})):
1099 remote = self.followers.get(actor) or self.following.get(actor)
1100 if remote is None:
1101 continue
1102 if remote.inbox == "mailbox":
1103 self.deliver(remote, self._activity("Fetch", {"key": key}))
1104 asked.append(actor)
1105 continue
1106 path = f"/kept/{key}"
1107 try:
1108 status, blob = http_json("GET", f"{remote.url}{path}", timeout=10.0, headers=sign(remote.secret, self.id, "GET", path, None))
1109 except Exception:
1110 continue
1111 if status == 200 and isinstance(blob, dict) and "data" in blob:
1112 try:
1113 self.store.put_own(key, blob["data"])
1114 except StoreError:
1115 continue
1116 return {"status": "ok", "source": actor, "blob": self.store.get_own(key)}
1117 if asked:
1118 return {"status": "asked", "say": f"{len(asked)} member(s) asked through their mailbox; try again once they have answered.", "asked": asked}
1119 return {"status": "error", "reason": "not-held", "say": f"Nobody holds {key!r}."}
1120
1121 def _adopt(self, key: str, blob: Any, who: str) -> bool:
1122 """Take a blob a keeper hands back, when we have no copy of our own."""
1123 if not isinstance(blob, dict) or "data" not in blob or self.store.get_own(key):
1124 return False
1125 try:
1126 self.store.put_own(key, blob["data"])
1127 except StoreError:
1128 return False
1129 with self._lock:
1130 entry = self.keeping.setdefault(key, {"holders": {}})
1131 meta = self.store.get_own(key) or {}
1132 entry.update({"sha256": meta.get("sha256"), "size": meta.get("size"), "updated": meta.get("updated")})
1133 entry["holders"][who] = {"at": time.time(), "sha256": meta.get("sha256")}
1134 self._save()
1135 if key == "directory":
1136 self.merge_directory(blob.get("data"), via=who, keep_tags=True)
1137 return True
1138
1139 def _take_capabilities(self, remote: Remote, caps: dict[str, Any]) -> None:
1140 with self._lock:
1141 # Only what the hub holds itself: a tree it learned from a hub it
1142 # follows is two hops away and not ours to route to.
1143 remote.trees = [t for t in caps.get("trees", []) if isinstance(t, dict) and not t.get("via")]
1144 remote.name = caps.get("name") or remote.name
1145 remote.last_ok = time.time()
1146 remote.last_error = None
1147
1148 # ------------------------------------------------------------ incoming
1149
1150 def secret_for(self, actor: str) -> str | None:
1151 """The secret a request from ``actor`` is signed with. A hub asks the
1152 hubs it follows, so the pair in which the actor follows us comes
1153 first; with a mutual follow there are two pairs and this is the one
1154 the actor signs requests to us with."""
1155 actor = actor.rstrip("/")
1156 remote = self.followers.get(actor) or self.following.get(actor)
1157 return remote.secret if remote else None
1158
1159 def verify_request(self, method: str, path: str, body: Any, headers: dict[str, str] | None) -> str | None:
1160 """For a signed request to a routed path: the actor it is from, or
1161 None (and the caller answers 401)."""
1162 h = _lower(headers)
1163 actor = h.get("X-Pyrtan-Actor", "").rstrip("/")
1164 secret = self.secret_for(actor)
1165 if not actor or secret is None:
1166 return None
1167 return actor if verify(secret, method, path, body, h) is None else None
1168
1169 def handle_inbox(self, activity: Any, headers: dict[str, str] | None, trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]:
1170 """``trusted`` says the request also carried the operator's token, or
1171 a key whose root this hub grants ``run`` (``trusted_by`` says which):
1172 a member that follows that way may run the hub's trees. ``signer`` is
1173 the :class:`pyrtan.identity.Signer` of a key-signed request, if any."""
1174 h = _lower(headers)
1175 if not isinstance(activity, dict) or activity.get("type") not in ACTIVITY_TYPES or not isinstance(activity.get("actor"), str):
1176 return 400, {"status": "error", "reason": "bad-activity", "say": f"Send an activity of type {', '.join(ACTIVITY_TYPES)} with an actor."}
1177 actor = activity["actor"].rstrip("/")
1178 if h.get("X-Pyrtan-Actor", "").rstrip("/") != actor:
1179 return 401, {"status": "error", "reason": "unsigned", "say": "The signature's actor must be the activity's actor."}
1180 kind = activity["type"]
1181 if activity.get("type") not in UNLOGGED:
1182 self.inbox_log.append({**_logged(activity), "received": time.time()})
1183 if kind == "Follow":
1184 return self._on_follow(actor, activity, h, trusted, trusted_by=trusted_by, signer=signer)
1185 if kind == "ChatLink":
1186 return self._on_chat_link(actor, activity, h)
1187 followed = self.following.get(actor)
1188 follower = self.followers.get(actor)
1189 if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES):
1190 # Announce from a hub we follow: its trees are ours to route to;
1191 # from a follower: only the directory it carries. Keeping and
1192 # chatting go both ways between any two that share a secret.
1193 remote = followed or follower
1194 else:
1195 remote = followed if kind in ("Accept", "Reject") else follower
1196 linked = False
1197 if remote is None and kind in CHAT_TYPES and self.chat is not None:
1198 # A chat link: chat may travel on it, and nothing else does.
1199 remote = self.chat.link_for(actor)
1200 linked = remote is not None
1201 if remote is None:
1202 return 403, {"status": "error", "reason": "unknown-actor", "say": f"{actor} is not a hub this one follows or is followed by."}
1203 problem = verify(remote.secret, "POST", "/inbox", activity, h)
1204 if problem and kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES) and followed and follower and followed is not follower:
1205 # Mutual follow: the announce may be signed with the other pair.
1206 remote = follower if remote is followed else followed
1207 problem = verify(remote.secret, "POST", "/inbox", activity, h)
1208 if problem and kind in CHAT_TYPES and self.chat is not None and not linked:
1209 # A follow made after a chat link: the line may be signed with the link's secret.
1210 link = self.chat.link_for(actor)
1211 if link is not None and verify(link.secret, "POST", "/inbox", activity, h) is None:
1212 remote, linked, problem = link, True, None
1213 if problem:
1214 return 401, {"status": "error", "reason": "unsigned", "say": problem}
1215 if kind in CHAT_TYPES:
1216 if self.chat is None:
1217 return 404, {"status": "error", "reason": "no-chat", "say": f"{self.id} keeps no lobby."}
1218 if remote.kind != "hub":
1219 return 403, {"status": "error", "reason": "not-a-hub", "say": "Strangers meet hub to hub; a member chats through its hub's lobby."}
1220 remote.last_ok = time.time()
1221 return self.chat.on_activity(kind, remote, activity.get("object"), follower=remote is follower or linked)
1222 if kind == "Accept":
1223 with self._lock:
1224 remote.state = "accepted"
1225 remote.last_ok = time.time()
1226 remote.last_error = None
1227 self._save()
1228 return 200, {"status": "ok"}
1229 if kind == "Reject":
1230 with self._lock:
1231 remote.state = "rejected"
1232 remote.last_error = str(activity.get("summary") or "the hub refused the follow")
1233 self._save()
1234 return 200, {"status": "ok"}
1235 if kind == "Announce":
1236 obj = activity.get("object")
1237 if not isinstance(obj, dict) or not isinstance(obj.get("trees"), list):
1238 return 400, {"status": "error", "reason": "bad-activity", "say": "An Announce carries {\"trees\": [...]}."}
1239 if remote is followed:
1240 self._take_capabilities(remote, obj)
1241 else:
1242 with self._lock:
1243 remote.name = obj.get("name") or remote.name
1244 remote.last_ok = time.time()
1245 remote.last_error = None
1246 if remote.kind == "member" and remote.trusted:
1247 # A trusted member runs the trees we handed it, and only those;
1248 # what each tree answers to is our copy's word, not the member's.
1249 handed = self.handout()
1250 names = {t.get("name") for t in obj.get("trees", []) if isinstance(t, dict)}
1251 remote.trees = [handed[n].capability() for n in sorted(names & set(handed))]
1252 taken = self.merge_directory(obj.get("directory", []), via=actor, network=remote.network)
1253 self.merge_reports(obj.get("reports", []), via=actor)
1254 # What it keeps for us that we no longer have: ask for it back.
1255 wanted = [k for k in obj.get("kept", []) if isinstance(k, str) and not self.store.get_own(k)]
1256 for k in wanted[:20]:
1257 self.deliver(remote, self._activity("Fetch", {"key": k}))
1258 return 200, {"status": "ok", "trees": len(remote.trees) if remote is followed else 0, "merged": taken, "directory": self.gossip(remote.network), "reports": self.report_gossip(), "kept": [b["key"] for b in self.store.list_kept(actor)]}
1259 if kind == "Keep":
1260 obj = activity.get("object")
1261 if not isinstance(obj, dict) or "data" not in obj:
1262 return 400, {"status": "error", "reason": "bad-activity", "say": "A Keep carries {\"key\", \"sha256\", \"data\"}."}
1263 try:
1264 meta = self.store.put_kept(actor, obj.get("key"), obj["data"], sha256=obj.get("sha256"), updated=obj.get("updated"))
1265 except StoreError as e:
1266 return 413, {"status": "error", "reason": "not-kept", "say": str(e)}
1267 remote.last_ok = time.time()
1268 return 200, {"status": "ok", "kept": meta["key"], "sha256": meta["sha256"]}
1269 if kind == "Kept":
1270 obj = activity.get("object")
1271 if not isinstance(obj, dict) or not isinstance(obj.get("key"), str):
1272 return 400, {"status": "error", "reason": "bad-activity", "say": "A Kept names a key."}
1273 if obj["key"].startswith("trees/"):
1274 with self._lock:
1275 handed = self._handed.setdefault(actor, {}).get(obj["key"])
1276 if handed and handed.get("sha256") == obj.get("sha256"):
1277 handed["confirmed"] = True
1278 elif "data" in obj:
1279 self._adopt(obj["key"], obj, actor)
1280 elif isinstance(obj.get("sha256"), str):
1281 self.confirm(actor, obj["key"], obj["sha256"])
1282 remote.last_ok = time.time()
1283 return 200, {"status": "ok"}
1284 if kind == "Answer":
1285 obj = activity.get("object")
1286 run = obj.get("run") if isinstance(obj, dict) else None
1287 pending = self._asks.get(run) if isinstance(run, str) else None
1288 if pending is None or pending["member"] != actor:
1289 return 404, {"status": "error", "reason": "no-such-ask", "say": "No run waits for this answer from this member."}
1290 pending["answer"] = obj.get("outcome") if isinstance(obj.get("outcome"), dict) else {"status": "error", "reason": "bad-answer", "say": "The member's answer was not an outcome."}
1291 pending["event"].set()
1292 remote.last_ok = time.time()
1293 return 200, {"status": "ok"}
1294 if kind == "Fetch":
1295 obj = activity.get("object")
1296 key = obj.get("key") if isinstance(obj, dict) else None
1297 blob = self.store.get_kept(actor, key) if isinstance(key, str) else None
1298 if blob is None:
1299 return 404, {"status": "error", "reason": "not-kept", "say": f"Nothing kept here for {actor} under {key!r}."}
1300 return 200, {"status": "ok", "blob": blob}
1301 if kind == "Undo":
1302 self.drop_follower(actor)
1303 return 200, {"status": "ok"}
1304 return 400, {"status": "error", "reason": "bad-activity"}
1305
1306 def drop_follower(self, actor: str) -> bool:
1307 """Forget a follower, its mailbox, what we handed it and what we keep for it."""
1308 with self._lock:
1309 gone = self.followers.pop(actor, None) is not None
1310 self.mailboxes.pop(actor, None)
1311 self._handed.pop(actor, None)
1312 self.store.drop_kept(actor)
1313 self._save()
1314 return gone
1315
1316 def _on_chat_link(self, actor: str, activity: dict[str, Any], h: dict[str, str]) -> tuple[int, dict[str, Any]]:
1317 """A hub asks for a chat link: checked as an open ``Follow`` is (signed
1318 with the pair secret it carries; its inbox is the one its ``/actor``
1319 names), then the lobby decides. A closed federation links with nobody."""
1320 pair, inbox = activity.get("secret"), activity.get("inbox")
1321 if not isinstance(pair, str) or len(pair) < 16 or not isinstance(inbox, str) or not actor.startswith(("http://", "https://")):
1322 return 400, {"status": "error", "reason": "bad-activity", "say": "A ChatLink comes from a hub and carries a pair secret and its inbox."}
1323 if self.chat is None:
1324 return 404, {"status": "error", "reason": "no-chat", "say": f"{self.id} keeps no lobby."}
1325 if self.secret or not self.open_follows:
1326 return 403, {"status": "error", "reason": "closed", "say": "This hub links only with hubs its operator chose."}
1327 problem = verify(pair, "POST", "/inbox", activity, h)
1328 if problem:
1329 return 401, {"status": "error", "reason": "unsigned", "say": problem}
1330 try:
1331 status, doc = http_json("GET", f"{actor}/actor", timeout=10.0)
1332 except Exception as e:
1333 return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor did not answer: {e}"}
1334 if status != 200 or not isinstance(doc, dict) or doc.get("id", "").rstrip("/") != actor or doc.get("inbox") != inbox:
1335 return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor does not describe this hub."}
1336 return self.chat.accept_link(Remote(actor, inbox, pair, str(activity.get("name") or doc.get("name") or "")[:80], state="accepted", last_ok=time.time(), kind="hub"))
1337
1338 def _on_follow(self, actor: str, activity: dict[str, Any], h: dict[str, str], trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]:
1339 pair = activity.get("secret")
1340 inbox = activity.get("inbox")
1341 if not isinstance(pair, str) or len(pair) < 16 or not isinstance(inbox, str):
1342 return 400, {"status": "error", "reason": "bad-activity", "say": "A Follow carries a pair secret and the follower's inbox."}
1343 nid = activity.get("network")
1344 net = self.network_by_id(nid)
1345 if nid is not None:
1346 # A follow in a network: signed with its secret, and only into a network this hub is in.
1347 if net is None:
1348 return 403, {"status": "error", "reason": "not-in-network", "say": "This hub is not in the network this follow names."}
1349 problem = verify(net.secret, "POST", "/inbox", activity, h)
1350 if problem:
1351 return 401, {"status": "error", "reason": "unsigned", "say": f"A follow in {net.name} is signed with its secret: {problem}."}
1352 elif self.secret:
1353 problem = verify(self.secret, "POST", "/inbox", activity, h)
1354 if problem:
1355 return 401, {"status": "error", "reason": "unsigned", "say": f"This hub takes follows signed with its federation secret: {problem}."}
1356 elif not self.open_follows:
1357 return 403, {"status": "error", "reason": "closed", "say": "This hub does not take follows."}
1358 else:
1359 problem = verify(pair, "POST", "/inbox", activity, h)
1360 if problem:
1361 return 401, {"status": "error", "reason": "unsigned", "say": problem}
1362 if inbox == "mailbox":
1363 # A member: a browser or a phone, with no address of its own. It
1364 # polls its mailbox here, signed, instead of being posted to.
1365 if actor.startswith(KEY_PREFIX):
1366 if signer is None or signer.key != actor:
1367 return 401, {"status": "error", "reason": "unsigned", "say": f"A member whose id is a key signs its follow with that key (X-Pyrtan-Key: {actor})."}
1368 elif not actor.startswith(MEMBER_PREFIX) or len(actor) < len(MEMBER_PREFIX) + 16:
1369 return 400, {"status": "error", "reason": "bad-activity", "say": f"A member's id is {MEMBER_PREFIX}<at least 16 characters>, or its key, pyr:…."}
1370 name = str(activity.get("name") or "")[:80]
1371 kind = "member"
1372 else:
1373 # The inbox must be the actor's own: ask the actor.
1374 try:
1375 status, doc = http_json("GET", f"{actor}/actor", timeout=10.0)
1376 except Exception as e:
1377 return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor did not answer: {e}"}
1378 if status != 200 or not isinstance(doc, dict) or doc.get("id", "").rstrip("/") != actor or doc.get("inbox") != inbox:
1379 return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor does not describe this follower."}
1380 name = str(activity.get("name") or doc.get("name") or "")
1381 kind = "hub"
1382 with self._lock:
1383 remote = Remote(actor, inbox, pair, name, state="accepted", last_ok=time.time(), kind=kind, trusted=bool(trusted and kind == "member"), network=net.id if net else None)
1384 if remote.trusted:
1385 remote.trusted_by = trusted_by or "token"
1386 if signer is not None and kind == "member":
1387 remote.identity, remote.device, remote.note = signer.root, signer.key, signer.note
1388 self.followers[actor] = remote
1389 self._handed.pop(actor, None)
1390 self._save()
1391 self.deliver(remote, self._activity("Accept", {"type": "Follow", "actor": actor, "object": self.id}))
1392 self.deliver(remote, self._activity("Announce", self._announcement(remote)))
1393 if remote.trusted:
1394 self.hand_out()
1395 return 202, {"status": "ok", "say": f"{self.id} accepts {actor}.", "mailbox": f"{self.id}/mailbox" if kind == "member" else None, "trusted": remote.trusted, "identity": remote.identity}
1396
1397 # ------------------------------------------------------------ views
1398
1399 def to_dict(self) -> dict[str, Any]:
1400 with self._lock:
1401 return {"id": self.id, "listed": self.listed, "following": [r.to_dict() for r in self.following.values()], "followers": [r.to_dict() for r in self.followers.values()], "directory": len({e["id"] for e in self.directory_entries.values()}) + (1 if self.listed else 0), "keeping": len(self.keeping), "store": self.store.usage(), "replicas": self.replicas, "networks": [{"name": n.name, "id": n.id, "public": n.public} for n in self.networks.values()]}