Pasture: Lemmy 0.19 joins, built to trust the pasture's CA

Most of the threadiverse runs Lemmy 0.19, whose release trusts only the
roots its rustls bundles. images/lemmy19 builds 0.19.20 from its tag with
reqwest's rustls-tls-native-roots added, and the pasture runs it as
lemmy19.test. scenarios/lemmy19.sh passes 27 checks with no change to
PrivaPub (communities, threads, comments, votes, its private message,
moderation) and one known gap: 0.19 takes private messages only as
ChatMessage and answers our direct Note 400, like Mbin, so G-0008 now
covers both and waits for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 17:32:22 +02:00
1 parent bd5901d15e
commit a85ebd30c0
9 files changed
+197 -9

No files matched your search

+5
View File
@@ -107,3 +107,8 @@ nodebb.test {
tls internal
reverse_proxy pasture-nodebb:4567
}
lemmy19.test {
tls internal
reverse_proxy pasture-lemmy19:8536
}
@@ -0,0 +1,13 @@
# Lemmy 0.19.20, built from its tag with one change: reqwest's rustls also trusts the platform's roots
# (rustls-tls-native-roots), where the pasture's CA is, besides the roots it bundles. The release image trusts only
# those, so 0.19 could not reach any pasture peer.
FROM docker.io/library/rust:1.81-bookworm AS build
RUN git clone --depth 1 --branch 0.19.20 --recurse-submodules https://github.com/LemmyNet/lemmy.git /lemmy
WORKDIR /lemmy
RUN sed -i 's/^ "rustls-tls",$/ "rustls-tls",\n "rustls-tls-native-roots",/' Cargo.toml && grep -q '"rustls-tls-native-roots"' Cargo.toml
RUN cargo build --release && strip target/release/lemmy_server
FROM docker.io/library/debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /lemmy/target/release/lemmy_server /usr/local/bin/lemmy_server
ENTRYPOINT ["/usr/local/bin/lemmy_server"]
+44
View File
@@ -0,0 +1,44 @@
# Lemmy 0.19.20, the version most of the threadiverse runs, as lemmy19.test: the backend alone on the shared Postgres,
# built by images/lemmy19 from its tag with reqwest also trusting the platform's roots (the release trusts only the ones
# it bundles), and given the pasture's bundle as the platform's. Its API is v3.
LEMMY19_IMAGE=${LEMMY19_IMAGE:-localhost/pasture-lemmy19:0.19.20}
. "$here/peers/shared.sh"
lemmy19_up() {
podman image exists "$LEMMY19_IMAGE" || podman build -q -t "$LEMMY19_IMAGE" "$here/images/lemmy19" >/dev/null
shared_postgres_up
pg_db lemmy19
mkdir -p "$here/.state/lemmy19"
cat > "$here/.state/lemmy19/config.hjson" <<HJSON
{
database: { uri: "postgresql://pasture:pasture@postgres:5432/lemmy19" }
hostname: "lemmy19.test"
bind: "0.0.0.0"
port: 8536
tls_enabled: true
setup: {
admin_username: "lemmyuser"
admin_password: "Lemmy-Pasture-Pass-1"
site_name: "Pasture Lemmy 0.19"
admin_email: "lemmyuser@lemmy19.test"
}
}
HJSON
podman run -d --replace --name pasture-lemmy19 --network $net --label pasture=1 -e LEMMY_CONFIG_LOCATION=/config/config.hjson \
-e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -e RUST_LOG="${LEMMY_LOG:-warn}" -v "$here/.state/lemmy19:/config:z,ro" \
-v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" "$LEMMY19_IMAGE" >/dev/null
for _ in $(seq 1 90); do
site lemmy19.test -s -o /dev/null -w '%{http_code}' https://lemmy19.test:6443/api/v3/site 2>/dev/null | grep -q 200 && break
sleep 2
done
# Lemmy's default limits would throttle a scripted run; it reads them again when its site is edited
podman exec pasture-postgres psql -U pasture -d lemmy19 -qc "update local_site_rate_limit set message=100000, message_per_second=1,
post=100000, post_per_second=1, register=100000, register_per_second=1, image=100000, image_per_second=1,
comment=100000, comment_per_second=1, search=100000, search_per_second=1" >/dev/null
site lemmy19.test -s -X POST https://lemmy19.test:6443/api/v3/user/login -H 'Content-Type: application/json' \
-d '{"username_or_email":"lemmyuser","password":"Lemmy-Pasture-Pass-1"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])" > "$here/.state/lemmy19.token" 2>/dev/null || true
site lemmy19.test -s -o /dev/null -X PUT https://lemmy19.test:6443/api/v3/site -H "Authorization: Bearer $(cat "$here/.state/lemmy19.token")" \
-H 'Content-Type: application/json' -d '{"registration_mode":"Open","require_email_verification":false,"captcha_enabled":false}'
echo "lemmy19: https://lemmy19.test:6443"
}
+113
View File
@@ -0,0 +1,113 @@
# Lemmy 0.19.20, what most of the threadiverse runs: communities both ways (FEP-1b12), threads with titles, comments both
# ways, votes up and down both ways, private messages both ways, a moderator's lock, ban and removal, statistics. Driven
# through its v3 API; scenarios/lemmy.sh does the same against Lemmy 1.0.
LM=https://lemmy19.test:6443
LT=$(cat "$here/.state/lemmy19.token" 2>/dev/null)
# lm <method> <path> [json]: a Lemmy v3 API call as lemmyuser
lm() {
local method=$1 path=$2 body=${3:-}
if [ -n "$body" ]; then
site lemmy19.test -s -X "$method" "$LM/api/v3/$path" -H "Authorization: Bearer $LT" -H 'Content-Type: application/json' -d "$body"
else
site lemmy19.test -s -X "$method" "$LM/api/v3/$path" -H "Authorization: Bearer $LT"
fi
}
lm_posts() { lm GET "post/list?community_id=$1&sort=New&limit=50" | j "print(json.dumps([p['post'] for p in d.get('posts', [])]))"; }
lm_post_by_ap() { lm_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; }
lm_worker() { podman exec pasture-postgres psql -U pasture -d lemmy19 -tAc "select count(*) from federation_queue_state q join instance i on i.id=q.instance_id where i.domain='privapub.test'"; }
echo "lemmy19"
[ -n "$LT" ] && ok "Lemmy 0.19 token for lemmyuser" || { ko "Lemmy 0.19 token"; return 1; }
LAT=$(privapub_token alice_lemmy19)
LAH="Authorization: Bearer $LAT"
[ -n "$LAT" ] && ok "PrivaPub token for alice_lemmy19" || { ko "PrivaPub token for alice_lemmy19"; return 1; }
jwt=$(privapub_root)
alice_id=$(curl -s -H "$LAH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
run=$(date +%s)
dogs="ldogs$run"
cats="lcats$run"
echo " communities"
dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
dogs_on_lm=$(lm GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['community']['id'])")
[ -n "$dogs_on_lm" ] && ok "Lemmy 0.19 resolves a PrivaPub community" || ko "Lemmy 0.19 cannot resolve the PrivaPub community"
# (a newly seen server gets its send worker within a minute; what was queued before it starts is never sent)
until_true 45 '[ "$(lm_worker)" = "1" ]' || true
lm POST community/follow "{\"community_id\":$dogs_on_lm,\"follow\":true}" >/dev/null
until_true 45 '[ "$(lm GET "community?id=$dogs_on_lm" | j "print(d[\"community_view\"][\"subscribed\"])")" = "Subscribed" ]' \
&& ok "lemmyuser follows the PrivaPub community (Accept arrived)" || ko "Lemmy 0.19's community follow not accepted"
cats_on_lm=$(lm POST community "{\"name\":\"$cats\",\"title\":\"Pasture cats\"}" | j "print(d['community_view']['community']['id'])")
cats_on_p=$(curl -s -H "$LAH" "$P/api/v2/search?q=@$cats@lemmy19.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$cats_on_p" ] && ok "PrivaPub resolves a Lemmy 0.19 community" || ko "PrivaPub cannot resolve the Lemmy 0.19 community"
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/accounts/$cats_on_p/follow"
until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows the Lemmy 0.19 community (Accept arrived)" || ko "PrivaPub's community follow not accepted"
echo " threads"
lm_thread=$(lm POST post "{\"name\":\"A Lemmy 0.19 thread\",\"body\":\"posted from Lemmy 0.19 into PrivaPub\",\"community_id\":$dogs_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
dogs_on_p=$(curl -s -H "$LAH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])")
until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any(s[\"uri\"]==\"$lm_thread\" and (s.get(\"privapub\") or {}).get(\"title\")==\"A Lemmy 0.19 thread\" for s in d))")" = "True" ]' \
&& ok "a Lemmy 0.19 thread in the PrivaPub community arrives with its title" || ko "Lemmy 0.19's thread missing or untitled on PrivaPub"
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread $run\",\"text\":\"posted into our own community for Lemmy 0.19\",\"groupId\":\"$dogs_gid\"}"
dogs_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into our own community for Lemmy 0.19/}, {}, {sort:{_id:-1}}).ObjectURI)')
until_true 45 '[ "$(lm_post_by_ap "$dogs_on_lm" "$dogs_post" | j "print(d and d[\"name\"])")" = "A PrivaPub community thread $run" ]' \
&& ok "a titled post in the PrivaPub community reaches Lemmy 0.19 through its announce" || ko "PrivaPub community post missing on Lemmy 0.19"
lm_cats_thread=$(lm POST post "{\"name\":\"Cats only $run\",\"body\":\"a Lemmy 0.19 community post\",\"community_id\":$cats_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
p_home_has() { curl -s -H "$LAH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if (s.get('reblog') or s)['uri']=='$1'), ''))"; }
until_true 45 '[ -n "$(p_home_has "$lm_cats_thread")" ]' && ok "the Lemmy 0.19 community's announce brings its thread to alice's home" || ko "Lemmy 0.19 community thread missing from alice's home"
lm_cats_on_p=$(p_home_has "$lm_cats_thread")
p_thread=$(curl -s -X POST -H "$LAH" $P/api/v1/statuses --data-urlencode "status=A PrivaPub thread
@$cats@lemmy19.test posted from PrivaPub" -d 'visibility=public')
p_thread_id=$(echo "$p_thread" | j "print(d['id'])"); p_thread_uri=$(echo "$p_thread" | j "print(d['uri'])")
until_true 45 '[ "$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri")" != "null" ]' && ok "alice's thread lands in the Lemmy 0.19 community" || ko "PrivaPub thread missing from the Lemmy 0.19 community"
p_thread_on_lm=$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri" | j "print(d['id'])")
echo " comments"
lm POST comment "{\"post_id\":$p_thread_on_lm,\"content\":\"a Lemmy 0.19 comment on PrivaPub\"}" >/dev/null
until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id/context" | j "print(any(\"a Lemmy 0.19 comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "Lemmy 0.19's comment threads under alice's post" || ko "Lemmy 0.19's comment missing on PrivaPub"
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=a PrivaPub comment on Lemmy 0.19&in_reply_to_id=$lm_cats_on_p&visibility=public"
lm_cats_id=$(lm GET "resolve_object?q=$lm_cats_thread" | j "print(d['post']['post']['id'])")
until_true 45 '[ "$(lm GET "comment/list?post_id=$lm_cats_id" | j "print(any(\"a PrivaPub comment on Lemmy 0.19\" in c[\"comment\"][\"content\"] for c in d.get(\"comments\", [])))")" = "True" ]' \
&& ok "alice's reply becomes a comment on Lemmy 0.19" || ko "PrivaPub reply missing as a Lemmy 0.19 comment"
echo " votes"
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"score\":1}" >/dev/null
until_true 60 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \
&& ok "Lemmy 0.19's upvote counts as a like on PrivaPub" || ko "Lemmy 0.19's relayed upvote not counted"
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"score\":-1}" >/dev/null
until_true 60 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]' \
&& ok "Lemmy 0.19's change to a downvote reaches PrivaPub" || ko "Lemmy 0.19's change to a downvote not applied"
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/favourite"
until_true 45 '[ "$(lm GET "post?id=$lm_cats_id" | j "print(d[\"post_view\"][\"counts\"][\"upvotes\"])")" = "2" ]' && ok "alice's like is an upvote on Lemmy 0.19" || ko "like not counted as an upvote on Lemmy 0.19"
echo " private messages"
alice_on_lm=$(lm GET "resolve_object?q=@alice_lemmy19@privapub.test" | j "print(d['person']['person']['id'])")
lm POST private_message "{\"content\":\"a secret from Lemmy 0.19\",\"recipient_id\":$alice_on_lm}" >/dev/null
until_true 45 'curl -s -H "$LAH" "$P/api/v1/conversations" | grep -q "a secret from Lemmy 0.19"' && ok "Lemmy 0.19's private message arrives as a DM" || ko "Lemmy 0.19's private message missing on PrivaPub"
# (0.19 takes a private message only as a ChatMessage and answers alice's direct Note 400, G-0008; 1.0 takes a Note)
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d 'status=@lemmyuser@lemmy19.test a secret from PrivaPub&visibility=direct'
until_true 30 'lm GET "private_message/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a Lemmy 0.19 private message" \
|| xf "alice's DM never reaches Lemmy 0.19, which takes private messages only as ChatMessage (G-0008)"
echo " moderation"
p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
p_reply() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "$LAH" $P/api/v1/statuses -d "status=$1&in_reply_to_id=$lm_cats_on_p&visibility=public"; }
p_banned() { curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0]['blocked_by'])"; }
lm POST post/lock "{\"post_id\":$lm_cats_id,\"locked\":true}" >/dev/null
until_true 45 '[ "$(p_locked)" = "True" ]' && ok "a moderator's lock reaches PrivaPub" || ko "a moderator's lock did not reach PrivaPub"
[ "$(p_reply "too late")" = "422" ] && ok "a reply to the locked thread is refused" || ko "a reply to the locked thread was taken"
lm POST post/lock "{\"post_id\":$lm_cats_id,\"locked\":false}" >/dev/null
until_true 45 '[ "$(p_locked)" = "False" ]' && ok "a moderator's unlock reaches PrivaPub" || ko "a moderator's unlock did not reach PrivaPub"
lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":true,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_lemmy19 reaches PrivaPub (blocked_by)" || ko "the community's ban did not reach PrivaPub"
[ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken"
lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub"
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ -z "$(p_home_has "$lm_cats_thread")" ]' && ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub"
echo " statistics"
stats_check lemmy19.test lemmy
+4 -4
View File
@@ -110,16 +110,16 @@
},
{
"id": "G-0008",
"title": "A direct message to an Mbin account never arrives: Mbin takes a private message only as a ChatMessage",
"title": "A direct message to a Lemmy 0.19 or Mbin account never arrives: both take a private message only as a ChatMessage",
"match": {
"feature": "deliver\\.direct",
"observer": "mbin"
"observer": "(mbin|lemmy19)"
},
"kind": "server",
"phase": "P3",
"code": "mbin 1.10.1 src/Service/ActivityPub/ActivityPubContent.php getVisibility(): a Note neither public nor to the author's followers throws 'PM: not implemented'; only CreateHandler's ChatMessage branch makes a private message",
"code": "lemmy 0.19.20 crates/apub/src/protocol/objects/chat_message.rs: ChatMessageType has ChatMessage alone, so a Create{Note} to a person answers 400 (Lemmy 1.0 takes a Note); mbin 1.10.1 src/Service/ActivityPub/ActivityPubContent.php getVisibility(): a Note neither public nor to the author's followers throws 'PM: not implemented'",
"opened": "2026-10-05",
"status": "open",
"note": "Mbin's actors advertise nothing that says so, and PrivaPub never decides by a server's software name, so choosing ChatMessage for a recipient waits for the owner. Mbin's API also never starts a conversation with an account elsewhere, so nothing comes the other way through it."
"note": "Neither server's actors say so, and PrivaPub never decides by a server's software name, so choosing ChatMessage for a recipient waits for the owner. Pleroma and Akkoma file a ChatMessage as a chat apart from direct messages, so sending both would show them twice. Mbin's API also never starts a conversation with an account elsewhere."
}
]