diff --git a/CLAUDE.md b/CLAUDE.md index de13642..b14bcda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,6 +494,10 @@ tools/pasture/run.sh down # removes e - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. +- **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs + libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only + certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers + an inbox POST at once and verifies in a worker (`oban_jobs` shows what it refused and why). Town only, no scenario. - **Iceshrimp.NET (2026.1.2-beta):** on the shared Postgres with AuthorizedFetch on and open registrations, trusting Caddy's CA through `SSL_CERT_FILE`. Accounts come from its own `/api/iceshrimp/auth/register` (its login cuts the connection short for a name it does not know), Mastodon API tokens from its OAuth form, which prints the out-of-band diff --git a/docs/INTEROP.md b/docs/INTEROP.md index b6588a5..68b269f 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -417,6 +417,18 @@ Seen along the way: - It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`. - Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected. +**Town evidence (2026-10-05, Pleroma 2.10.2, `town.sh seed pleroma-pair`):** 241 cells pass between two personas and +three Pleroma accounts: delivery and confinement at every visibility both ways, likes, boosts, reactions and votes +counted, threads, edits and deletes, follows of locked accounts, and the privacy rows. Its first run found two things: +- **A Follow taken and then lost.** Pleroma answers an inbox POST 200 and checks the signature in a worker; while it + could not fetch our actor, it dropped our Follows there, and they stayed pending on our side for good. A pending + request is now sent again when the persona follows again (FEDERATION.md, Delivery). +- **Duplicates are refused, harmlessly.** A Create whose object Pleroma already holds (fetched for a reply, or + delivered to another inbox first) is cancelled with "The object to create already exists". + +Pleroma federates through hackney, which trusts certifi's compiled-in roots, never a file: the pasture's image names the +system bundle in its config. + ### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled` diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index c95a520..6e2c02c 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -52,3 +52,8 @@ iceshrimp.test { tls internal reverse_proxy pasture-iceshrimp:3000 } + +pleroma.test { + tls internal + reverse_proxy pasture-pleroma:4000 +} diff --git a/tools/pasture/images/pleroma/Containerfile b/tools/pasture/images/pleroma/Containerfile new file mode 100644 index 0000000..3bb7606 --- /dev/null +++ b/tools/pasture/images/pleroma/Containerfile @@ -0,0 +1,16 @@ +# Pleroma publishes no image, so this is its OTP release on the Ubuntu it is built for (24.04 for 2.10). The "stable" +# zip moves; the checksum pins 2.10.2 and fails the build when stable has moved on. Its image handling (vix) needs libvips. +FROM docker.io/library/ubuntu:24.04 +ARG PLEROMA_ZIP=https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-stable-amd64/latest/pleroma.zip +ARG PLEROMA_SHA256=5b6ab701685c1254145e33426d8775d4e159b5a17cc54a8c45d81ae0d9adc0b5 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl unzip libncurses6 libmagic1 libmagic-mgc file postgresql-client libimage-exiftool-perl ffmpeg imagemagick libvips42t64 \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL "$PLEROMA_ZIP" -o /tmp/pleroma.zip \ + && echo "$PLEROMA_SHA256 /tmp/pleroma.zip" | sha256sum -c - \ + && unzip -q /tmp/pleroma.zip -d /tmp && mv /tmp/release /opt/pleroma && rm /tmp/pleroma.zip \ + && mkdir -p /var/lib/pleroma/uploads /var/lib/pleroma/static /etc/pleroma +ENV PLEROMA_CONFIG_PATH=/etc/pleroma/config.exs LANG=C.UTF-8 ELIXIR_ERL_OPTIONS=+fnu +COPY entrypoint.sh /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tools/pasture/images/pleroma/entrypoint.sh b/tools/pasture/images/pleroma/entrypoint.sh new file mode 100755 index 0000000..f6e666f --- /dev/null +++ b/tools/pasture/images/pleroma/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Trusts the pasture's CA, writes the config on first start, migrates, and runs Pleroma in the foreground. +set -e +cp /pasture/ca/root.crt /usr/local/share/ca-certificates/pasture.crt && update-ca-certificates >/dev/null +# Mint and Gun read the bundles shipped in the release (CAStore, certifi), never the system's +for bundle in /opt/pleroma/lib/castore-*/priv/cacerts.pem /opt/pleroma/lib/certifi-*/priv/cacerts.pem; do + [ -f "$bundle" ] || continue + grep -q "pasture" "$bundle" || { echo "# pasture"; cat /pasture/ca/root.crt; } >> "$bundle" +done +if [ ! -f /etc/pleroma/config.exs ]; then + # pleroma_ctl passes its arguments on unquoted, so no value may contain a space + env -u PLEROMA_CONFIG_PATH /opt/pleroma/bin/pleroma_ctl instance gen --force --output /etc/pleroma/config.exs --output-psql /tmp/setup.psql \ + --domain pleroma.test --instance-name PasturePleroma --admin-email admin@pleroma.test --notify-email admin@pleroma.test \ + --dbhost postgres --dbname pleroma --dbuser pasture --dbpass pasture --rum N --indexable N --db-configurable N \ + --uploads-dir /var/lib/pleroma/uploads --static-dir /var/lib/pleroma/static --listen-ip 0.0.0.0 --listen-port 4000 \ + --strip-uploads-location N --read-uploads-description Y --anonymize-uploads N --dedupe-uploads N \ + > /etc/pleroma/config.exs <<'EOF' + +# pasture: system CA bundle +config :pleroma, :http, adapter: [ssl_options: [cacertfile: "/etc/ssl/certs/ca-certificates.crt"]] +EOF +/opt/pleroma/bin/pleroma_ctl migrate +exec /opt/pleroma/bin/pleroma start diff --git a/tools/pasture/peers/pleroma.sh b/tools/pasture/peers/pleroma.sh new file mode 100644 index 0000000..272abd7 --- /dev/null +++ b/tools/pasture/peers/pleroma.sh @@ -0,0 +1,19 @@ +# Pleroma 2.10: our own image (images/pleroma: the OTP release, pinned by checksum) on the shared Postgres. Akkoma's +# parent, with its inbox guard (400 to Move, QuoteRequest and Bite), InlineQuotePolicy's "RT:" rewrite of quotes, +# chats, and outgoing Block on by default. Users are made with pleroma_ctl and sign in with the password grant. +PLEROMA_IMAGE=${PLEROMA_IMAGE:-localhost/pasture-pleroma:2.10.2} +. "$here/peers/shared.sh" + +pleroma_up() { + shared_postgres_up + podman image exists "$PLEROMA_IMAGE" || podman build -q -t "$PLEROMA_IMAGE" "$here/images/pleroma" >/dev/null + pg_db pleroma citext pg_trgm uuid-ossp + mkdir -p "$here/.state/pleroma" + podman run -d --replace --name pasture-pleroma --network $net \ + -v "$here/.state/pleroma:/etc/pleroma:z" -v "$ca:/pasture/ca:z,ro" "$PLEROMA_IMAGE" >/dev/null + for _ in $(seq 1 150); do + site pleroma.test -s -o /dev/null -w '%{http_code}' https://pleroma.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break + sleep 3 + done + echo "pleroma: https://pleroma.test:6443" +} diff --git a/tools/pasture/town/dialects/__init__.py b/tools/pasture/town/dialects/__init__.py index e6cfab9..3f364f8 100644 --- a/tools/pasture/town/dialects/__init__.py +++ b/tools/pasture/town/dialects/__init__.py @@ -6,6 +6,7 @@ from dialects.iceshrimp import Iceshrimp from dialects.lemmy_api import LemmyApi from dialects.mastodon import Mastodon from dialects.misskey_api import Misskey, Sharkey +from dialects.pleroma import Pleroma from dialects.privapub import PrivaPub DRIVERS = { @@ -18,6 +19,7 @@ DRIVERS = { "lemmy": (LemmyApi, "lemmy.test"), "hollo": (Hollo, "hollo.test"), "iceshrimp": (Iceshrimp, "iceshrimp.test"), + "pleroma": (Pleroma, "pleroma.test"), } _made = {} diff --git a/tools/pasture/town/dialects/pleroma.py b/tools/pasture/town/dialects/pleroma.py new file mode 100644 index 0000000..52a2a5b --- /dev/null +++ b/tools/pasture/town/dialects/pleroma.py @@ -0,0 +1,11 @@ +"""Pleroma 2.10: Akkoma's parent, driven exactly as Akkoma (pleroma_ctl, the password grant, its Postgres) from its own +container and database. What differs on the wire (the inbox's 400 to Move, QuoteRequest and Bite; quotes rewritten to +"RT:" by InlineQuotePolicy) is the checker's business, not the driver's.""" +from dialects.akkoma import Akkoma + + +class Pleroma(Akkoma): + platform = "pleroma" + container = "pasture-pleroma" + ctl = "/opt/pleroma/bin/pleroma_ctl" + db = "pleroma" diff --git a/tools/pasture/town/dialects/privapub.py b/tools/pasture/town/dialects/privapub.py index 6cb6dd9..cfd8af2 100644 --- a/tools/pasture/town/dialects/privapub.py +++ b/tools/pasture/town/dialects/privapub.py @@ -69,11 +69,19 @@ class PrivaPub(MastodonApi): # -- groups: communities (FEP-1b12) and circles def group(self, s, username, name, community, policy="followers", approve=False): jwt = self.jwt(s.account.root, s.account.password) - r = self.http.post(self.base + "/clientapi/group/insert", headers={"Authorization": f"Bearer {jwt}"}, ok={200, 201}, + auth = {"Authorization": f"Bearer {jwt}"} + r = self.http.post(self.base + "/clientapi/group/insert", headers=auth, json={"avatarId": s.local_id, "userName": username, "name": name, "description": name, "isCommunity": community, "postingPolicy": policy, "isDiscoverable": community, "manuallyApprovesMembers": approve}) - return r.json() + if r.ok: + return r.json() + # a run on accounts an earlier run made finds the group it made + mine = self.http.get(self.base + "/clientapi/group/list", headers=auth, params={"avatarId": s.local_id}, ok={200}).json() or [] + found = next((g for g in mine if g.get("userName") == username), None) + if found is None: + raise RuntimeError(f"/clientapi/group/insert answered {r.status}: {r.text[:300]}") + return found # -- content def post(self, s, spec): diff --git a/tools/pasture/town/gen.py b/tools/pasture/town/gen.py index 24646e3..0f7d05f 100644 --- a/tools/pasture/town/gen.py +++ b/tools/pasture/town/gen.py @@ -20,10 +20,10 @@ from core.rng import Rng GENERATOR_VERSION = 1 HOSTS = {"privapub": "privapub.test", "gts": "gts.test", "mastodon": "mastodon.test", "misskey": "misskey.test", "sharkey": "sharkey.test", "akkoma": "akkoma.test", "lemmy": "lemmy.test", "hollo": "hollo.test", - "iceshrimp": "iceshrimp.test"} + "iceshrimp": "iceshrimp.test", "pleroma": "pleroma.test"} SHORT = {"privapub": "pp", "gts": "gt", "mastodon": "ms", "misskey": "mk", "sharkey": "sk", "akkoma": "ak", "lemmy": "lm", - "hollo": "ho", "iceshrimp": "is"} -MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp") + "hollo": "ho", "iceshrimp": "is", "pleroma": "pl"} +MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp", "pleroma") CAPS = { "privapub": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", "dm", "delete", "edit", "vote", "react", "quote", "profile", "circle", "community", "located"}, @@ -37,6 +37,8 @@ CAPS = { "report", "dm", "delete", "vote", "quote", "profile", "edit"}, "akkoma": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", "dm", "delete", "edit", "vote", "react", "quote", "profile"}, + "pleroma": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", + "dm", "delete", "edit", "vote", "react", "quote", "profile"}, "lemmy": {"thread", "comment", "upvote", "downvote", "dm", "delete", "community", "edit", "block"}, "hollo": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", "dm", "delete", "edit", "vote", "quote", "react", "profile"}, @@ -195,7 +197,7 @@ class Planner: # circles: members are local siblings' neighbours and remote followers of the owner for ref, grp in sorted(self.groups.items()): if grp["kind"] == "circle": - pool = sorted(k for k in micro if k != grp["owner"] and k.split("/")[0] in ("privapub", "gts", "mastodon", "akkoma", "hollo", "iceshrimp")) + pool = sorted(k for k in micro if k != grp["owner"] and k.split("/")[0] in ("privapub", "gts", "mastodon", "akkoma", "hollo", "iceshrimp", "pleroma")) for member in rng.sample(pool, min(len(pool), self.spec.get("circleMembers", 4))): grp["members"].add(member) self.step("graph", member, "join", {"group": ref}) diff --git a/tools/pasture/town/seed.py b/tools/pasture/town/seed.py index 2d8d0bd..e0e5a9a 100644 --- a/tools/pasture/town/seed.py +++ b/tools/pasture/town/seed.py @@ -97,7 +97,13 @@ class Seeder: for r in rounds: if r > 0: time.sleep(self.timing.get("roundSettleSeconds", 20)) - self.parallel([s for s in steps if s["round"] == r]) + # a post quoting or answering one of its own round goes once that one is made + these = [s for s in steps if s["round"] == r] + made_here = {s["ref"] for s in these} + later = [s for s in these if {s["args"].get("quote"), s["args"].get("reply_to")} & made_here] + self.parallel([s for s in these if s not in later]) + if later: + self.parallel(later) else: if phase in ("interact", "mutate"): time.sleep(self.timing.get("roundSettleSeconds", 20)) diff --git a/tools/pasture/town/specs/pleroma-pair.json b/tools/pasture/town/specs/pleroma-pair.json new file mode 100644 index 0000000..b446478 --- /dev/null +++ b/tools/pasture/town/specs/pleroma-pair.json @@ -0,0 +1,22 @@ +{ + "schema": "pasture-town/1", + "name": "pleroma-pair", + "seed": 20261007, + "peers": { + "privapub": {"roots": 1, "personas": [2, 2], "circles": 1, "communities": 0}, + "pleroma": {"accounts": 3} + }, + "profiles": {"locked": 0.2, "bot": 0.0, "fields": [0, 2], "avatar": 0.9, "header": 0.3, "bioWords": [5, 12], + "langs": {"en": 80, "it": 20}}, + "graph": {"follows": [2, 4], "mutual": 0.6, "pending": 0.0, "rejected": 0.0}, + "circleMembers": 2, + "content": { + "posts": 24, + "mix": {"text": 40, "cw": 10, "tags": 10, "mention": 10, "image": 10, "poll": 10, "quote": 10}, + "visibility": {"public": 50, "unlisted": 10, "followers": 20, "direct": 10, "circle": 10}, + "replyRounds": 2, "replies": 0.5, "deeperReplies": 0.4 + }, + "interactions": {"likes": [0, 3], "boosts": [0, 1], "react": 0.4, "vote": 0.8, "bookmark": 0.1}, + "mutations": {"edit": 0.15, "delete": 0.05, "blocks": 0, "mutes": 1, "reports": 0}, + "time": {"roundSettleSeconds": 15, "graphSettleSeconds": 20, "settleSeconds": 30, "deadlineSeconds": 120} +}