pyrtan / sourcev0.14.0 · main

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/arm64
diff --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 that
already 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 federated
sentence 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 cannot
run 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 claim
It 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 (the
browser 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: hosting
Nothing 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 rendezvous
pyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following
+ pyrtan directory [--at RENDEZVOUS] hubs that asked to be found
pyrtan 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 Any
from . import __version__ from .catalog import Catalog, CatalogError, build_index, catalog_url
+from .federation import rendezvous_url
from .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 0
def 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 and
the 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, Callable
from . 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, UrllibTransport
MAX_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