diff --git a/CLAUDE.md b/CLAUDE.md index b14bcda..f13cf9e 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. +- **PeerTube (8.3.1):** the official image on the shared Postgres and Redis (db 4), configured through `PEERTUBE_*` + variables, trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`; `peertube_settle` turns transcoding off (a test video + is served as uploaded) and keeps root's token in `.state/peertube.token`. It wants a bare `Host: peertube.test`. + `scenarios/peertube.sh`, 24 checks. - **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 diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 199bb54..d4bae29 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -635,6 +635,18 @@ attachment. The player is a card whose iframe loads from the remote host, which | Dislike counts; live state through `Update`; chapters and captions | P2 | own | | Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — | +**Pasture evidence (2026-10-05, PeerTube 8.3.1, `tools/pasture/scenarios/peertube.sh`):** 24 checks pass: +- a persona finds a channel (a group account) and its owner's account, and follows the channel (accepted at once); +- a new video comes as the channel's `Announce`, not a `Create`, so it reaches the persona's home as the channel's boost + of the video, with a playable attachment served through the media proxy, which answers byte ranges; +- a reply to the video is a comment on PeerTube, and an answer to that comment threads under the video here; +- a like and its undo count on PeerTube; the video's renaming (`Update`) and its deletion reach us; +- the unfollow, and statistics. + +PeerTube's own instance account announces each new video too, which we drop: nobody here follows it. Its users can +follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name, +without a port, before it gives out its OAuth client. + ### Loops (1.0.0-beta.14) and Pixelfed (0.14.4) - **Loops:** diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 6e2c02c..50baa18 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -57,3 +57,8 @@ pleroma.test { tls internal reverse_proxy pasture-pleroma:4000 } + +peertube.test { + tls internal + reverse_proxy pasture-peertube:9000 +} diff --git a/tools/pasture/peers/iceshrimp.sh b/tools/pasture/peers/iceshrimp.sh index ab0d754..373c614 100644 --- a/tools/pasture/peers/iceshrimp.sh +++ b/tools/pasture/peers/iceshrimp.sh @@ -1,6 +1,6 @@ # Iceshrimp.NET (2026.1): the strict one. AuthorizedFetch on (every fetch from it is signed, and it answers unsigned # reads 401), full JSON-LD expansion that drops undefined terms, several reactions per user. .NET, so it trusts Caddy's CA -# through SSL_CERT_FILE. On the shared Postgres (database iceshrimp); the town makes its users with its own CLI. +# through SSL_CERT_FILE. On the shared Postgres (database iceshrimp); the town makes its users through its own API. ICESHRIMP_IMAGE=${ICESHRIMP_IMAGE:-iceshrimp.dev/iceshrimp/iceshrimp.net:v2026.1.2-beta} . "$here/peers/shared.sh" diff --git a/tools/pasture/peers/peertube.sh b/tools/pasture/peers/peertube.sh new file mode 100644 index 0000000..668e4c8 --- /dev/null +++ b/tools/pasture/peers/peertube.sh @@ -0,0 +1,59 @@ +# PeerTube 8.3: videos as ActivityPub objects (Video with its files, HLS playlists, captions and storyboards), comments +# as Notes, likes and dislikes, accounts and channels as separate actors. On the shared Postgres (database peertube) and +# Redis (db 4). Node, so it trusts Caddy's CA through NODE_EXTRA_CA_CERTS; its SSRF guard lets the pasture's public-looking +# subnet through. Transcoding is turned off once it is up, so a test video is served as it was uploaded. Its admin is +# root, with the password below; the town makes its users through the admin API. +PEERTUBE_IMAGE=${PEERTUBE_IMAGE:-docker.io/chocobozzz/peertube:v8.3.1} +PEERTUBE_ROOT_PASSWORD=Peertube-Pasture-Root-1 +. "$here/peers/shared.sh" + +peertube_up() { + shared_postgres_up + shared_redis_up + pg_db peertube pg_trgm unaccent + mkdir -p "$here/.state/peertube/data" "$here/.state/peertube/config" + podman run -d --replace --name pasture-peertube --network $net \ + -e PEERTUBE_WEBSERVER_HOSTNAME=peertube.test -e PEERTUBE_WEBSERVER_PORT=443 -e PEERTUBE_WEBSERVER_HTTPS=true \ + -e PEERTUBE_TRUST_PROXY='["loopback", "linklocal", "uniquelocal", "'"$subnet"'"]' \ + -e PEERTUBE_DB_HOSTNAME=postgres -e PEERTUBE_DB_NAME=peertube -e PEERTUBE_DB_SUFFIX= \ + -e PEERTUBE_DB_USERNAME=pasture -e PEERTUBE_DB_PASSWORD=pasture \ + -e PEERTUBE_REDIS_HOSTNAME=redis -e PEERTUBE_REDIS_DB=4 \ + -e PEERTUBE_SECRET=pasture-peertube-secret-0123456789abcdef -e PEERTUBE_ADMIN_EMAIL=admin@peertube.test \ + -e PT_INITIAL_ROOT_PASSWORD=$PEERTUBE_ROOT_PASSWORD \ + -e PEERTUBE_RATES_LIMIT_API_MAX=100000 -e PEERTUBE_RATES_LIMIT_LOGIN_MAX=100000 -e PEERTUBE_RATES_LIMIT_ACTIVITY_PUB_MAX=100000 \ + -e PEERTUBE_SIGNUP_ENABLED=true \ + -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt \ + -v "$here/.state/peertube/data:/data:Z" -v "$here/.state/peertube/config:/config:Z" -v "$ca:/pasture/ca:z,ro" \ + "$PEERTUBE_IMAGE" >/dev/null + for _ in $(seq 1 150); do + site peertube.test -s -o /dev/null -w '%{http_code}' https://peertube.test:6443/api/v1/config 2>/dev/null | grep -q 200 && break + sleep 3 + done + peertube_settle + echo "peertube: https://peertube.test:6443" +} + +# root's token (the password grant with the instance's own client); transcoding off, signups open, any import allowed +peertube_settle() { + local client token + # PeerTube gives its client only to its own host, named without the port + client=$(site peertube.test -s -H "Host: peertube.test" https://peertube.test:6443/api/v1/oauth-clients/local) + token=$(site peertube.test -s -X POST https://peertube.test:6443/api/v1/users/token \ + --data-urlencode "client_id=$(echo "$client" | python3 -c 'import sys,json; print(json.load(sys.stdin)["client_id"])')" \ + --data-urlencode "client_secret=$(echo "$client" | python3 -c 'import sys,json; print(json.load(sys.stdin)["client_secret"])')" \ + --data-urlencode grant_type=password --data-urlencode username=root --data-urlencode "password=$PEERTUBE_ROOT_PASSWORD" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])') + echo "$token" > "$here/.state/peertube.token" + site peertube.test -s https://peertube.test:6443/api/v1/config/custom -H "Authorization: Bearer $token" | python3 -c ' +import sys, json +c = json.load(sys.stdin) +c["transcoding"]["enabled"] = False +c["signup"]["enabled"] = True +c["signup"]["requiresEmailVerification"] = False +c["signup"]["limit"] = -1 +c["user"]["videoQuota"] = -1 +c["user"]["videoQuotaDaily"] = -1 +print(json.dumps(c))' > "$here/.state/peertube/custom.json" + site peertube.test -s -o /dev/null -w '%{http_code}\n' -X PUT https://peertube.test:6443/api/v1/config/custom -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' --data @"$here/.state/peertube/custom.json" +} diff --git a/tools/pasture/scenarios/peertube.sh b/tools/pasture/scenarios/peertube.sh new file mode 100644 index 0000000..ed89773 --- /dev/null +++ b/tools/pasture/scenarios/peertube.sh @@ -0,0 +1,94 @@ +# PeerTube 8.3: a persona follows a channel; the channel's video arrives as a playable post, its file streamed through +# PrivaPub's media proxy with byte ranges; comments both ways thread; a like counts and its undo too; the video's edit +# and its deletion reach PrivaPub; statistics. PeerTube's users follow channels and accounts of PeerTube-like servers +# only, so nothing here has PeerTube following a persona. +PTB=https://peertube.test:6443 +# PeerTube checks the Host against its own name, without the port +pcurl() { curl -sk --resolve peertube.test:6443:127.0.0.1 -H "Host: peertube.test" "$@"; } +PTROOT=$(cat "$here/.state/peertube.token" 2>/dev/null) +PTPASS=Peertube-Pasture-User-1 +# a user's token through the instance's own OAuth client +pt_token() { + local client + client=$(pcurl "$PTB/api/v1/oauth-clients/local") + pcurl -X POST "$PTB/api/v1/users/token" \ + --data-urlencode "client_id=$(echo "$client" | j "print(d['client_id'])")" \ + --data-urlencode "client_secret=$(echo "$client" | j "print(d['client_secret'])")" \ + --data-urlencode grant_type=password --data-urlencode "username=$1" --data-urlencode "password=$2" | j "print(d['access_token'])" +} +# a PrivaPub status in alice_peertube's home whose text or title has the given words; a channel shares its videos by +# boosting them, so it is the boosted status +p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((o['id'] for o in ((s.get('reblog') or s) for s in d) if '$1' in (o['content'] or '') or '$1' in ((o.get('privapub') or {}).get('title') or '')), ''))"; } + +echo "peertube" +[ -n "$PTROOT" ] && ok "PeerTube root token" || { ko "PeerTube root token"; return 1; } +pcurl -o /dev/null -X POST -H "Authorization: Bearer $PTROOT" -H 'Content-Type: application/json' "$PTB/api/v1/users" \ + -d "{\"username\":\"ptuser\",\"password\":\"$PTPASS\",\"email\":\"ptuser@peertube.test\",\"role\":2,\"videoQuota\":-1,\"videoQuotaDaily\":-1}" +TT=$(pt_token ptuser "$PTPASS") +TH="Authorization: Bearer $TT" +[ -n "$TT" ] && ok "PeerTube token for ptuser" || { ko "PeerTube token for ptuser"; return 1; } +channel=$(pcurl -H "$TH" "$PTB/api/v1/users/me" | j "print(d['videoChannels'][0]['name'])") +channel_id=$(pcurl -H "$TH" "$PTB/api/v1/users/me" | j "print(d['videoChannels'][0]['id'])") +PT=$(privapub_token alice_peertube) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_peertube" || { ko "PrivaPub token for alice_peertube"; return 1; } + +echo " discovery and follows" +channel_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$channel@peertube.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$channel_on_p" ] && ok "PrivaPub resolves the channel @$channel@peertube.test" || ko "PrivaPub cannot resolve the channel" +[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$channel_on_p" | j "print(d['group'])")" = "True" ] && ok "the channel is a group account" || ko "the channel is not shown as a group" +account_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=ptuser@peertube.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$account_on_p" ] && ok "PrivaPub resolves the account @ptuser@peertube.test" || ko "PrivaPub cannot resolve the account" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/follow" +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$channel_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice_peertube follows the channel (Accept arrived)" || ko "the channel's Accept never arrived" +until_true 30 '[ "$(pcurl -H "$TH" "$PTB/api/v1/video-channels/$channel/followers" | j "print(d[\"total\"])")" -ge 1 ]' \ + && ok "PeerTube lists alice_peertube among the channel's followers" || ko "PeerTube does not list the follower" + +echo " videos" +video=$(pcurl -X POST -H "$TH" "$PTB/api/v1/videos/upload" -F "videofile=@$here/town/media/tiny.mp4;type=video/mp4" \ + -F "channelId=$channel_id" -F "name=Hello PrivaPub from PeerTube" -F "description=a tiny test video" -F privacy=1 -F commentsPolicy=1 -F waitTranscoding=false) +video_id=$(echo "$video" | j "print(d['video']['shortUUID'])") +[ -n "$video_id" ] && ok "ptuser uploads a video" || ko "upload refused: $(echo "$video" | head -c 200)" +until_true 60 '[ -n "$(p_home_id "Hello PrivaPub from PeerTube")" ]' && ok "the video reaches alice_peertube's home, boosted by its channel" || ko "the video never arrived" +v_on_p=$(p_home_id "Hello PrivaPub from PeerTube") +v_status=$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p") +[ "$(echo "$v_status" | j "print(d['media_attachments'][0]['type'])")" = "video" ] && ok "the video is a playable attachment" || ko "no video attachment: $(echo "$v_status" | j "print(d['media_attachments'])" | head -c 200)" +v_file=$(echo "$v_status" | j "print(d['media_attachments'][0]['url'])") +case "$v_file" in + *privapub.test/*) ok "its file is served through PrivaPub's proxy" ;; + *) ko "its file is not proxied: $v_file" ;; +esac +range=$(pfetch -s -o /dev/null -w '%{http_code} %{size_download}' -H 'Range: bytes=0-99' "$v_file") +[ "$range" = "206 100" ] && ok "the proxy answers a byte range (206, 100 bytes)" || ko "the proxy did not answer the range: $range" + +echo " comments" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@ptuser@peertube.test lovely video&in_reply_to_id=$v_on_p&visibility=public" +until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id/comment-threads" | j "print(any(\"lovely video\" in c[\"text\"] for c in d[\"data\"]))")" = "True" ]' \ + && ok "alice_peertube's reply is a comment on the video" || ko "the reply never became a comment" +thread=$(pcurl "$PTB/api/v1/videos/$video_id/comment-threads" | j "print(next(c['id'] for c in d['data'] if 'lovely video' in c['text']))") +pcurl -o /dev/null -X POST -H "$TH" -H 'Content-Type: application/json' "$PTB/api/v1/videos/$video_id/comments/$thread" -d '{"text":"thank you from PeerTube"}' +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p/context" | j "print(any(\"thank you from PeerTube\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "ptuser's answer threads under the video on PrivaPub" || ko "PeerTube's answer missing from the thread" + +echo " likes" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$v_on_p/favourite" +until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id" | j "print(d[\"likes\"])")" = "1" ]' && ok "alice_peertube's like counts on PeerTube" || ko "like not counted on PeerTube" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$v_on_p/unfavourite" +until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id" | j "print(d[\"likes\"])")" = "0" ]' && ok "alice_peertube's unlike reaches PeerTube" || ko "unlike not applied on PeerTube" + +echo " edits and deletes" +pcurl -o /dev/null -X PUT -H "$TH" -H 'Content-Type: application/json' "$PTB/api/v1/videos/$video_id" -d '{"name":"Hello again from PeerTube","description":"renamed"}' +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p" | j "print(\"Hello again from PeerTube\" in json.dumps(d))")" = "True" ]' \ + && ok "the video's new name reaches PrivaPub" || ko "the edit never arrived" +pcurl -o /dev/null -X DELETE -H "$TH" "$PTB/api/v1/videos/$video_id" +until_true 30 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$v_on_p")" = "404" ]' \ + && ok "the video's deletion reaches PrivaPub" || ko "the deleted video is still on PrivaPub" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/unfollow" +until_true 30 '[ "$(pcurl -H "$TH" "$PTB/api/v1/video-channels/$channel/followers" | j "print(d[\"total\"])")" = "0" ]' \ + && ok "the unfollow reaches PeerTube" || ko "PeerTube still lists alice_peertube" + +echo " statistics" +stats_check peertube.test peertube diff --git a/tools/pasture/town/dialects/lemmy_api.py b/tools/pasture/town/dialects/lemmy_api.py index 3ef7fde..58c1754 100644 --- a/tools/pasture/town/dialects/lemmy_api.py +++ b/tools/pasture/town/dialects/lemmy_api.py @@ -77,7 +77,13 @@ class LemmyApi(Driver): # -- communities and people def community(self, s, name, title): - return self.lm(s, "POST", "community", {"name": name, "title": title})["community_view"]["community"] + try: + return self.lm(s, "POST", "community", {"name": name, "title": title})["community_view"]["community"] + except HttpError as e: + # a run on accounts an earlier run made finds the community it made + if "already_exists" not in str(e): + raise + return self.lm(s, "GET", "community", params={"name": name})["community_view"]["community"] def resolve_community(self, s, ref): """ref: !name@host""" diff --git a/tools/pasture/town/seed.py b/tools/pasture/town/seed.py index e0e5a9a..a22e34f 100644 --- a/tools/pasture/town/seed.py +++ b/tools/pasture/town/seed.py @@ -261,6 +261,11 @@ class Seeder: member=member, state="local") return member_uri = self.session(member).actor_uri + # a member an earlier run on these accounts approved asks nothing again + if podman.mongo(f"db.Follower.findOne({{LocalActorId: '{group['id']}', ActorURI: '{member_uri}', IsAccepted: true}}, {{_id: 1}})"): + self.ledger.add(type="relation", n=s["n"], verb="approve", actor=s["actor"], group=s["args"]["group"], + member=member, state="accepted", already=True) + return for _ in range(30): found = podman.mongo(f"db.Follower.findOne({{LocalActorId: '{group['id']}', IsAccepted: false}}, {{ActorURI: 1}})") pending = podman.mongo(f"db.Follower.find({{LocalActorId: '{group['id']}', IsAccepted: false}}, {{ActorURI: 1}}).toArray()") or []