Merge pull request #5 from b-i-t-8/claude/ecstatic-euler-rki9t4
b-i-t-8 · 2026-10-25 · dfd0fda1fc7af1e112b1e1cb8bea8c4f14a29939 · parents cbbc340 e11d55b
Make pyrtan.com the rendezvous: listed follows, a directory, the hub stack
Changes
.github/workflows/image.yml | 52 +++++++++++++++++++++++++++++++ README.md | 9 ++++-- _meta/federation.md | 38 +++++++++++++++++++---- _meta/platform.md | 12 +++++++- deploy/pyrtan.com/README.md | 38 ++++++++++++++++++++++- deploy/pyrtan.com/index.html | 7 +++++ deploy/pyrtan.com/rendezvous/Caddyfile | 7 +++++ deploy/pyrtan.com/rendezvous/docker-compose.yml | 75 +++++++++++++++++++++++++++++++++++++++++++++ src/pyrtan/cli.py | 56 +++++++++++++++++++++++++++------ src/pyrtan/federation.py | 61 +++++++++++++++++++++++++++++++----- src/pyrtan/hub.py | 9 ++++-- src/pyrtan/page.py | 20 ++++++++++++ src/pyrtan/service.py | 2 +- tests/test_federation.py | 52 +++++++++++++++++++++++++++++++ 14 files changed, 408 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..6a5ff67 --- /dev/null +++ b/.github/workflows/image.yml@@ -0,0 +1,52 @@+# Build the image and publish it to GitHub's registry on every tag, so a+# deployment (deploy/pyrtan.com/rendezvous, the compose stack, hosting)+# can pull ghcr.io/b-i-t-8/pyrtan:<version> instead of building from a+# checkout. Tests run first; a tag whose tests fail publishes nothing.++name: image++on:+ push:+ tags: ["v*"]+ workflow_dispatch:++permissions:+ contents: read+ packages: write++jobs:+ test:+ runs-on: ubuntu-latest+ steps:+ - uses: actions/checkout@v4+ - uses: astral-sh/setup-uv@v5+ - run: uv sync+ - run: uv run pytest -q+ - run: uv run pyrtan check examples++ publish:+ needs: test+ runs-on: ubuntu-latest+ steps:+ - uses: actions/checkout@v4+ - uses: docker/setup-buildx-action@v3+ - uses: docker/login-action@v3+ with:+ registry: ghcr.io+ username: ${{ github.actor }}+ password: ${{ secrets.GITHUB_TOKEN }}+ - id: meta+ uses: docker/metadata-action@v5+ with:+ images: ghcr.io/${{ github.repository }}+ tags: |+ type=semver,pattern={{version}}+ type=semver,pattern={{major}}.{{minor}}+ type=raw,value=latest+ - uses: docker/build-push-action@v6+ with:+ context: .+ push: true+ tags: ${{ steps.meta.outputs.tags }}+ labels: ${{ steps.meta.outputs.labels }}+ platforms: linux/amd64,linux/arm64diff --git a/README.md b/README.md index 0f2ef3d..90d238a 100644 --- a/README.md +++ b/README.md@@ -125,7 +125,7 @@ whole format is in [`_meta/tree-format.md`](_meta/tree-format.md).| `pyrtan match "…" --trees DIR` | How a sentence reads, without running anything: the rewrites, every trigger's verdict, the winner. The tool for writing triggers. | | `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. | | `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--federate` lets other hubs follow it and it follow them. |-| `pyrtan follow URL --at HUB` | Makes your hub follow another: it then routes sentences to that hub's trees too. `unfollow` undoes it; `peers` lists services, hubs followed and followers. |+| `pyrtan follow [URL] --at HUB` | Makes your hub follow another (default: the rendezvous at hub.pyrtan.com): it then routes sentences to that hub's trees too. `--listed` asks to be shown in its directory. `unfollow` undoes it; `peers` lists services, hubs followed and followers; `directory` lists hubs that asked to be found. || `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. | | `pyrtan gaps --at URL` | Every decision that met a value with no branch: the branches to write next. | | `pyrtan unanswered --at URL` | Every sentence no tree answered, with the tree that would: the trees to write next. `--scaffold DIR` writes them. |@@ -183,7 +183,12 @@ never what it learned from others, and refuses to forward a request thatalready crossed a hub, so there is nothing to loop. **Every message is signed**, and a hub started with `--federation-secret` takes follows only from hubs that know the secret; `--closed` takes none. `--state FILE`-keeps the relationships across restarts. The answer to a federated+keeps the relationships across restarts. The hub at+<https://hub.pyrtan.com> is the **rendezvous**: it holds the example+trees, anyone may follow it (`pyrtan follow` with no URL does), and+`pyrtan follow --listed` puts your hub in its directory (`pyrtan+directory`) so other operators can find and follow it. It routes to+nobody: finding is the directory's job, following is yours. The answer to a federatedsentence carries `via: [the hub it crossed]`, and the console's Peers tab shows who follows whom. The design is in [`_meta/federation.md`](_meta/federation.md). diff --git a/_meta/federation.md b/_meta/federation.md index 1278fc3..c860419 100644 --- a/_meta/federation.md +++ b/_meta/federation.md@@ -132,11 +132,12 @@ would buy interoperation that means anything: a Mastodon server cannotrun a tree. The shape is kept because it is the right shape for "who hears from whom"; the wire format is Pyrtan's own and says so.-**A directory of hubs.** A place where hubs register so others can find-them. Rejected for now: it is the catalog's job to make trees findable,-and a hub's job to answer for what it holds. An operator who wants to be-followed publishes their hub's URL; the catalog could carry "hubs that-hold this tree" later, as metadata, without the runtime changing.+**A directory of hubs that routes.** A place hubs register with, which+then answers for all of them. Rejected: it would route to hubs nobody+chose. What was built instead is the *rendezvous* (§7): a hub anyone may+follow, whose directory lists the followers that asked to be listed, and+which routes to none of them. Discovery is a directory's job; routing+stays an operator's follow.**Multi-hop routing.** Announcing transitive capabilities with a hop count, and forwarding with a TTL. It would let a sentence cross three@@ -171,7 +172,32 @@ follow. `--closed` takes no follows at all while still following others.restart forgets them and the other side's `link` goes `down` until the follow is redone.-## 6. What this design does not claim+## 7. The rendezvous++A rendezvous is an ordinary federated hub that is public on purpose. The+one at `https://hub.pyrtan.com` (`deploy/pyrtan.com/rendezvous/`) holds+the example trees, takes open follows, follows nobody, and keeps a+directory:++- **To try federation:** `pyrtan follow` with no URL follows it (the+ default is `$PYRTAN_RENDEZVOUS` or `https://hub.pyrtan.com`), and your+ hub answers "what's the weather in Oslo" through it.+- **To be found:** `pyrtan follow --listed` asks the rendezvous to show+ your hub at `GET /directory` (public), with the trees it holds. The+ rendezvous reads a listed follower's `/capabilities`, signed with the+ pair secret, on its poll; a follower that did not ask is never read.+ `pyrtan directory` prints the list; the console shows it under+ Directory.+- **What listing is not:** routing. The rendezvous forwards nothing to a+ listed hub, and a hub that finds another in the directory follows it+ with its own `pyrtan follow`, which is the one act of trust there is.++`listed` travels in the Follow activity and in the state file, so a+restart keeps the directory. Any federated hub can be a rendezvous for+its own circle: the directory is on every federated hub, empty until+someone asks.++## 8. What this design does not claimIt does not claim a follower cannot see what the followed hub holds; that is the point of following. It does not claim a hub behind a federation diff --git a/_meta/platform.md b/_meta/platform.md index 62c2c4f..1bf702c 100644 --- a/_meta/platform.md +++ b/_meta/platform.md@@ -117,7 +117,17 @@ a gap), Trees (capabilities, with a link to the catalog), Peers (hub only;A browser will refuse a `http://` hub from an `https://` page. The console says so and points at the hub's own `/`, which is the same console.-### 2.6 Hosting (designed)+### 2.6 The rendezvous (built)++pyrtan.com runs one public federated hub, `hub.pyrtan.com`, from the+same image as everything else (`deploy/pyrtan.com/rendezvous/`). It holds+the example trees so a new hub has something to federate with on day+one, and it keeps the opt-in directory from [`federation.md`](federation.md)+§7, which is how operators find each other. It follows nobody, so pyrtan.com+never routes a stranger's sentence to a stranger's hub. The site links+to it; the console on pyrtan.com can be pointed at it like at any hub.++### 2.7 Hosting (designed)A tenant gets a hub and one or more services as containers from this repository's image, with a token, `--cors https://pyrtan.com`, `--gaps` on diff --git a/deploy/pyrtan.com/README.md b/deploy/pyrtan.com/README.md index 0b5d291..9b8c83f 100644 --- a/deploy/pyrtan.com/README.md +++ b/deploy/pyrtan.com/README.md@@ -12,6 +12,7 @@ What to create, in order:| :-- | :-- | :-- | :-- | | 1 | `b-i-t-8/pyrtan-trees` | `pyrtan.com/catalog/` | its own `trees/` folder, checked and indexed by CI | | 2 | `b-i-t-8/pyrtan-site` | `pyrtan.com/` and `pyrtan.com/console/` | `index.html` here, and `pyrtan console --out` |+| 3 | a small box with Docker | `hub.pyrtan.com`, the rendezvous | [`rendezvous/`](rendezvous/) here |Both can be one repository if you prefer; two keeps "I wrote a tree" pull requests apart from "I changed the site" ones, and lets the catalog's@@ -140,7 +141,42 @@ a LAN over plain `http://` cannot be reached from an `https://` page (thebrowser blocks it); the console says so and points at the hub's own `/`, which is the same console served locally.-## 3. Later: hosting+## 3. The rendezvous (`hub.pyrtan.com`)++The one piece of pyrtan.com that runs: a public federated hub with the+example trees, that anyone may follow and that lists the hubs which ask+to be found (`_meta/federation.md` §7). Any small VPS does; it holds no+data but the follow list.++1. **DNS:** an `A` (and `AAAA`) record for `hub.pyrtan.com` to the box.+2. **On the box**, with Docker installed:++ ```+ mkdir -p /opt/rendezvous && cd /opt/rendezvous+ curl -fsSLO https://raw.githubusercontent.com/b-i-t-8/pyrtan/main/deploy/pyrtan.com/rendezvous/docker-compose.yml+ curl -fsSLO https://raw.githubusercontent.com/b-i-t-8/pyrtan/main/deploy/pyrtan.com/rendezvous/Caddyfile+ echo "PYRTAN_TOKEN=$(openssl rand -hex 24)" > .env+ docker compose up -d+ ```++ Caddy fetches the certificate; the hub comes up as+ `https://hub.pyrtan.com` with its one service of examples. The image+ is published by this repository's `image` workflow on every tag+ (`ghcr.io/b-i-t-8/pyrtan:0.3.0`); the first time, make the package+ public under the repository's *Packages* settings, or `docker login+ ghcr.io` on the box.+3. **Check:** `curl https://hub.pyrtan.com/actor` answers with+ `"type": "Hub"`; `pyrtan directory` lists nobody yet;+ `pyrtan ask "what's the weather in Oslo" --at https://hub.pyrtan.com+ --token …` answers.+4. **From any other hub:** `pyrtan follow --at https://your-hub --token …`+ follows it (the default URL is the rendezvous); add `--listed` to+ appear in `pyrtan directory`.++To update: edit the image tag in the compose file, `docker compose pull+&& docker compose up -d`. The follow list is in the `state` volume.++## 4. Later: hostingNothing here needs to change. A tenant's hub is this repository's image started with `--token`, `--cors https://pyrtan.com`, `--gaps` on a volume, diff --git a/deploy/pyrtan.com/index.html b/deploy/pyrtan.com/index.html index 3aac98b..6c85b52 100644 --- a/deploy/pyrtan.com/index.html +++ b/deploy/pyrtan.com/index.html@@ -35,6 +35,7 @@<div class="actions"> <a class="primary" href="/console/">Open the console</a> <a href="/catalog/">Browse the catalog</a>+ <a href="https://hub.pyrtan.com/#directory">The rendezvous</a><a href="https://github.com/b-i-t-8/pyrtan">Source on GitHub</a> </div>@@ -66,6 +67,12 @@ pyrtan serve trees --port 8701 # then open http://127<h2>The console</h2> <p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="/console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p>+ <h2>The rendezvous</h2>+ <p>A public hub at <code>hub.pyrtan.com</code> holds the example trees and takes follows from anyone. <code>pyrtan follow --at https://your-hub</code> makes your swarm answer through it; <code>--listed</code> puts your hub in its <a href="https://hub.pyrtan.com/#directory">directory</a> so others can find and follow yours. Following is one-way trust and one hop: the rendezvous routes to nobody, and nothing crosses more than one hub.</p>+<pre>pyrtan hub --federate --advertise https://your-hub --token … # your hub, behind TLS+pyrtan follow --at https://your-hub --token … --listed # follow the rendezvous, be listed+pyrtan directory # who else is there</pre>+<h2>Hosting</h2> <p>Self-hosting is the product. A hosted option, running the same open-source hub per customer with secrets, retention, scheduled re-runs and alerts around it, is planned; it will speak the same API, so a swarm can move either way without changing a caller.</p> diff --git a/deploy/pyrtan.com/rendezvous/Caddyfile b/deploy/pyrtan.com/rendezvous/Caddyfile new file mode 100644 index 0000000..0f0f945 --- /dev/null +++ b/deploy/pyrtan.com/rendezvous/Caddyfile@@ -0,0 +1,7 @@+# TLS from Let's Encrypt, everything to the hub. Caddy sets the usual+# proxy headers; the hub does not read them, it only needs --advertise to+# know its public name.+hub.pyrtan.com {+ reverse_proxy hub:8700+ encode gzip+}diff --git a/deploy/pyrtan.com/rendezvous/docker-compose.yml b/deploy/pyrtan.com/rendezvous/docker-compose.yml new file mode 100644 index 0000000..3d532f5 --- /dev/null +++ b/deploy/pyrtan.com/rendezvous/docker-compose.yml@@ -0,0 +1,75 @@+# The rendezvous: the public hub at hub.pyrtan.com that anyone may follow+# to try federation, and that lists the hubs which asked to be found.+#+# One box with Docker, DNS for hub.pyrtan.com pointing at it, ports 80+# and 443 open. Caddy gets the certificate; the hub and one service with+# the example trees sit behind it on the compose network. Put the token+# in .env (PYRTAN_TOKEN=...) and `docker compose up -d`.+#+# The hub's own paths (/follow, /peers, /outbox, /unanswered, /gaps) need+# the token; /actor, /inbox, /directory, /health and the console are+# public, which is what a rendezvous is for. It follows nobody, so it+# routes to nobody but its own service.++name: rendezvous++services:+ caddy:+ image: caddy:2+ restart: unless-stopped+ ports: ["80:80", "443:443"]+ volumes:+ - ./Caddyfile:/etc/caddy/Caddyfile:ro+ - caddy-data:/data+ - caddy-config:/config+ depends_on: [hub]++ hub:+ image: ghcr.io/b-i-t-8/pyrtan:0.3.0 # or build: ../../.. from a checkout+ restart: unless-stopped+ read_only: true+ security_opt: [no-new-privileges:true]+ cap_drop: [ALL]+ tmpfs: [/tmp]+ environment:+ PYRTAN_TOKEN: ${PYRTAN_TOKEN:?set PYRTAN_TOKEN in .env}+ PYRTAN_PORT: "8700"+ command:+ - hub+ - --host+ - 0.0.0.0+ - --port+ - "8700"+ - --name+ - rendezvous+ - --federate+ - --advertise+ - https://hub.pyrtan.com+ - --state+ - /state/federation.json+ - --cors+ - https://pyrtan.com+ - --poll+ - "15"+ volumes:+ - state:/state++ examples:+ image: ghcr.io/b-i-t-8/pyrtan:0.3.0+ restart: unless-stopped+ read_only: true+ security_opt: [no-new-privileges:true]+ cap_drop: [ALL]+ tmpfs: [/tmp]+ environment:+ PYRTAN_TOKEN: ${PYRTAN_TOKEN}+ PYRTAN_EGRESS: public # the example trees reach only public hosts+ command: ["serve", "/examples", "--host", "0.0.0.0", "--port", "8701", "--name", "examples", "--hub", "http://hub:8700", "--advertise", "http://examples:8701", "--gaps", "/state/gaps.jsonl", "--egress", "public"]+ volumes:+ - state:/state+ depends_on: [hub]++volumes:+ state:+ caddy-data:+ caddy-config:diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 84bfe69..cf0a47a 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -9,9 +9,10 @@pyrtan serve [PATH ...] [--port N] host trees as a service pyrtan hub [--peer URL ...] route between services pyrtan hub --federate --advertise URL [--follow URL ...] and between hubs- pyrtan follow URL --at HUB follow another hub (and route to it)+ pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: the rendezvouspyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following+ pyrtan directory [--at RENDEZVOUS] hubs that asked to be foundpyrtan gaps --at URL branches waiting to be written pyrtan unanswered --at URL [--scaffold DIR] trees waiting to be written pyrtan new [NAME] [--from TEXT] [--dir PATH] scaffold a tree file@@ -37,6 +38,7 @@ from typing import Anyfrom . import __version__ from .catalog import Catalog, CatalogError, build_index, catalog_url+from .federation import rendezvous_urlfrom .grow import Proposal, propose from .page import PAGE from .registry import Registry, default_paths@@ -113,13 +115,23 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--federation-secret", default=os.environ.get("PYRTAN_FEDERATION_SECRET"), help="only hubs that sign their Follow with this secret may follow (default $PYRTAN_FEDERATION_SECRET; without it, follows are open)") p.add_argument("--closed", action="store_true", help="take no follows at all (this hub can still follow others)") p.add_argument("--state", metavar="FILE", help="keep who this hub follows and is followed by in this file, across restarts")+ p.add_argument("--listed", action="store_true", help="with --follow: ask those hubs to show this one in their directory")p.add_argument("--quiet", action="store_true")- for verb in ("follow", "unfollow"):- p = sub.add_parser(verb, help=f"{verb} another hub, through a running federated hub of yours")- p.add_argument("url", help="the other hub's URL")- p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")- _token_arg(p, "your hub's token, if any")+ p = sub.add_parser("follow", help="follow another hub, through a running federated hub of yours")+ p.add_argument("url", nargs="?", default=None, help=f"the other hub's URL (default $PYRTAN_RENDEZVOUS or {rendezvous_url()})")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")+ p.add_argument("--listed", action="store_true", help="ask that hub to show yours in its directory, with the trees it holds")+ _token_arg(p, "your hub's token, if any")++ p = sub.add_parser("unfollow", help="stop following a hub")+ p.add_argument("url", help="the other hub's URL")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")+ _token_arg(p, "your hub's token, if any")++ p = sub.add_parser("directory", help="hubs that asked a rendezvous to list them")+ p.add_argument("--at", default=None, help=f"the rendezvous (default $PYRTAN_RENDEZVOUS or {rendezvous_url()})")+ p.add_argument("--json", action="store_true")p = sub.add_parser("peers", help="a hub's services, and the hubs it follows and is followed by") p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"))@@ -165,7 +177,7 @@ def main(argv: list[str] | None = None) -> int:commands = { "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new, "search": cmd_search, "pull": cmd_pull, "index": cmd_index, "console": cmd_console,- "follow": cmd_follow, "unfollow": cmd_unfollow, "peers": cmd_peers,+ "follow": cmd_follow, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory,} return commands[args.command](args)@@ -392,7 +404,7 @@ def cmd_hub(args: argparse.Namespace) -> int:hub, server = serve_hub( args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors, federate=args.federate or bool(args.advertise) or bool(args.follow), advertise=args.advertise, federation_secret=args.federation_secret,- state=args.state, open_follows=not args.closed, follow=args.follow,+ state=args.state, open_follows=not args.closed, follow=args.follow, listed=args.listed,) fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") if hub.federation else "" print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){fed}" + _access_note(args), flush=True)@@ -433,7 +445,33 @@ def cmd_gaps(args: argparse.Namespace) -> int:def cmd_follow(args: argparse.Namespace) -> int:- return _hub_post(args, "/follow", {"url": args.url}, lambda b: f"{args.at} now follows {b['following']['url']} ({b['following']['state']}); it routes there once accepted")+ url = args.url or rendezvous_url()+ return _hub_post(args, "/follow", {"url": url, "listed": bool(args.listed)}, lambda b: f"{args.at} now follows {b['following']['url']} ({b['following']['state']}); it routes there once accepted" + (", and asked to be listed" if args.listed else ""))+++def cmd_directory(args: argparse.Namespace) -> int:+ at = (args.at or rendezvous_url()).rstrip("/")+ try:+ status, body = http_json("GET", f"{at}/directory")+ except Exception as e:+ print(f"pyrtan: {at}: {e}", file=sys.stderr)+ return 3+ if status == 404:+ print(f"pyrtan: {at} is not a federated hub, so it has no directory", file=sys.stderr)+ return 3+ if status != 200 or not isinstance(body, dict):+ print(f"pyrtan: {at} answered {status}", file=sys.stderr)+ return 3+ if args.json:+ print(json.dumps(body, indent=2, ensure_ascii=False))+ return 0+ hubs = body.get("hubs", [])+ print(f"{body.get('name') or at} ({body.get('rendezvous', at)}): {len(hubs)} listed hub(s)")+ for h in hubs:+ print(f"{h['link']:8} {h['name'] or '-':16} {h['id']} {', '.join(h.get('trees', []))}")+ if hubs:+ print(f"to follow one: pyrtan follow URL --at YOUR-HUB; to be listed here: pyrtan follow {at} --at YOUR-HUB --listed")+ return 0def cmd_unfollow(args: argparse.Namespace) -> int: diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 6d3fd23..23338b8 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -37,6 +37,13 @@ and ``X-Pyrtan-Signature`` is HMAC-SHA256 over the method, path, date andthe SHA-256 of the canonical JSON body. The body sent *is* that canonical JSON, so the receiver can verify from the body it parsed.+A hub may also be a *rendezvous*: a public hub others follow to try+federation and, if they ask to be ``listed``, to be found. ``GET+/directory`` names the listed followers, with the trees they hold (the+rendezvous reads a listed follower's ``/capabilities``, signed, because+listing is asking to be seen). The directory is discovery only: a+rendezvous routes to nobody it did not follow, like any hub.+What this module refuses: open relaying (no hop beyond one), asymmetric secrets (there is no key infrastructure to lean on in the standard library, and a pair secret is enough for hubs whose operators chose each@@ -60,6 +67,7 @@ from typing import Any, Callablefrom . import __version__ from .service import bearer_ok, http_json+DEFAULT_RENDEZVOUS = "https://hub.pyrtan.com"SIGNATURE_WINDOW = 300.0 # seconds a signed message stays valid MAX_HOPS = 1 # federation is one hop; raise with care STALE_AFTER = 30.0@@ -68,6 +76,14 @@ ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce")SIGNED_PATHS = ("/inbox", "/run", "/intent", "/resume", "/capabilities")+def rendezvous_url() -> str:+ """The rendezvous `pyrtan follow` and `pyrtan directory` use when given+ no URL: ``$PYRTAN_RENDEZVOUS`` or the one at pyrtan.com."""+ import os++ return os.environ.get("PYRTAN_RENDEZVOUS") or DEFAULT_RENDEZVOUS++# ---------------------------------------------------------------- signing@@ -121,6 +137,7 @@ class Remote:last_error: str | None = None since: float = field(default_factory=time.time) kind: str = "hub"+ listed: bool = False # a follower that asked to appear in our directory@property def link(self) -> str:@@ -136,14 +153,14 @@ class Remote:return "down" def to_dict(self) -> dict[str, Any]:- return {"url": self.url, "name": self.name, "kind": "hub", "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error}+ return {"url": self.url, "name": self.name, "kind": "hub", "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error, "listed": self.listed}def to_state(self) -> dict[str, Any]:- return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since}+ return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since, "listed": self.listed}@classmethod def from_state(cls, d: dict[str, Any]) -> "Remote":- return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())))+ return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())), listed=bool(d.get("listed", False)))class Federation:@@ -211,9 +228,10 @@ class Federation:self.outbox.append({**activity, "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"}) return ok, None if ok else f"inbox answered {status}: {answer}"- def follow(self, url: str) -> Remote:+ def follow(self, url: str, listed: bool = False) -> Remote:"""Follow the hub at ``url``: fetch its actor, make a pair secret,- send the Follow. The remote is pending until it Accepts."""+ send the Follow. The remote is pending until it Accepts. ``listed``+ asks the hub to show this one in its directory."""url = url.rstrip("/") if url == self.id: raise ValueError("a hub cannot follow itself")@@ -233,7 +251,7 @@ class Federation:remote.name = actor.get("name", remote.name) remote.state = "pending" self._save()- activity = self._activity("Follow", url, secret=remote.secret, inbox=self.inbox, name=self.name)+ activity = self._activity("Follow", url, secret=remote.secret, inbox=self.inbox, name=self.name, listed=bool(listed))# Under a shared federation secret the Follow is signed with it; in # the open case with the pair secret, which proves nothing yet but # keeps every message signed.@@ -288,9 +306,38 @@ class Federation:else: remote.last_error = f"capabilities answered {status}"+ def refresh_listed(self, remote: Remote) -> None:+ """Read what a listed follower holds, for the directory. Signed with+ the pair secret: the follower accepts a request from the hub it+ follows. Nothing is routed there; this is what it asked to show."""+ if not remote.listed or remote.state != "accepted":+ return+ path = "/capabilities"+ try:+ status, caps = http_json("GET", f"{remote.url}{path}", timeout=5.0, headers=sign(remote.secret, self.id, "GET", path, None))+ except Exception as e:+ remote.last_error = str(e)+ return+ if status == 200 and isinstance(caps, dict):+ self._take_capabilities(remote, caps)+ else:+ remote.last_error = f"capabilities answered {status}"+def refresh_all(self) -> None: for remote in list(self.following.values()): self.refresh(remote)+ for remote in list(self.followers.values()):+ self.refresh_listed(remote)++ def directory(self) -> dict[str, Any]:+ """The listed followers: where to find hubs to follow."""+ with self._lock:+ hubs = [+ {"id": r.url, "name": r.name, "since": r.since, "link": r.link, "trees": [t.get("name") for t in r.trees if isinstance(t, dict) and t.get("name")]}+ for r in self.followers.values()+ if r.listed and r.state == "accepted"+ ]+ return {"rendezvous": self.id, "name": self.name, "hubs": sorted(hubs, key=lambda h: (h["link"] != "up", h["since"]))}def _take_capabilities(self, remote: Remote, caps: dict[str, Any]) -> None: with self._lock:@@ -388,7 +435,7 @@ class Federation:if status != 200 or not isinstance(doc, dict) or doc.get("id", "").rstrip("/") != actor or doc.get("inbox") != inbox: return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor does not describe this follower."} with self._lock:- remote = Remote(actor, inbox, pair, str(activity.get("name") or doc.get("name") or ""), state="accepted", last_ok=time.time())+ remote = Remote(actor, inbox, pair, str(activity.get("name") or doc.get("name") or ""), state="accepted", last_ok=time.time(), listed=activity.get("listed") is True)self.followers[actor] = remote self._save() self.deliver(remote, self._activity("Accept", {"type": "Follow", "actor": actor, "object": self.id})) diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 61d1c96..3a9042f 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -328,6 +328,8 @@ class Hub:if fed is not None: if method == "GET" and path == "/actor": return 200, fed.actor()+ if method == "GET" and path == "/directory":+ return 200, fed.directory()if method == "POST" and path == "/inbox": return fed.handle_inbox(body, headers) if method == "GET" and path in ("/following", "/followers"):@@ -339,7 +341,7 @@ class Hub:if not isinstance(url, str) or not url.startswith(("http://", "https://")): return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"https://hub.example\"}."} try:- remote = fed.follow(url)+ remote = fed.follow(url, listed=body.get("listed") is True)except ValueError as e: return 400, {"status": "error", "reason": "cannot-follow", "say": str(e)} return 200, {"status": "ok", "following": remote.to_dict()}@@ -359,7 +361,7 @@ class Hub:if method == "GET" and path == "/api": endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/register"] if fed is not None:- endpoints += ["/actor", "/inbox", "/follow", "/unfollow", "/following", "/followers", "/outbox"]+ endpoints += ["/actor", "/inbox", "/directory", "/follow", "/unfollow", "/following", "/followers", "/outbox"]return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "endpoints": endpoints} if method == "POST" and path == "/register": url = body.get("url")@@ -397,6 +399,7 @@ def serve_hub(state: str | None = None, open_follows: bool = True, follow: list[str] | None = None,+ listed: bool = False,) -> tuple[Hub, Server]: hub = Hub(name=name, poll_every=poll_every, token=token) server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).start()@@ -406,7 +409,7 @@ def serve_hub(hub.add_peer(url) for url in follow or []: try:- hub.federation.follow(url) # type: ignore[union-attr]+ hub.federation.follow(url, listed=listed) # type: ignore[union-attr]except ValueError as e: print(f"pyrtan hub: cannot follow {url}: {e}", flush=True) hub.start_polling() diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index e1c2cdd..ebbf33b 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -94,6 +94,7 @@ PAGE = r"""<!doctype html><a href="#peers" data-tab="peers" id="peers-tab" hidden>Peers</a> <a href="#gaps" data-tab="gaps">Gaps</a> <a href="#unanswered" data-tab="unanswered">Unanswered</a>+ <a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a><a href="#api" data-tab="api">API</a> </nav>@@ -138,6 +139,12 @@ PAGE = r"""<!doctype html><div class="card"><table id="unanswered"><thead><tr><th>Nobody answered</th><th>Would be</th><th>To write it</th></tr></thead><tbody></tbody></table></div> </section>+ <section id="tab-directory" hidden>+ <h2>Directory</h2>+ <p class="mute" style="font-size:14px;margin-top:0">Hubs that follow this one and asked to be found. This hub routes to none of them; each operator follows whom they choose: <code>pyrtan follow URL --at YOUR-HUB</code>. To be listed here: <code>pyrtan follow <span class="at">URL</span> --at YOUR-HUB --listed</code>.</p>+ <div class="card"><table id="directory"><thead><tr><th>Link</th><th>Hub</th><th>Holds</th><th>Since</th></tr></thead><tbody></tbody></table></div>+ </section>+<section id="tab-api" hidden> <h2>The same endpoints this page uses</h2> <div class="card">@@ -194,6 +201,18 @@ function showTab(name) {if (name === "peers") loadPeers(); if (name === "gaps") loadGaps(); if (name === "unanswered") loadUnanswered();+ if (name === "directory") loadDirectory();+}++async function loadDirectory() {+ let d; try { d = await json("directory"); } catch (e) { problem(e.message, true); return; }+ const body = $("#directory tbody"); body.innerHTML = "";+ for (const h of d.hubs || []) {+ const tr = document.createElement("tr");+ tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${h.since ? new Date(h.since * 1000).toLocaleDateString() : ""}</td>`;+ body.appendChild(tr);+ }+ if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody listed yet.</td></tr>`;} window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1)));@@ -243,6 +262,7 @@ async function load() {for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin; if (!a) { $("#who").textContent = remote ? "not connected" : "unreachable"; return; } $("#peers-tab").hidden = !isHub;+ $("#directory-tab").hidden = !(isHub && a.federation);$("#where").textContent = (base || location.origin) + (a.auth === "bearer" ? " · token" : ""); $("#hub-version").textContent = a.version ? ` · ${a.pyrtan} v${a.version}` : ""; try { diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index f26795e..6e7de4a 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py@@ -63,7 +63,7 @@ from .transport import Transport, UrllibTransportMAX_BODY = 1 << 20 # What answers without a token: the console, its index, and liveness.-PUBLIC_PATHS = frozenset({"/", "/index.html", "/console", "/api", "/health", "/actor"})+PUBLIC_PATHS = frozenset({"/", "/index.html", "/console", "/api", "/health", "/actor", "/directory"})# On a federated hub, a request carrying a signature reaches the route, # which verifies it (see :mod:`pyrtan.federation`). SIGNED_PATHS = frozenset({"/inbox", "/run", "/intent", "/resume", "/capabilities"}) diff --git a/tests/test_federation.py b/tests/test_federation.py index f36cef7..d5c0126 100644 --- a/tests/test_federation.py +++ b/tests/test_federation.py@@ -263,3 +263,55 @@ def test_signing_primitives():assert Remote("http://b", "http://b/inbox", "s", state="accepted").link == "pending" # accepted, not heard from yet assert Remote("http://b", "http://b/inbox", "s", state="accepted", last_error="x").link == "down" assert Remote("http://b", "http://b/inbox", "s", state="rejected").link == "down"+++def test_rendezvous_directory_lists_only_who_asked(two, tmp_path):+ a, b = two+ (tmp_path / "c").mkdir()+ (tmp_path / "c" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma"))+ c = Swarm("c", tmp_path / "c")+ try:+ # A is the rendezvous. B follows and asks to be listed; C follows quietly.+ b.post("/follow", {"url": a.url, "listed": True})+ c.post("/follow", {"url": a.url})+ assert a.fed.followers[b.url].listed and not a.fed.followers[c.url].listed+ status, d = http_json("GET", f"{a.url}/directory") # public+ assert status == 200 and d["rendezvous"] == a.url and [h["id"] for h in d["hubs"]] == [b.url]+ assert d["hubs"][0]["trees"] == [] # not read yet+ a.hub.refresh_all() # the rendezvous reads what a listed follower holds+ d = http_json("GET", f"{a.url}/directory")[1]+ assert d["hubs"][0]["trees"] == ["beta"] and d["hubs"][0]["link"] == "up"+ assert a.fed.followers[c.url].trees == [] # a quiet follower is not read+ # Listing grants no routing: the rendezvous still answers only for itself.+ assert a.post("/intent", {"text": "beta hello"})[1]["reason"] == "no-match"+ assert [t["name"] for t in a.get("/capabilities")[1]["trees"]] == ["alpha"]+ # Listed survives the state file.+ state = tmp_path / "state.json"+ a.fed.state_path = state+ a.fed._save()+ assert Federation(a.url, state=state).followers[b.url].listed is True+ # Unfollowing removes the listing.+ b.post("/unfollow", {"url": a.url})+ assert http_json("GET", f"{a.url}/directory")[1]["hubs"] == []+ assert "/directory" in http_json("GET", f"{a.url}/api")[1]["endpoints"]+ finally:+ c.stop()+++def test_cli_directory_and_follow_default(two, capsys, monkeypatch):+ a, b = two++ def run(*argv):+ code = main(list(argv))+ out = capsys.readouterr()+ return code, out.out, out.err++ monkeypatch.setenv("PYRTAN_RENDEZVOUS", a.url)+ code, out, _ = run("follow", "--at", b.url, "--token", "token-b", "--listed") # no URL: the rendezvous+ assert code == 0 and f"now follows {a.url}" in out and "asked to be listed" in out+ code, out, _ = run("directory")+ assert code == 0 and "1 listed hub(s)" in out and b.url in out and "--listed" in out+ code, out, _ = run("directory", "--json")+ assert json.loads(out)["hubs"][0]["id"] == b.url+ code, _, err = run("directory", "--at", b.svc_srv.url)+ assert code == 3 and "no directory" in err